diff --git a/.github/workflows/pr-head.yaml b/.github/workflows/pr-head.yaml index b485d47..d0ad9a9 100644 --- a/.github/workflows/pr-head.yaml +++ b/.github/workflows/pr-head.yaml @@ -37,11 +37,6 @@ jobs: - name: Build Storybook run: npm run build-storybook - - name: Verify combined Storybook in Chromium - run: | - npx playwright install --with-deps chromium - node bin/storybook/smoke-test.mjs - - name: Build Form Configurator run: npm run build:form-configurator diff --git a/CLAUDE.md b/CLAUDE.md index 404c6bd..95cb344 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,17 +68,18 @@ Path aliases defined in `libs/ui/vitest.alias.ts` and `libs/ui/tsconfig.json`: ` **Build output** — Vite produces both ESM (`.js`, `preserveModules`) and CJS (`.cjs`) to `dist/libs/ui`. Post-build step (`scripts/postbuild-types.mjs`) patches `.d.ts` exports. React and `@emotion/*` are externalized. `bin/export-theme.js` serializes `defaultTheme` to JSON from the built dist; it runs automatically as part of `npm run storybook` and `npm run build-storybook`. -**Shared Storybook** — `npm run storybook` manages the React host (6006) and native Web Components preview (6007) together. Open only the host. Framework-neutral pages listed in `bin/storybook/shared-docs.json` appear at the root, before React, without a prefix. Other MDX and component stories receive a central `React/` title prefix; do not duplicate it in their titles. Keep React integration examples under React, even when their topic also has shared design guidance. The combined static artifact is `libs/ui/storybook-static`, with the child under `web-components/`. Use generated story IDs for links and run `storybook:check-links` after the combined build. Package-only validation uses `node bin/storybook/check-links.mjs --package` and indexes source stories without building previews. Do not regenerate the frozen legacy route inventory to conceal missing entries. Redirects support both original links and interim React-prefixed links to shared pages. The native catalog covers all 23 published atoms and two molecules. `node bin/storybook/smoke-test.mjs` verifies native rendering and representative interactions after the combined build. Keep Angular and Vue native-element harness coverage aligned with the supported catalog. - -**Package subpaths** — the published package exposes four explicit subpaths (wildcards are intentionally removed to prevent internal path coupling): - -| Subpath | Contents | -| ------------------------------ | ---------------------------------------------------------------------------------- | -| `gd-design-library` | Components, hooks, tokens, assets | -| `gd-design-library/renderer` | `renderA2UISpec`, `A2UI_RENDERER_COMPONENT_TYPES`, `A2UICustomComponentDefinition` | -| `gd-design-library/ai` | Prompt builders, schemas, discovery, A2UI spec tools | -| `gd-design-library/tokens` | Raw token modules and `defaultTheme` without the component tree | -| `gd-design-library/styles.css` | Global CSS reset + Google Fonts (Fira Sans/Code) — **must be imported explicitly** | +**Shared Storybook** — `npm run storybook` manages the React host (6006) and native Web Components preview (6007) together. Open only the host. Framework-neutral pages listed in `bin/storybook/shared-docs.json` appear at the root, before React, without a prefix. Other MDX and component stories receive a central `React/` title prefix; do not duplicate it in their titles. Keep React integration examples under React, even when their topic also has shared design guidance. The combined static artifact is `libs/ui/storybook-static`, with the child under `web-components/`. Use generated story IDs for links and run `storybook:check-links` after the combined build. Package-only validation uses `node bin/storybook/check-links.mjs --package` and indexes source stories without building previews. Do not regenerate the frozen legacy route inventory to conceal missing entries. Redirects support both original links and interim React-prefixed links to shared pages. The native catalog covers all 23 published atoms, two molecules, and five layout elements. `node bin/storybook/smoke-test.mjs` verifies native rendering and representative interactions after the combined build. Keep Angular and Vue native-element harness coverage aligned with the supported catalog. + +**Package subpaths** — the published package exposes five explicit subpaths (wildcards are intentionally removed to prevent internal path coupling): + +| Subpath | Contents | +| ------------------------------ | ------------------------------------------------------------------------------------- | +| `gd-design-library` | Components, hooks, tokens, assets | +| `gd-design-library/renderer` | `renderA2UISpec`, `A2UI_RENDERER_COMPONENT_TYPES`, `A2UICustomComponentDefinition` | +| `gd-design-library/ai` | Prompt builders, schemas, discovery, A2UI spec tools | +| `gd-design-library/tokens` | Raw token modules and `defaultTheme` without the component tree | +| `gd-design-library/icons` | `iconCatalog` SVG data shared by React `Icon` and Web Components `gd-icon` (no React) | +| `gd-design-library/styles.css` | Global CSS reset + Google Fonts (Fira Sans/Code) — **must be imported explicitly** | `styles.css` is not auto-imported by the main entry. App entry points must add `import 'gd-design-library/styles.css'` explicitly. diff --git a/Dockerfile.storybook b/Dockerfile.storybook index 8670b3f..2aa5549 100644 --- a/Dockerfile.storybook +++ b/Dockerfile.storybook @@ -3,7 +3,6 @@ WORKDIR /app COPY package.json package-lock.json nx.json tsconfig.base.json .npmrc .prettierrc eslint.config.mjs ./ COPY libs/ui/ libs/ui/ -COPY libs/design-core/ libs/design-core/ COPY libs/web-components/ libs/web-components/ COPY libs/form-configurator/ libs/form-configurator/ COPY libs/react-native/package.json libs/react-native/package.json diff --git a/README.md b/README.md index d62c857..b261918 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ Not published, and under active investigation: | Package | Description | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `gd-design-core` (`libs/design-core`) | Framework-agnostic state stores + token resolution. No React, no Lit, no `gd-design-library` dependency | | `web-components` (`libs/web-components`) | Lit custom-element port GridKit atoms. `private: true` — see [Web Components](libs/web-components/README.md) | ## Quick start @@ -73,7 +72,7 @@ Shared **Introduction**, **Theme & Tokens**, and framework-neutral **Patterns & appear at the top level, before the framework catalogs. One entry point contains two peer catalogs: **React** (components and React-specific guides) and **Web Components** (native custom-element stories). The Web Components catalog currently -contains native stories and controls for all 23 published atoms and two molecules. +contains native stories and controls for all 23 published atoms, two molecules, and five layout elements. `npm run storybook` manages both preview servers automatically; open `http://localhost:6006`. Ports 6006 and 6007 must be free. There is no separate Web Components startup command. @@ -101,7 +100,7 @@ list to refresh when a story disappears. Link checks cover source documentation Native browser smoke checks run with `node bin/storybook/smoke-test.mjs` after the combined build. They render every native story and exercise representative custom events, state changes, semantic output, truncation, and popover dismissal. Angular and Vue harnesses render every -supported atom family and both molecules through native custom elements. +supported atom family, both molecules, and all five layout elements through native custom elements. ## Verification (gd-design-library) diff --git a/bin/generate-icon-catalog.ts b/bin/generate-icon-catalog.ts index c755546..e994ce5 100644 --- a/bin/generate-icon-catalog.ts +++ b/bin/generate-icon-catalog.ts @@ -25,4 +25,4 @@ const output = `export const iconCatalog = ${JSON.stringify(catalog, null, 2)} as const;\n\n` + `export type GridKitIconName = keyof typeof iconCatalog;\n`; -writeFileSync(resolve(import.meta.dirname, '../libs/design-core/src/icons/catalog.ts'), output); +writeFileSync(resolve(import.meta.dirname, '../libs/ui/src/icons/catalog.ts'), output); diff --git a/bin/storybook/check-links.mjs b/bin/storybook/check-links.mjs index d0d446f..52752f4 100644 --- a/bin/storybook/check-links.mjs +++ b/bin/storybook/check-links.mjs @@ -3,7 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node import { tmpdir } from 'node:os'; import { extname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { legacyReactIds, legacyRouteAliases, sharedDocIds } from './routes.mjs'; +import { legacyReactIds, sharedDocIds } from './routes.mjs'; import { validateLinks } from './link-validation.mjs'; const root = fileURLToPath(new URL('../../', import.meta.url)); @@ -49,7 +49,7 @@ if (built) { const errors = []; for (const id of legacyReactIds) { - const canonical = legacyRouteAliases[id] ?? (sharedDocIds.includes(id) ? id : `react-${id}`); + const canonical = sharedDocIds.includes(id) ? id : `react-${id}`; if (!indexes.react.entries[canonical]) errors.push(`Missing migrated entry: ${canonical}`); } for (const id of sharedDocIds) { @@ -67,30 +67,13 @@ if (built) { errors.push('Shared documentation must appear before React in the built sidebar order'); } if (!indexes.webComponents.entries['introduction-overview--overview']) errors.push('Missing Web Components overview'); - -// Docs is the component landing format in both catalogs: it keeps the overview, -// controls, source, and initial example together. Default remains an example -// story only where the source component defines one. -for (const [catalog, index] of Object.entries(indexes)) { - const componentEntries = Object.values(index.entries).filter((entry) => { - const title = entry.title.replace(/^React\//, ''); - return /^(Atoms|Molecules|Organisms|Layout & Structure|Templates)\//.test(title); - }); - const titles = [...new Set(componentEntries.map((entry) => entry.title))]; - for (const title of titles) { - const entries = componentEntries.filter((entry) => entry.title === title); - const docs = entries.find((entry) => entry.type === 'docs'); - if (!docs) errors.push(`${catalog}: ${title} is missing its Docs landing entry`); - if (built && docs && entries[0]?.id !== docs.id) - errors.push(`${catalog}: ${title} must open with Docs before its example stories`); - } -} for (const [group, names] of [ ['atoms', ['avatar', 'button', 'checkbox', 'input', 'select', 'typography']], ['molecules', ['counter', 'menu']], ]) { for (const name of names) { - for (const variant of ['docs']) { + const primaryStory = name === 'input' ? 'primary-default-with-label-and-helper-text' : 'default'; + for (const variant of ['docs', primaryStory]) { const id = `${group}-${name}--${variant}`; if (!indexes.webComponents.entries[id]) errors.push(`Missing native Web Components entry: ${id}`); } diff --git a/bin/storybook/legacy-react-ids.json b/bin/storybook/legacy-react-ids.json index 1c52f15..77d1cc5 100644 --- a/bin/storybook/legacy-react-ids.json +++ b/bin/storybook/legacy-react-ids.json @@ -116,17 +116,13 @@ "atoms-link--with-accessibility", "atoms-link--with-sizes", "atoms-link--with-underline", - "atoms-loader--custom-loader-tailwind-class-bounce-animation", "atoms-loader--default", "atoms-loader--default-tokens", "atoms-loader--docs", - "atoms-loader--inline-loader-button-variant", "atoms-loader--loader-names", "atoms-loader--loader-rounded", - "atoms-loader--loader-section-variant", "atoms-loader--loader-sizes", "atoms-loader--loader-variants-with-wrapper-view-as-header-tag", - "atoms-loader--section-loader-button-variant", "atoms-loader--with-accessibility", "atoms-select--color-error", "atoms-select--color-primary", diff --git a/bin/storybook/native-checks.mjs b/bin/storybook/native-checks.mjs index 78ec889..d37fbf7 100644 --- a/bin/storybook/native-checks.mjs +++ b/bin/storybook/native-checks.mjs @@ -1,303 +1,27 @@ import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { translateStorybookMetadata } from '../../libs/web-components/scripts/translate-storybook-metadata.mjs'; - -const reactArgTypes = translateStorybookMetadata( - JSON.parse( - await readFile( - new URL('../../libs/web-components/stories/react-storybook-arg-types.snapshot.json', import.meta.url), - 'utf8' - ) - ) -); /** Trusted browser input is required for native popover light-dismiss. */ -export async function checkNativeStories(page, base, webBase = `${base}/web-components`) { - const visit = async (url) => { - await page.goto(url); - await page.locator('#storybook-root > *').first().waitFor({ state: 'attached' }); - await page.evaluate(async () => { - await document.fonts.ready; - for (let i = 0; i < 3; i++) await new Promise(requestAnimationFrame); - }); - }; - const index = await (await page.request.get(`${webBase}/index.json`)).json(); - for (const [component, sourceArgTypes] of Object.entries(reactArgTypes)) { - const title = `${component === 'Counter' || component === 'Menu' ? 'Molecules' : 'Atoms'}/${component}`; - const entry = Object.values(index.entries).find( - (candidate) => candidate.type === 'story' && candidate.title === title - ); - assert.ok(entry, `${title} must have a Web Component story`); - assert.ok( - Object.values(index.entries).some((candidate) => candidate.type === 'docs' && candidate.title === title), - `${title} must expose an autodocs entry` - ); - await visit(`${webBase}/iframe.html?id=${entry.id}&viewMode=story`); - const targetArgTypes = await page.evaluate(async (storyId) => { - const preview = window.__STORYBOOK_PREVIEW__; - await preview.storeInitializationPromise; - const story = await preview.storyStoreValue.loadStory({ storyId }); - return JSON.parse(JSON.stringify(story.argTypes ?? {})); - }, entry.id); - assert.deepEqual( - Object.keys(targetArgTypes).sort(), - Object.keys(sourceArgTypes).sort(), - `${title} control inventory must match React` - ); - for (const [name, source] of Object.entries(sourceArgTypes)) { - const target = targetArgTypes[name]; - assert.equal(target.description, source.description, `${title}.${name} description must match React`); - assert.deepEqual(target.options, source.options, `${title}.${name} options must match React`); - assert.deepEqual(target.table, source.table, `${title}.${name} table metadata must match React`); - assert.deepEqual(target.if, source.if, `${title}.${name} conditional visibility must match React`); - } - } - await visit(`${webBase}/iframe.html?id=atoms-inputfile--default&viewMode=story`); - await page.locator('gd-input-file').waitFor({ state: 'attached' }); - const webInputFileButton = await page.evaluate(() => { - const inputFile = document.querySelector('gd-input-file'); - const button = inputFile.shadowRoot.querySelector('gd-button').shadowRoot.querySelector('button'); - const rect = button.getBoundingClientRect(); - const style = getComputedStyle(button); - return { - width: rect.width, - height: rect.height, - color: style.color, - background: style.backgroundColor, - border: style.border, - padding: style.padding, - fontSize: style.fontSize, - fontWeight: style.fontWeight, - lineHeight: style.lineHeight, - }; - }); - await visit(`${base}/iframe.html?id=react-atoms-inputfile--default&viewMode=story`); - await page.locator('#storybook-root button').waitFor({ state: 'attached' }); - const reactInputFileButton = await page.evaluate(() => { - const button = document.querySelector('#storybook-root button'); - const rect = button.getBoundingClientRect(); - const style = getComputedStyle(button); - return { - width: rect.width, - height: rect.height, - color: style.color, - background: style.backgroundColor, - border: style.border, - padding: style.padding, - fontSize: style.fontSize, - fontWeight: style.fontWeight, - lineHeight: style.lineHeight, - }; - }); - assert.ok( - Math.abs(webInputFileButton.width - reactInputFileButton.width) < 0.25, - `InputFile button width must match React (Web ${webInputFileButton.width}px, React ${reactInputFileButton.width}px)` - ); - assert.ok( - Math.abs(webInputFileButton.height - reactInputFileButton.height) < 0.25, - `InputFile button height must match React (Web ${webInputFileButton.height}px, React ${reactInputFileButton.height}px)` - ); - for (const property of ['color', 'background', 'border', 'padding', 'fontSize', 'fontWeight', 'lineHeight']) { - assert.equal( - webInputFileButton[property], - reactInputFileButton[property], - `InputFile button ${property} must match React` - ); - } - assert.equal( - reactArgTypes.Checkbox.children.table.category, - 'Content', - 'Checkbox children must remain in the Content group' - ); - await visit(`${webBase}/iframe.html?id=atoms-icon--registering-custom-icons&viewMode=story`); - await page.locator('gd-icon[name="projectOrbit"]').waitFor({ state: 'attached' }); - const customIconExample = await page.evaluate(async () => { - const icon = document.querySelector('gd-icon[name="projectOrbit"]'); - await icon.updateComplete; - const preview = window.__STORYBOOK_PREVIEW__; - await preview.storeInitializationPromise; - const story = await preview.storyStoreValue.loadStory({ storyId: 'atoms-icon--registering-custom-icons' }); - return { - viewBox: icon.shadowRoot.querySelector('svg')?.getAttribute('viewBox'), - hasCircle: Boolean(icon.shadowRoot.querySelector('circle')), - source: story.parameters.docs.source.code, - }; - }); - assert.equal(customIconExample.viewBox, '0 0 24 24'); - assert.equal(customIconExample.hasCircle, true, 'Custom icon example must render its registered SVG'); - assert.match(customIconExample.source, /registerCustomIcons/); - assert.match(customIconExample.source, / { - const rect = (element) => { - const bounds = element.getBoundingClientRect(); - return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; - }; - return { - container: rect(document.querySelector('.icon-library')), - names: [...document.querySelectorAll('.icon-library-item gd-typography')].map((label) => label.textContent), - items: [...document.querySelectorAll('.icon-library-item')].map(rect), - icons: [...document.querySelectorAll('.icon-library-item gd-icon')].map((icon) => - rect(icon.shadowRoot.querySelector('svg')) - ), - labels: [...document.querySelectorAll('.icon-library-item gd-typography')].map((label) => - rect(label.shadowRoot.querySelector('small')) - ), - }; - }); - await visit(`${base}/iframe.html?id=react-atoms-icon--all-icons&viewMode=story`); - await page.locator('[data-testid="Column"]').first().waitFor({ state: 'attached' }); - const reactIconLibrary = await page.evaluate(() => { - const rect = (element) => { - const bounds = element.getBoundingClientRect(); - return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; - }; - return { - container: rect(document.querySelector('[data-testid="FlexContainer"]')), - names: [...document.querySelectorAll('[data-testid="Column"] [data-testid="typography"]')].map( - (label) => label.textContent - ), - items: [...document.querySelectorAll('[data-testid="Column"]')].map(rect), - icons: [...document.querySelectorAll('[data-testid="Column"] svg')].map(rect), - labels: [...document.querySelectorAll('[data-testid="Column"] [data-testid="typography"]')].map(rect), - }; - }); - assert.deepEqual(webIconLibrary.names, reactIconLibrary.names, 'Icon library names and order must match React'); - const assertRects = (webRects, reactRects, label) => { - assert.equal(webRects.length, reactRects.length, `${label} count must match React`); - webRects.forEach((webRect, index) => { - for (const key of ['x', 'y', 'width', 'height']) - assert.ok( - Math.abs(webRect[key] - reactRects[index][key]) < 0.25, - `${label} ${index + 1} ${key} must match React` - ); - }); - }; - assertRects([webIconLibrary.container], [reactIconLibrary.container], 'Icon library container'); - assertRects(webIconLibrary.items, reactIconLibrary.items, 'Icon library item'); - assertRects(webIconLibrary.icons, reactIconLibrary.icons, 'Icon library SVG'); - assertRects(webIconLibrary.labels, reactIconLibrary.labels, 'Icon library label'); - const badgeIcons = index.entries['atoms-badge--with-icons']; - assert.ok(badgeIcons, 'Atoms/Badge WithIcons must exist'); - await visit(`${webBase}/iframe.html?id=${badgeIcons.id}&viewMode=story`); - await page.locator('gd-badge').first().waitFor({ state: 'attached' }); - await page.waitForFunction(() => document.querySelectorAll('gd-badge').length === 5); - const badgeIconParity = await page.evaluate(() => ({ - labels: [...document.querySelectorAll('gd-badge')].map((badge) => - [...badge.childNodes] - .filter((node) => node.nodeType === Node.TEXT_NODE) - .map((node) => node.textContent) - .join('') - .trim() - ), - variants: [...document.querySelectorAll('gd-badge')].map((badge) => badge.getAttribute('variant') ?? 'primary'), - icons: [...document.querySelectorAll('gd-badge')].map((badge) => - [...badge.querySelectorAll('gd-icon')].map((icon) => ({ - name: icon.getAttribute('name'), - size: icon.getAttribute('size'), - slot: icon.getAttribute('slot'), - })) - ), - })); - assert.deepEqual(badgeIconParity, { - labels: ['With Start Icon', 'With End Icon', 'Both Icons', 'Quaternary with Icon', 'Quinary with Icon'], - variants: ['primary', 'secondary', 'tertiary', 'quaternary', 'quinary'], - icons: [ - [{ name: 'success', size: 'md', slot: 'icon-start' }], - [{ name: 'warning', size: 'md', slot: 'icon-end' }], - [ - { name: 'info', size: 'md', slot: 'icon-start' }, - { name: 'arrowRight', size: 'md', slot: 'icon-end' }, - ], - [{ name: 'error', size: 'md', slot: 'icon-start' }], - [{ name: 'accountCircle', size: 'md', slot: 'icon-start' }], - ], - }); - const webBadgeGeometry = await page.evaluate(() => - [...document.querySelectorAll('gd-badge')].map((badge) => { - const root = badge.shadowRoot.querySelector('[part="root"]').getBoundingClientRect(); - const content = badge.shadowRoot.querySelector('[part="content"]').getBoundingClientRect(); - const icons = [...badge.querySelectorAll('gd-icon')].map((icon) => { - const rect = icon.shadowRoot.querySelector('svg').getBoundingClientRect(); - return { - width: rect.width, - height: rect.height, - centerOffset: rect.top + rect.height / 2 - (root.top + root.height / 2), - }; - }); - return { - width: root.width, - height: root.height, - contentCenterOffset: content.top + content.height / 2 - (root.top + root.height / 2), - icons, - }; - }) - ); - await visit(`${base}/iframe.html?id=react-atoms-badge--with-icons&viewMode=story`); - await page.locator('[data-testid="Badge"]').first().waitFor({ state: 'attached' }); - const reactBadgeGeometry = await page.evaluate(() => - [...document.querySelectorAll('[data-testid="Badge"]')].map((badge) => { - const root = badge.getBoundingClientRect(); - const content = badge.querySelector('[data-testid="Badge-content"]').getBoundingClientRect(); - const icons = [...badge.querySelectorAll('svg')].map((icon) => { - const rect = icon.getBoundingClientRect(); - return { - width: rect.width, - height: rect.height, - centerOffset: rect.top + rect.height / 2 - (root.top + root.height / 2), - }; - }); - return { - width: root.width, - height: root.height, - contentCenterOffset: content.top + content.height / 2 - (root.top + root.height / 2), - icons, - }; - }) - ); - assert.equal(webBadgeGeometry.length, reactBadgeGeometry.length, 'Badge WithIcons example count must match React'); - for (let index = 0; index < reactBadgeGeometry.length; index += 1) { - const web = webBadgeGeometry[index]; - const react = reactBadgeGeometry[index]; - assert.ok( - Math.abs(web.width - react.width) < 1, - `Badge ${index + 1} width must match React within 1px (Web ${web.width}px, React ${react.width}px)` - ); - assert.ok( - Math.abs(web.height - react.height) < 1, - `Badge ${index + 1} height must match React within 1px (Web ${web.height}px, React ${react.height}px)` - ); - assert.ok(Math.abs(web.contentCenterOffset) < 1, `Badge ${index + 1} text must be vertically centered`); - assert.equal(web.icons.length, react.icons.length, `Badge ${index + 1} icon count must match React`); - for (let iconIndex = 0; iconIndex < react.icons.length; iconIndex += 1) { - assert.ok(Math.abs(web.icons[iconIndex].width - react.icons[iconIndex].width) < 0.5); - assert.ok(Math.abs(web.icons[iconIndex].height - react.icons[iconIndex].height) < 0.5); - assert.ok( - Math.abs(web.icons[iconIndex].centerOffset - react.icons[iconIndex].centerOffset) < 0.25, - `Badge ${index + 1} inner SVG must match the React SVG vertical center` - ); - } - } +export async function checkNativeStories(page, base) { + const index = await (await page.request.get(`${base}/web-components/index.json`)).json(); const stories = Object.values(index.entries).filter( (entry) => - entry.type === 'story' && /^(Atoms|Molecules)\//.test(entry.title) && !entry.id.endsWith('--default-tokens') + entry.type === 'story' && + (/^(Atoms|Molecules)\//.test(entry.title) || entry.title.startsWith('Layout & Structure/')) && + !entry.id.endsWith('--default-tokens') ); const names = new Set(); - const tags = { inputfile: 'input-file', sliderdots: 'slider-dots' }; + const tags = { + chatcontainer: 'chat-container', + flexcontainer: 'flex-container', + inputfile: 'input-file', + sliderdots: 'slider-dots', + }; for (const story of stories) { const name = story.title.split('/')[1].toLowerCase(); names.add(name); - await visit(`${webBase}/iframe.html?id=${story.id}&viewMode=story`); - if ( - [ - 'atoms-loader--loader-section-variant', - 'atoms-loader--section-loader-button-variant', - 'atoms-loader--inline-loader-button-variant', - ].includes(story.id) - ) - continue; + await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`); const tag = `gd-${tags[name] ?? name}`; + if (name === 'portal') await page.getByText('Show Portal', { exact: true }).click(); await page.locator(tag).first().waitFor({ state: 'attached' }); await page.waitForFunction((tag) => { const el = document.querySelector(tag); @@ -309,8 +33,11 @@ export async function checkNativeStories(page, base, webBase = `${base}/web-comp 'badge', 'box', 'button', + 'chatcontainer', 'checkbox', + 'column', 'counter', + 'flexcontainer', 'icon', 'image', 'input', @@ -319,6 +46,9 @@ export async function checkNativeStories(page, base, webBase = `${base}/web-comp 'link', 'loader', 'menu', + 'portal', + 'row', + 'scroll', 'select', 'separator', 'skeleton', @@ -335,7 +65,7 @@ export async function checkNativeStories(page, base, webBase = `${base}/web-comp (entry) => entry.type === 'story' && entry.id.endsWith('--default-tokens') ); for (const story of tokenStories) { - await visit(`${webBase}/iframe.html?id=${story.id}&viewMode=story`); + await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`); const viewer = page.getByRole('region', { name: 'Token viewer' }); await viewer.waitFor(); const text = await viewer.textContent(); @@ -345,26 +75,219 @@ export async function checkNativeStories(page, base, webBase = `${base}/web-comp await branch.click(); assert.equal(await branch.getAttribute('aria-expanded'), 'false'); } - assert.equal(tokenStories.length, 24, 'Every React token story port should render Web Component tokens'); + assert.equal(tokenStories.length, 29, 'Every React token story port should render Web Component tokens'); const open = async (id) => { - await visit(`${webBase}/iframe.html?id=${id}&viewMode=story`); + await page.goto(`${base}/web-components/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root').waitFor(); + // Wait for the story's play function to finish; it drives the same controls the checks drive next. + await page.waitForFunction(() => window.__STORYBOOK_PREVIEW__?.currentRender?.phase === 'finished'); + // Source stories render no for events, so record the `gd-*` custom events the components dispatch. await page.evaluate(() => { - window.__nativeEvents = []; - for (const name of ['gd-change', 'gd-input']) - document.addEventListener(name, (event) => window.__nativeEvents.push(event.detail)); + window.__gdEvents = []; + const dispatch = EventTarget.prototype.dispatchEvent; + EventTarget.prototype.dispatchEvent = function (event) { + if (event instanceof CustomEvent && event.type.startsWith('gd-')) { + window.__gdEvents.push(JSON.stringify(event.detail)); + } + return dispatch.call(this, event); + }; }); }; - const event = async (detail) => + // Match when every key in `detail` equals the recorded event detail's value (other keys are ignored). + const event = (detail) => page.waitForFunction( - (expected) => window.__nativeEvents.some((value) => JSON.stringify(value) === JSON.stringify(expected)), + (expected) => + window.__gdEvents.some((recorded) => { + const actual = JSON.parse(recorded); + return Object.entries(expected).every( + ([key, value]) => JSON.stringify(actual?.[key]) === JSON.stringify(value) + ); + }), detail ); + await open('layout-structure-chatcontainer--interactive-demo'); + const chatContainer = page.locator('gd-chat-container'); + await page.waitForFunction(() => document.querySelector('gd-chat-container')?.isOpen === true); + await chatContainer.evaluate((element) => element.close()); + assert.equal(await chatContainer.evaluate((element) => element.isOpen), false); + await chatContainer.evaluate((element) => element.open()); + assert.equal(await chatContainer.evaluate((element) => element.isOpen), true); + const toggleEvent = chatContainer.evaluate( + (element) => + new Promise((resolve) => { + element.addEventListener('gd-toggle-sidebar', (event) => resolve(event.detail), { once: true }); + element.toggle(); + }) + ); + assert.deepEqual(await toggleEvent, { open: false }); + await open('layout-structure-column--default'); + assert.equal( + await page + .locator('gd-column') + .first() + .evaluate((element) => getComputedStyle(element.shadowRoot.querySelector('[part="root"]')).flexDirection), + 'column' + ); + await open('layout-structure-row--default'); + assert.equal(await page.locator('gd-row').evaluate((element) => element.getAttribute('display')), 'inline-flex'); + await open('layout-structure-flexcontainer--default'); + assert.equal( + await page + .locator('gd-flex-container') + .evaluate((element) => getComputedStyle(element.shadowRoot.querySelector('[part="root"]')).display), + 'flex' + ); + await open('layout-structure-portal--rendering-in-target'); + await page.getByText('Show Portal', { exact: true }).click(); + assert.equal( + await page + .locator('#custom-portal-container') + .getByText('This content is inside a portal, rendered within the green container.') + .count(), + 1 + ); + assert.equal(await page.locator('gd-portal').evaluate((element) => element.portalRoot), null); + await open('layout-structure-portal--with-wrapper-as-tag-main'); + await page.getByText('Show Portal', { exact: true }).click(); + assert.equal(await page.locator('gd-portal').evaluate((element) => element.portalRoot?.tagName), 'MAIN'); + assert.deepEqual( + await page + .locator('gd-portal') + .evaluate((element) => [...element.portalRoot.children].map((child) => child.tagName)), + ['GD-TYPOGRAPHY', 'GD-LINK'], + 'Wrapped Portal stories should preserve the React child structure without an extra demo section' + ); + await page.getByText('Hide', { exact: true }).click(); + assert.equal(await page.locator('gd-portal').count(), 0); + await open('layout-structure-scroll--interactive-example'); + const scroll = page.locator('gd-scroll'); + await page.waitForFunction(() => { + const element = document.querySelector('gd-scroll'); + return element?.shadowRoot?.querySelectorAll('[part~="scrollbar"]').length === 2; + }); + assert.deepEqual( + await scroll.evaluate((element) => ({ + host: [element.getBoundingClientRect().width, element.getBoundingClientRect().height], + client: [element.clientWidth, element.clientHeight], + extent: [element.scrollWidth, element.scrollHeight], + border: getComputedStyle(element).borderWidth, + bars: [ + element.shadowRoot.querySelector('[part="scrollbars"]').getBoundingClientRect().width, + element.shadowRoot.querySelector('[part="scrollbars"]').getBoundingClientRect().height, + ], + verticalTrack: [ + element.shadowRoot.querySelector('[part~="vertical-scrollbar"]').getBoundingClientRect().width, + element.shadowRoot.querySelector('[part~="vertical-scrollbar"]').getBoundingClientRect().height, + ], + horizontalTrack: [ + element.shadowRoot.querySelector('[part~="horizontal-scrollbar"]').getBoundingClientRect().width, + element.shadowRoot.querySelector('[part~="horizontal-scrollbar"]').getBoundingClientRect().height, + ], + })), + { + host: [300, 200], + client: [298, 198], + extent: [600, 700], + border: '1px', + bars: [297, 197], + verticalTrack: [8, 189], + horizontalTrack: [289, 8], + } + ); + await scroll.hover(); + await page.mouse.wheel(0, 80); + await page.waitForFunction(() => document.querySelector('gd-scroll').scrollTop > 0); + const wheelTop = await scroll.evaluate((element) => element.scrollTop); + await scroll.evaluate((element) => (element.tabIndex = 0)); + await scroll.focus(); + await page.keyboard.press('ArrowDown'); + await page.waitForFunction((previousTop) => document.querySelector('gd-scroll').scrollTop > previousTop, wheelTop); + // Chromium animates the native keyboard scroll. Let it settle before checking an + // independent imperative scroll so the in-flight key action cannot overwrite it. + await page.waitForTimeout(350); + await scroll.evaluate((element) => element.scrollTo({ top: 100, left: 100 })); + await page.waitForFunction(() => { + const element = document.querySelector('gd-scroll'); + return ( + element.scrollTop === 100 && + element.scrollLeft === 100 && + Number.parseFloat(element.shadowRoot.querySelector('[part~="vertical-thumb"]').style.top) > 0 && + Number.parseFloat(element.shadowRoot.querySelector('[part~="horizontal-thumb"]').style.left) > 0 + ); + }); + await scroll.evaluate((element) => element.scrollTo({ top: 0, left: 0 })); + await page.waitForFunction(() => document.querySelector('gd-scroll').scrollTop === 0); + const verticalThumb = scroll.locator('[part~="vertical-thumb"]'); + const verticalTrack = scroll.locator('[part~="vertical-scrollbar"]'); + const [thumbBox, trackBox] = await Promise.all([verticalThumb.boundingBox(), verticalTrack.boundingBox()]); + assert.ok(thumbBox && trackBox, 'Scroll thumb and track should have browser geometry'); + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2); + await page.mouse.down(); + await page.mouse.move(trackBox.x + trackBox.width / 2, trackBox.y + trackBox.height - 2); + await page.mouse.up(); + await page.waitForFunction(() => document.querySelector('gd-scroll').scrollTop > 100); + await open('layout-structure-scroll--vertical-scroll-only'); + await page.waitForFunction(() => document.querySelector('gd-scroll')?.scrollHeight > 198); + assert.deepEqual( + await page.locator('gd-scroll').evaluate((element) => ({ + extent: [element.scrollWidth, element.scrollHeight], + vertical: Boolean(element.shadowRoot.querySelector('[part~="vertical-scrollbar"]')), + horizontal: Boolean(element.shadowRoot.querySelector('[part~="horizontal-scrollbar"]')), + })), + { extent: [298, 500], vertical: true, horizontal: false } + ); + await open('layout-structure-scroll--horizontal-scroll-only'); + await page.waitForFunction(() => document.querySelector('gd-scroll')?.scrollWidth > 298); + assert.deepEqual( + await page.locator('gd-scroll').evaluate((element) => ({ + extent: [element.scrollWidth, element.scrollHeight], + vertical: Boolean(element.shadowRoot.querySelector('[part~="vertical-scrollbar"]')), + horizontal: Boolean(element.shadowRoot.querySelector('[part~="horizontal-scrollbar"]')), + })), + { extent: [1020, 198], vertical: false, horizontal: true } + ); + await open('layout-structure-scroll--auto-hide-scrollbars'); + await page.waitForFunction(() => + document.querySelector('gd-scroll')?.shadowRoot?.querySelector('[part~="vertical-scrollbar"]') + ); + await page.mouse.move(0, 0); + const autoHideScrollbar = page.locator('gd-scroll').locator('[part~="vertical-scrollbar"]'); + // The story's parity play function performs a keyboard scroll before finishing. + // Wait for that activity's auto-hide timer rather than assuming an untouched story. + await page.waitForFunction( + () => + getComputedStyle(document.querySelector('gd-scroll').shadowRoot.querySelector('[part~="vertical-scrollbar"]')) + .opacity === '0' + ); + assert.equal(await autoHideScrollbar.evaluate((element) => getComputedStyle(element).opacity), '0'); + await page.locator('gd-scroll').evaluate((element) => element.scrollTo({ top: 40 })); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector('gd-scroll').shadowRoot.querySelector('[part~="vertical-scrollbar"]')) + .opacity === '0.2' + ); + await page.waitForTimeout(1250); + assert.equal(await autoHideScrollbar.evaluate((element) => getComputedStyle(element).opacity), '0'); + await open('layout-structure-scroll--auto-hide-vertical-only'); + await page.waitForFunction(() => document.querySelector('gd-scroll')?.scrollHeight === 980); + assert.deepEqual( + await page.locator('gd-scroll').evaluate((element) => ({ + extent: [element.scrollWidth, element.scrollHeight], + vertical: Boolean(element.shadowRoot.querySelector('[part~="vertical-scrollbar"]')), + horizontal: Boolean(element.shadowRoot.querySelector('[part~="horizontal-scrollbar"]')), + })), + { extent: [298, 980], vertical: true, horizontal: false } + ); await open('atoms-button--disabled-button'); await page.getByRole('button', { name: 'Disabled Button', exact: true }).waitFor(); assert.ok(await page.getByRole('button', { name: 'Disabled Button', exact: true }).isDisabled()); await open('atoms-input--primary-default-with-label-and-helper-text'); - await page.getByRole('textbox', { name: 'Label', exact: true }).fill('Updated value'); + // The story's play function types "hello" after render; wait for it so it cannot overwrite the fill below. + await page.waitForFunction( + () => document.querySelector('gd-input')?.shadowRoot?.querySelector('input')?.value === 'hello' + ); + // The wrapping