Skip to content

Commit

Permalink
feat: add Reflection node in iFrame to isolate styles (#780)
Browse files Browse the repository at this point in the history
* chore: update package deps

* chore: update package deps

* feat: add Reflection node

* refactor: rename useRef to useCallbackRef

* refactor: comments and code

* refactor: frame prop types and dom utils

* fix: keep frame size synced with reflected node

* refactor: split frame and dimensions into component and hooks respectively

* fix: keep framed styles in sync with parent document

* chore: remove unused style components

* fix: include bounds in frame dimensions

* test: package entry exports

* test: add tests for Element component

* test: add tests for Frame component

* test: add tests for Mirror component

* test: add tests for Reflection component

* test: add test for useDimensions hook

* test: add tests for useMirror hook

* test: add test for useObserver hook

* test: add tests for useRef hook

* test: add tests for useRenderTrigger hook

* test: address missing coverage

* chore: update id for parent document mirror styles
  • Loading branch information
iamogbz authored May 7, 2022
1 parent f25d270 commit a21a38d
Show file tree
Hide file tree
Showing 40 changed files with 1,364 additions and 453 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"class-methods-use-this": 0,
"no-console": 1,
"no-param-reassign": ["error", { "props": false }],
"no-unused-vars": ["warn", {
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_"
}],
"no-trailing-spaces": 2,
Expand Down
23 changes: 23 additions & 0 deletions config/mocks/DOMRect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export default class DOMRect {
bottom = 0;
left = 0;
right = 0;
top = 0;
constructor(
// eslint-disable-next-line no-unused-vars
public x = 0,
// eslint-disable-next-line no-unused-vars
public y = 0,
// eslint-disable-next-line no-unused-vars
public width = 0,
// eslint-disable-next-line no-unused-vars
public height = 0,
) {
this.left = x;
this.top = y;
this.right = x + width;
this.bottom = y + height;
}
}

Object.assign(window, { DOMRect });
11 changes: 11 additions & 0 deletions config/mocks/Range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Range.prototype.getBoundingClientRect = () => new DOMRect();
/*
Range.prototype.getClientRects = function () {
const clientRects = [this.getBoundingClientRect()];
return {
item: (i: number) => clientRects[i] ?? null,
length: 1,
[Symbol.iterator]: () => clientRects.values(),
};
};
*/
13 changes: 13 additions & 0 deletions config/mocks/ResizeObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default class ResizeObserver {
observe() {
// do nothing
}
unobserve() {
// do nothing
}
disconnect() {
// do nothing
}
}

Object.assign(window, { ResizeObserver });
6 changes: 6 additions & 0 deletions config/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { configure } from "@testing-library/react";
import "./mocks/DOMRect";
import "./mocks/Range";
import "./mocks/ResizeObserver";

configure({ testIdAttribute: "data-test-id" });
2 changes: 1 addition & 1 deletion demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function App(): JSX.Element {
</button>

<div className="Demo">
<div ref={ref}>
<div className="Frame" ref={ref}>
<input className="Input" placeholder="type something..." />
<div style={{ padding: 10 }}>Mirror mirror in my dom</div>
<Clock />
Expand Down
2 changes: 2 additions & 0 deletions demo/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
font-family: sans-serif;
text-align: center;
border: none;
padding: 0;
margin: 0;
}

.Input, .Button {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"setupFilesAfterEnv": [
"./config/setupTests.ts"
],
Expand All @@ -63,6 +64,7 @@
"./artifacts/",
"./node_modules/"
],
"testRegex": "(/__tests__/.+(\\.|/)(test|spec))\\.[jt]sx?$",
"coverageDirectory": "./artifacts/coverage"
},
"commitlint": {
Expand Down
62 changes: 62 additions & 0 deletions src/__mocks__.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export function addDomNode() {
const domNode = document.createElement("div");
document.body.appendChild(domNode);
const node0 = document.createComment("comment node");
document.appendChild(node0);
const node1 = document.createElement("div");
domNode.appendChild(node1);
node1.className = "class1 one";
node1.setAttribute("attr", "[value");
const node2 = document.createElement("input");
node1.appendChild(node2);
node2.className = "class2 two";
node2.value = "input-value";
const node3 = document.createTextNode("text content");
domNode.appendChild(node3);
return domNode;
}

export function addDomStyles() {
const domStyle = document.createElement("style");
document.head.appendChild(domStyle);
domStyle.innerHTML = `
@charset "utf-8";
@font-face {
font-family: "Open Sans";
}
body, .mirrorFrame:not(*) {
font-family: "san-serif";
font-size: 1.2em;
}
:is(::after), ::before {
position: absolute;
}
:where(::slotted(span)) {
border: none;
}
::after {
content: '';
}
.mirrorFrame::before {
content: 'mock text';
}
.class1.one, .class2.two {
height: 10px;
}
.class2.two {
font-size: 1.3em;
display: block;
width: 40px;
margin: 0 auto;
}
.class3.three::after {
background: red;
width: 5px;
height: 5px;
}
.class1.one[attr^="[val"] .class2.two {
width: 20px;
}
`;
return domStyle;
}
13 changes: 13 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
describe("Entry", () => {
it("exports expected modules", async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
expect(require("../")).toMatchInlineSnapshot(`
Object {
"Frame": [Function],
"Mirror": [Function],
"Reflection": [Function],
"useMirror": [Function],
}
`);
});
});
89 changes: 0 additions & 89 deletions src/clone.ts

This file was deleted.

28 changes: 28 additions & 0 deletions src/components/Element.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from "react";

export type ElementProps<T extends string> =
T extends keyof JSX.IntrinsicElements
? JSX.IntrinsicElements[T]
: JSX.IntrinsicElements[keyof JSX.IntrinsicElements];

type DOMElement<T extends string> = T extends keyof HTMLElementTagNameMap
? HTMLElementTagNameMap[T]
: never;

type DOMElementProps<T extends string> = {
tagName: T;
domRef?: React.Ref<DOMElement<T>>;
} & ElementProps<T>;

export function Element<T extends string>({
children,
domRef,
tagName,
...props
}: DOMElementProps<T>) {
return React.createElement(
tagName.toLowerCase(),
{ ...props, ref: domRef },
...(Array.isArray(children) ? children : [children]),
);
}
63 changes: 63 additions & 0 deletions src/components/Frame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from "react";
import { getAllStyleRules } from "../utils/dom";
import { useObserver } from "../hooks/useObserver";
import { IFrame, IFrameProps } from "./IFrame";
import { Style } from "./Styles";

export type FrameProps = Omit<IFrameProps, "getMountNode">;

/**
* Used to wrap and isolate reflection from rest of document
*/
export function Frame({ children, ...frameProps }: FrameProps) {
return (
<IFrame {...frameProps}>
<ResetStyle />
<DocumentStyle />
{children}
</IFrame>
);
}

/**
* Copy of the current document style sheets to be used in framing
*/
function DocumentStyle() {
const [rules, setRules] = React.useState(getAllStyleRules);

const onUpdate = React.useCallback(
function updateRules() {
const newRules = getAllStyleRules();
if (JSON.stringify(rules) === JSON.stringify(newRules)) return;
setRules(newRules);
},
[rules],
);

useObserver({
ObserverClass: MutationObserver,
onUpdate,
initOptions: {
attributeFilter: ["class"],
attributes: true,
characterData: true,
childList: true,
subtree: true,
},
target: window.document,
});

return <Style id="parent-document-mirror-stylesheets" rules={rules} />;
}

/**
* https://www.webfx.com/blog/web-design/should-you-reset-your-css
*/
function ResetStyle() {
return (
<link
rel="stylesheet"
href="https://necolas.github.io/normalize.css/8.0.1/normalize.css"
/>
);
}
25 changes: 25 additions & 0 deletions src/components/IFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as React from "react";
import { usePortal } from "../hooks/usePortal";
import { useCallbackRef } from "../hooks/useRef";
import { ElementProps } from "./Element";

export interface IFrameProps extends ElementProps<"iframe"> {
/** Get the iframe content node the react children will be rendered into */
getMountNode?: (_?: Window) => Element | undefined;
}

export function IFrame({
children,
getMountNode = (window) => window?.document?.body,
...props
}: IFrameProps) {
const [iframe, ref] = useCallbackRef<HTMLIFrameElement>();
const mountNode = getMountNode(iframe?.contentWindow ?? undefined);
const portal = usePortal({ source: children, target: mountNode });

return (
<iframe {...props} ref={ref}>
{portal}
</iframe>
);
}
Loading

0 comments on commit a21a38d

Please sign in to comment.