Skip to content

Commit cb11103

Browse files
committed
Improve support for CSS in JS libs, add test for BaseWeb
1 parent 0c3c5ff commit cb11103

File tree

11 files changed

+1052
-60
lines changed

11 files changed

+1052
-60
lines changed

e2e/baseweb/.ladle/components.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { Provider as StyletronProvider } from "styletron-react";
2+
import { Client as Styletron } from "styletron-engine-monolithic";
3+
import { LightTheme, DarkTheme, BaseProvider } from "baseui";
4+
import type { GlobalProvider } from "@ladle/react";
5+
6+
const engine = new Styletron();
7+
8+
export const Provider: GlobalProvider = ({ children, globalState }) => (
9+
<StyletronProvider value={engine}>
10+
<BaseProvider
11+
theme={{
12+
...(globalState.theme === "dark" ? DarkTheme : LightTheme),
13+
direction: globalState.rtl ? "rtl" : "ltr",
14+
}}
15+
>
16+
{children}
17+
</BaseProvider>
18+
</StyletronProvider>
19+
);

e2e/baseweb/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "test-baseweb",
3+
"version": "0.0.76",
4+
"license": "MIT",
5+
"private": true,
6+
"scripts": {
7+
"serve": "ladle serve -p 61111",
8+
"serve-prod": "ladle preview -p 61111",
9+
"build": "ladle build",
10+
"lint": "echo 'no lint'",
11+
"test-dev": "cross-env TYPE=dev pnpm exec playwright test",
12+
"test-prod": "cross-env TYPE=prod pnpm exec playwright test",
13+
"test": "pnpm test-dev && pnpm test-prod"
14+
},
15+
"dependencies": {
16+
"@ladle/playwright-config": "workspace:*",
17+
"@ladle/react": "workspace:*",
18+
"@playwright/test": "^1.37.1",
19+
"autoprefixer": "^10.4.15",
20+
"baseui": "^13.0.0",
21+
"cross-env": "^7.0.3",
22+
"postcss": "^8.4.29",
23+
"react": "^18.2.0",
24+
"react-dom": "^18.2.0",
25+
"styletron-engine-monolithic": "^1.0.0",
26+
"styletron-react": "^6.1.0",
27+
"tailwindcss": "^3.3.3"
28+
}
29+
}

e2e/baseweb/playwright.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import getPlaywrightConfig from "@ladle/playwright-config";
2+
3+
export default getPlaywrightConfig({
4+
port: 61111,
5+
});

e2e/baseweb/src/hello.stories.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Story } from "@ladle/react";
2+
import { Button } from "baseui/button";
3+
import { StarRating } from "baseui/rating";
4+
import { useState } from "react";
5+
6+
export const World: Story = () => {
7+
const [open, setOpen] = useState(false);
8+
const [value, setValue] = useState(3);
9+
return (
10+
<>
11+
<h1>Hello world!</h1>
12+
<Button onClick={() => setOpen(!open)}>Click me!</Button>
13+
{open && (
14+
<div>
15+
<StarRating
16+
numItems={5}
17+
onChange={(data) => setValue(data.value)}
18+
size={22}
19+
value={value}
20+
/>
21+
</div>
22+
)}
23+
<p>
24+
<button
25+
data-testid="add"
26+
onClick={() => {
27+
const sheet = new CSSStyleSheet();
28+
sheet.insertRule(`h1 { background-color: pink; }`);
29+
}}
30+
>
31+
add
32+
</button>
33+
</p>
34+
<p>
35+
<button
36+
data-testid="remove"
37+
onClick={() => {
38+
const sheet = new CSSStyleSheet();
39+
sheet.deleteRule(
40+
document.head.querySelectorAll("style[data-debug-css]").length -
41+
1,
42+
);
43+
}}
44+
>
45+
remove
46+
</button>
47+
</p>
48+
</>
49+
);
50+
};

e2e/baseweb/tests/hello.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
test("Base Web and CSSStyleSheet works correctly without iframe", async ({
4+
page,
5+
}) => {
6+
await page.goto("/?story=hello--world");
7+
await expect(page.locator('[data-baseweb="button"]')).toHaveCSS(
8+
"border-bottom-left-radius",
9+
"8px",
10+
);
11+
await expect(page.locator("h1")).toHaveCSS(
12+
"background-color",
13+
"rgba(0, 0, 0, 0)",
14+
);
15+
const button = await page.locator('[data-testid="add"]');
16+
await button.click();
17+
await expect(page.locator("h1")).toHaveCSS(
18+
"background-color",
19+
"rgb(255, 192, 203)",
20+
);
21+
const buttonRemove = await page.locator('[data-testid="remove"]');
22+
await buttonRemove.click();
23+
await expect(page.locator("h1")).toHaveCSS(
24+
"background-color",
25+
"rgba(0, 0, 0, 0)",
26+
);
27+
});
28+
29+
test("Base Web and CSSStyleSheet works correctly in iframe", async ({
30+
page,
31+
}) => {
32+
await page.goto("/?story=hello--world&width=414");
33+
await expect(
34+
page.frameLocator("iframe").locator('[data-baseweb="button"]'),
35+
).toHaveCSS("border-bottom-left-radius", "8px");
36+
await expect(page.frameLocator("iframe").locator("h1")).toHaveCSS(
37+
"background-color",
38+
"rgba(0, 0, 0, 0)",
39+
);
40+
const button = await page
41+
.frameLocator("iframe")
42+
.locator('[data-testid="add"]');
43+
await button.click();
44+
await expect(page.frameLocator("iframe").locator("h1")).toHaveCSS(
45+
"background-color",
46+
"rgb(255, 192, 203)",
47+
);
48+
const buttonRemove = await page
49+
.frameLocator("iframe")
50+
.locator('[data-testid="remove"]');
51+
await buttonRemove.click();
52+
await expect(page.frameLocator("iframe").locator("h1")).toHaveCSS(
53+
"background-color",
54+
"rgba(0, 0, 0, 0)",
55+
);
56+
});

e2e/baseweb/vite.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default {
2+
server: {
3+
open: "none",
4+
host: "127.0.0.1",
5+
},
6+
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"e2e/playwright",
3030
"e2e/playwright-config",
3131
"e2e/programmatic",
32-
"e2e/provider"
32+
"e2e/provider",
33+
"e2e/baseweb"
3334
],
3435
"devDependencies": {
3536
"@changesets/changelog-github": "^0.4.8",

packages/ladle/lib/app/src/story.tsx

Lines changed: 2 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from "react";
22
import { MDXProvider } from "@mdx-js/react";
3+
import SynchronizeHead from "./synchronize-head";
34
import ErrorBoundary from "./error-boundary";
45
import { stories, Provider } from "virtual:generated-list";
56
import { Ring } from "./icons";
@@ -9,8 +10,7 @@ import config from "./get-config";
910
import StoryNotFound from "./story-not-found";
1011
import { ModeState } from "../../shared/types";
1112
import { CodeHighlight } from "./addons/source";
12-
import { redirectKeyup, redirectKeydown } from "./redirect-events";
13-
import { Frame, useFrame } from "./iframe";
13+
import { Frame } from "./iframe";
1414
import { set, reset } from "./mock-date";
1515

1616
const StoryFrame = ({
@@ -40,63 +40,6 @@ const StoryFrame = ({
4040
);
4141
};
4242

43-
// detecting parent's document.head changes, so we can apply the same CSS for
44-
// the iframe, for CSS in JS we could target correct document directly but
45-
// import './foo.css' always ends up in the parent only
46-
const SynchronizeHead = ({
47-
active,
48-
children,
49-
rtl,
50-
width,
51-
}: {
52-
active: boolean;
53-
children: JSX.Element;
54-
rtl: boolean;
55-
width: number;
56-
}) => {
57-
const { window: storyWindow, document: iframeDocument } = useFrame();
58-
const syncHead = () => {
59-
if (!storyWindow) return;
60-
storyWindow.document.documentElement.setAttribute(
61-
"dir",
62-
rtl ? "rtl" : "ltr",
63-
);
64-
[...(document.head.children as any)].forEach((child) => {
65-
if (
66-
child.tagName === "STYLE" ||
67-
(child.tagName === "LINK" &&
68-
(child.getAttribute("type") === "text/css" ||
69-
child.getAttribute("rel") === "stylesheet"))
70-
) {
71-
storyWindow.document.head.appendChild(
72-
child.cloneNode(true),
73-
) as HTMLStyleElement;
74-
}
75-
});
76-
};
77-
React.useEffect(() => {
78-
if (active) {
79-
syncHead();
80-
iframeDocument?.addEventListener("keydown", redirectKeydown);
81-
iframeDocument?.addEventListener("keyup", redirectKeyup);
82-
const observer = new MutationObserver(() => syncHead());
83-
document.documentElement.setAttribute("data-iframed", `${width}`);
84-
observer.observe(document.head, {
85-
subtree: true,
86-
characterData: true,
87-
childList: true,
88-
});
89-
return () => {
90-
observer && observer.disconnect();
91-
iframeDocument?.removeEventListener("keydown", redirectKeydown);
92-
iframeDocument?.removeEventListener("keyup", redirectKeyup);
93-
};
94-
}
95-
return;
96-
}, [active, rtl, iframeDocument]);
97-
return children;
98-
};
99-
10043
const Story = ({
10144
globalState,
10245
dispatch,
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// detecting parent's document.head changes, so we can apply the same CSS for
2+
// the iframe, for CSS in JS using CSSStyleSheet API we transform it into
3+
// style tags and append them to the iframe's document.head
4+
// also re-fire hotkeys events from iframe to parent
5+
6+
import * as React from "react";
7+
import { useFrame } from "./iframe";
8+
import { redirectKeyup, redirectKeydown } from "./redirect-events";
9+
const CSS_ATTR = "data-debug-css";
10+
11+
function addStyleElement(rule: string, doc: Document, index?: number): number {
12+
const existingTags = doc.head.querySelectorAll(`style[${CSS_ATTR}]`);
13+
const tag = doc.createElement("style");
14+
tag.setAttribute(CSS_ATTR, "true");
15+
tag.appendChild(doc.createTextNode(rule));
16+
if (index && existingTags[index]) {
17+
existingTags[index].after(tag);
18+
return index + 1;
19+
} else {
20+
doc.head.appendChild(tag);
21+
return existingTags.length;
22+
}
23+
}
24+
25+
function deleteStyleElement(doc: Document, index?: number) {
26+
const existingTags = doc.head.querySelectorAll(`style[${CSS_ATTR}]`);
27+
console.log(existingTags);
28+
if (index != undefined && existingTags[index]) {
29+
existingTags[index].remove();
30+
}
31+
}
32+
33+
const SynchronizeHead = ({
34+
active,
35+
children,
36+
rtl,
37+
width,
38+
}: {
39+
active: boolean;
40+
children: JSX.Element;
41+
rtl: boolean;
42+
width: number;
43+
}) => {
44+
const { window: storyWindow, document: iframeDocument } = useFrame();
45+
const syncHead = () => {
46+
if (!storyWindow) return;
47+
storyWindow.document.documentElement.setAttribute(
48+
"dir",
49+
rtl ? "rtl" : "ltr",
50+
);
51+
[...(document.head.children as any)].forEach((child) => {
52+
if (
53+
child.tagName === "STYLE" ||
54+
(child.tagName === "LINK" &&
55+
(child.getAttribute("type") === "text/css" ||
56+
child.getAttribute("rel") === "stylesheet"))
57+
) {
58+
const exists = [...(storyWindow.document.head.children as any)].some(
59+
(el) => {
60+
if (el.tagName === "LINK") {
61+
return el.getAttribute("href") === child.getAttribute("href");
62+
}
63+
if (el.tagName === "STYLE") {
64+
return el.innerHTML === child.innerHTML;
65+
}
66+
return false;
67+
},
68+
);
69+
if (exists) return;
70+
storyWindow.document.head.appendChild(
71+
child.cloneNode(true),
72+
) as HTMLStyleElement;
73+
}
74+
});
75+
};
76+
77+
React.useEffect(() => {
78+
const originalInsertRule = window.CSSStyleSheet.prototype.insertRule;
79+
const originalDeleteRule = window.CSSStyleSheet.prototype.deleteRule;
80+
81+
window.CSSStyleSheet.prototype.insertRule = function (rule, index) {
82+
const retVal = addStyleElement(rule, document, index);
83+
if (active && iframeDocument) {
84+
return addStyleElement(rule, iframeDocument, index);
85+
}
86+
return retVal;
87+
};
88+
89+
window.CSSStyleSheet.prototype.deleteRule = function (index) {
90+
deleteStyleElement(document, index);
91+
if (active && iframeDocument) {
92+
deleteStyleElement(iframeDocument, index);
93+
}
94+
};
95+
96+
return () => {
97+
window.CSSStyleSheet.prototype.insertRule = originalInsertRule;
98+
window.CSSStyleSheet.prototype.deleteRule = originalDeleteRule;
99+
};
100+
}, []);
101+
102+
React.useEffect(() => {
103+
if (active) {
104+
syncHead();
105+
iframeDocument?.addEventListener("keydown", redirectKeydown);
106+
iframeDocument?.addEventListener("keyup", redirectKeyup);
107+
const observer = new MutationObserver(() => syncHead());
108+
document.documentElement.setAttribute("data-iframed", `${width}`);
109+
observer.observe(document.head, {
110+
subtree: true,
111+
characterData: true,
112+
childList: true,
113+
});
114+
return () => {
115+
observer && observer.disconnect();
116+
iframeDocument?.removeEventListener("keydown", redirectKeydown);
117+
iframeDocument?.removeEventListener("keyup", redirectKeyup);
118+
};
119+
}
120+
return;
121+
}, [active, rtl, iframeDocument]);
122+
return children;
123+
};
124+
125+
export default SynchronizeHead;

0 commit comments

Comments
 (0)