Skip to content

Improve support for CSS in JS libs, add tests for BaseWeb #488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions e2e/baseweb/.ladle/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Provider as StyletronProvider } from "styletron-react";
import { Client as Styletron } from "styletron-engine-monolithic";
import { LightTheme, DarkTheme, BaseProvider } from "baseui";
import type { GlobalProvider } from "@ladle/react";

const engine = new Styletron();

export const Provider: GlobalProvider = ({ children, globalState }) => (
<StyletronProvider value={engine}>
<BaseProvider
theme={{
...(globalState.theme === "dark" ? DarkTheme : LightTheme),
direction: globalState.rtl ? "rtl" : "ltr",
}}
>
{children}
</BaseProvider>
</StyletronProvider>
);
29 changes: 29 additions & 0 deletions e2e/baseweb/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "test-baseweb",
"version": "0.0.76",
"license": "MIT",
"private": true,
"scripts": {
"serve": "ladle serve -p 61111",
"serve-prod": "ladle preview -p 61111",
"build": "ladle build",
"lint": "echo 'no lint'",
"test-dev": "cross-env TYPE=dev pnpm exec playwright test",
"test-prod": "cross-env TYPE=prod pnpm exec playwright test",
"test": "pnpm test-dev && pnpm test-prod"
},
"dependencies": {
"@ladle/playwright-config": "workspace:*",
"@ladle/react": "workspace:*",
"@playwright/test": "^1.37.1",
"autoprefixer": "^10.4.15",
"baseui": "^13.0.0",
"cross-env": "^7.0.3",
"postcss": "^8.4.29",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styletron-engine-monolithic": "^1.0.0",
"styletron-react": "^6.1.0",
"tailwindcss": "^3.3.3"
}
}
5 changes: 5 additions & 0 deletions e2e/baseweb/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import getPlaywrightConfig from "@ladle/playwright-config";

export default getPlaywrightConfig({
port: 61111,
});
50 changes: 50 additions & 0 deletions e2e/baseweb/src/hello.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Story } from "@ladle/react";
import { Button } from "baseui/button";
import { StarRating } from "baseui/rating";
import { useState } from "react";

export const World: Story = () => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(3);
return (
<>
<h1>Hello world!</h1>
<Button onClick={() => setOpen(!open)}>Click me!</Button>
{open && (
<div>
<StarRating
numItems={5}
onChange={(data) => setValue(data.value)}
size={22}
value={value}
/>
</div>
)}
<p>
<button
data-testid="add"
onClick={() => {
const sheet = new CSSStyleSheet();
sheet.insertRule(`h1 { background-color: pink; }`);
}}
>
add
</button>
</p>
<p>
<button
data-testid="remove"
onClick={() => {
const sheet = new CSSStyleSheet();
sheet.deleteRule(
document.head.querySelectorAll("style[data-debug-css]").length -
1,
);
}}
>
remove
</button>
</p>
</>
);
};
56 changes: 56 additions & 0 deletions e2e/baseweb/tests/hello.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from "@playwright/test";

test("Base Web and CSSStyleSheet works correctly without iframe", async ({
page,
}) => {
await page.goto("/?story=hello--world");
await expect(page.locator('[data-baseweb="button"]')).toHaveCSS(
"border-bottom-left-radius",
"8px",
);
await expect(page.locator("h1")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
const button = await page.locator('[data-testid="add"]');
await button.click();
await expect(page.locator("h1")).toHaveCSS(
"background-color",
"rgb(255, 192, 203)",
);
const buttonRemove = await page.locator('[data-testid="remove"]');
await buttonRemove.click();
await expect(page.locator("h1")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
});

test("Base Web and CSSStyleSheet works correctly in iframe", async ({
page,
}) => {
await page.goto("/?story=hello--world&width=414");
await expect(
page.frameLocator("iframe").locator('[data-baseweb="button"]'),
).toHaveCSS("border-bottom-left-radius", "8px");
await expect(page.frameLocator("iframe").locator("h1")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
const button = await page
.frameLocator("iframe")
.locator('[data-testid="add"]');
await button.click();
await expect(page.frameLocator("iframe").locator("h1")).toHaveCSS(
"background-color",
"rgb(255, 192, 203)",
);
const buttonRemove = await page
.frameLocator("iframe")
.locator('[data-testid="remove"]');
await buttonRemove.click();
await expect(page.frameLocator("iframe").locator("h1")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
});
6 changes: 6 additions & 0 deletions e2e/baseweb/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default {
server: {
open: "none",
host: "127.0.0.1",
},
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"e2e/playwright",
"e2e/playwright-config",
"e2e/programmatic",
"e2e/provider"
"e2e/provider",
"e2e/baseweb"
],
"devDependencies": {
"@changesets/changelog-github": "^0.4.8",
Expand Down
61 changes: 2 additions & 59 deletions packages/ladle/lib/app/src/story.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";
import { MDXProvider } from "@mdx-js/react";
import SynchronizeHead from "./synchronize-head";
import ErrorBoundary from "./error-boundary";
import { stories, Provider } from "virtual:generated-list";
import { Ring } from "./icons";
Expand All @@ -9,8 +10,7 @@ import config from "./get-config";
import StoryNotFound from "./story-not-found";
import { ModeState } from "../../shared/types";
import { CodeHighlight } from "./addons/source";
import { redirectKeyup, redirectKeydown } from "./redirect-events";
import { Frame, useFrame } from "./iframe";
import { Frame } from "./iframe";
import { set, reset } from "./mock-date";

const StoryFrame = ({
Expand Down Expand Up @@ -40,63 +40,6 @@ const StoryFrame = ({
);
};

// detecting parent's document.head changes, so we can apply the same CSS for
// the iframe, for CSS in JS we could target correct document directly but
// import './foo.css' always ends up in the parent only
const SynchronizeHead = ({
active,
children,
rtl,
width,
}: {
active: boolean;
children: JSX.Element;
rtl: boolean;
width: number;
}) => {
const { window: storyWindow, document: iframeDocument } = useFrame();
const syncHead = () => {
if (!storyWindow) return;
storyWindow.document.documentElement.setAttribute(
"dir",
rtl ? "rtl" : "ltr",
);
[...(document.head.children as any)].forEach((child) => {
if (
child.tagName === "STYLE" ||
(child.tagName === "LINK" &&
(child.getAttribute("type") === "text/css" ||
child.getAttribute("rel") === "stylesheet"))
) {
storyWindow.document.head.appendChild(
child.cloneNode(true),
) as HTMLStyleElement;
}
});
};
React.useEffect(() => {
if (active) {
syncHead();
iframeDocument?.addEventListener("keydown", redirectKeydown);
iframeDocument?.addEventListener("keyup", redirectKeyup);
const observer = new MutationObserver(() => syncHead());
document.documentElement.setAttribute("data-iframed", `${width}`);
observer.observe(document.head, {
subtree: true,
characterData: true,
childList: true,
});
return () => {
observer && observer.disconnect();
iframeDocument?.removeEventListener("keydown", redirectKeydown);
iframeDocument?.removeEventListener("keyup", redirectKeyup);
};
}
return;
}, [active, rtl, iframeDocument]);
return children;
};

const Story = ({
globalState,
dispatch,
Expand Down
125 changes: 125 additions & 0 deletions packages/ladle/lib/app/src/synchronize-head.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// detecting parent's document.head changes, so we can apply the same CSS for
// the iframe, for CSS in JS using CSSStyleSheet API we transform it into
// style tags and append them to the iframe's document.head
// also re-fire hotkeys events from iframe to parent

import * as React from "react";
import { useFrame } from "./iframe";
import { redirectKeyup, redirectKeydown } from "./redirect-events";
const CSS_ATTR = "data-debug-css";

function addStyleElement(rule: string, doc: Document, index?: number): number {
const existingTags = doc.head.querySelectorAll(`style[${CSS_ATTR}]`);
const tag = doc.createElement("style");
tag.setAttribute(CSS_ATTR, "true");
tag.appendChild(doc.createTextNode(rule));
if (index && existingTags[index]) {
existingTags[index].after(tag);
return index + 1;
} else {
doc.head.appendChild(tag);
return existingTags.length;
}
}

function deleteStyleElement(doc: Document, index?: number) {
const existingTags = doc.head.querySelectorAll(`style[${CSS_ATTR}]`);
console.log(existingTags);
if (index != undefined && existingTags[index]) {
existingTags[index].remove();
}
}

const SynchronizeHead = ({
active,
children,
rtl,
width,
}: {
active: boolean;
children: JSX.Element;
rtl: boolean;
width: number;
}) => {
const { window: storyWindow, document: iframeDocument } = useFrame();
const syncHead = () => {
if (!storyWindow) return;
storyWindow.document.documentElement.setAttribute(
"dir",
rtl ? "rtl" : "ltr",
);
[...(document.head.children as any)].forEach((child) => {
if (
child.tagName === "STYLE" ||
(child.tagName === "LINK" &&
(child.getAttribute("type") === "text/css" ||
child.getAttribute("rel") === "stylesheet"))
) {
const exists = [...(storyWindow.document.head.children as any)].some(
(el) => {
if (el.tagName === "LINK") {
return el.getAttribute("href") === child.getAttribute("href");
}
if (el.tagName === "STYLE") {
return el.innerHTML === child.innerHTML;
}
return false;
},
);
if (exists) return;
storyWindow.document.head.appendChild(
child.cloneNode(true),
) as HTMLStyleElement;
}
});
};

React.useEffect(() => {
const originalInsertRule = window.CSSStyleSheet.prototype.insertRule;
const originalDeleteRule = window.CSSStyleSheet.prototype.deleteRule;

window.CSSStyleSheet.prototype.insertRule = function (rule, index) {
const retVal = addStyleElement(rule, document, index);
if (active && iframeDocument) {
return addStyleElement(rule, iframeDocument, index);
}
return retVal;
};

window.CSSStyleSheet.prototype.deleteRule = function (index) {
deleteStyleElement(document, index);
if (active && iframeDocument) {
deleteStyleElement(iframeDocument, index);
}
};

return () => {
window.CSSStyleSheet.prototype.insertRule = originalInsertRule;
window.CSSStyleSheet.prototype.deleteRule = originalDeleteRule;
};
}, []);

React.useEffect(() => {
if (active) {
syncHead();
iframeDocument?.addEventListener("keydown", redirectKeydown);
iframeDocument?.addEventListener("keyup", redirectKeyup);
const observer = new MutationObserver(() => syncHead());
document.documentElement.setAttribute("data-iframed", `${width}`);
observer.observe(document.head, {
subtree: true,
characterData: true,
childList: true,
});
return () => {
observer && observer.disconnect();
iframeDocument?.removeEventListener("keydown", redirectKeydown);
iframeDocument?.removeEventListener("keyup", redirectKeyup);
};
}
return;
}, [active, rtl, iframeDocument]);
return children;
};

export default SynchronizeHead;
Loading