From cd28625d06a6755bcc814e32d210b67e2b941511 Mon Sep 17 00:00:00 2001 From: Zlodej43sm <8863227+Zlodej43sm@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:03:00 +0200 Subject: [PATCH 01/10] feat: CTORNDSD-679 Update Storybook and documentation links for React components, integrate Web Components support, and enhance build workflows. --- .dockerignore | 10 + .github/workflows/pr-head.yaml | 6 + .gitignore | 1 + CLAUDE.md | 5 +- Dockerfile.storybook | 11 +- README.md | 32 +- bin/storybook/check-links.mjs | 115 ++++ bin/storybook/legacy-react-ids.json | 584 ++++++++++++++++++ bin/storybook/link-validation.mjs | 42 ++ bin/storybook/link-validation.test.mjs | 46 ++ bin/storybook/package-build.mjs | 10 + bin/storybook/routes.d.mts | 4 + bin/storybook/routes.mjs | 40 ++ bin/storybook/routes.test.mjs | 57 ++ bin/storybook/smoke-test.mjs | 104 ++++ libs/ui/.storybook/main.ts | 16 +- libs/ui/.storybook/preview.ts | 53 +- libs/ui/.storybook/public/defaultTheme.json | 6 +- libs/ui/README.md | 4 +- libs/ui/llms.txt | 36 +- libs/ui/project.json | 3 + libs/ui/src/ai/README.md | 4 +- libs/ui/src/ai/a2ui/system-prompt.ts | 2 +- .../organisms/Chart/Chart.stories.tsx | 2 +- .../stories/Introduction/Getting-started.mdx | 4 +- .../stories/Introduction/How-to-use-theme.mdx | 8 +- .../Introduction/Theme-tokens-usage.mdx | 6 +- .../Introduction/Theming-and-tokens.mdx | 42 +- libs/ui/src/stories/Introduction/Welcome.mdx | 4 + .../AI-Experience.mdx | 4 +- .../Prompt-Engineering-AI-Integration.mdx | 4 +- libs/ui/src/stories/chart/01-Introduction.mdx | 2 +- .../form-configurator/01-GettingStarted.mdx | 10 +- .../form-configurator/02-DataSchema.mdx | 6 +- .../stories/form-configurator/03-Controls.mdx | 8 +- .../stories/form-configurator/04-UISchema.mdx | 6 +- .../stories/form-configurator/05-Logger.mdx | 4 +- .../form-configurator/06-CustomControls.mdx | 6 +- libs/web-components/.storybook/main.ts | 15 + libs/web-components/.storybook/preview.ts | 15 + libs/web-components/.storybook/vite.config.ts | 3 + libs/web-components/README.md | 6 + libs/web-components/project.json | 3 + .../stories/Overview.stories.ts | 26 + libs/web-components/tsconfig.storybook.json | 11 + nginx.conf | 8 + package-lock.json | 40 ++ package.json | 8 +- 48 files changed, 1325 insertions(+), 117 deletions(-) create mode 100644 .dockerignore create mode 100644 bin/storybook/check-links.mjs create mode 100644 bin/storybook/legacy-react-ids.json create mode 100644 bin/storybook/link-validation.mjs create mode 100644 bin/storybook/link-validation.test.mjs create mode 100644 bin/storybook/package-build.mjs create mode 100644 bin/storybook/routes.d.mts create mode 100644 bin/storybook/routes.mjs create mode 100644 bin/storybook/routes.test.mjs create mode 100644 bin/storybook/smoke-test.mjs create mode 100644 libs/web-components/.storybook/main.ts create mode 100644 libs/web-components/.storybook/preview.ts create mode 100644 libs/web-components/.storybook/vite.config.ts create mode 100644 libs/web-components/stories/Overview.stories.ts create mode 100644 libs/web-components/tsconfig.storybook.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..839254a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.nx +node_modules +**/node_modules +dist +tmp +**/storybook-static +**/coverage +**/.vitest-artifacts +**/.vitest-attachments diff --git a/.github/workflows/pr-head.yaml b/.github/workflows/pr-head.yaml index 745494c..b485d47 100644 --- a/.github/workflows/pr-head.yaml +++ b/.github/workflows/pr-head.yaml @@ -37,6 +37,11 @@ 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 @@ -62,6 +67,7 @@ jobs: npx nx build ui cp libs/ui/package.json dist/libs/ui/package.json node libs/ui/scripts/postbuild-types.mjs + node bin/storybook/check-links.mjs --package - name: Upload dist/libs/ui artifact uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index c35a2fe..10dbba2 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ libs/ui/coverage/* # storybook storybook-static/* libs/ui/storybook-static/* +libs/web-components/storybook-static/* *storybook.log vite.config.*.timestamp* diff --git a/CLAUDE.md b/CLAUDE.md index 74c0398..5e1b053 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Node >= 22.17.0, npm workspaces. Run `npm install` after cloning. ```bash # Development -npm run storybook # Storybook dev server at http://localhost:6006 +npm run storybook # Shared React + Web Components Storybook at http://localhost:6006 npm test # Unit tests for gd-design-library (vitest, jsdom) npm run test:ai # AI integration tests in src/ai/__tests__/ npm run test:form-configurator # Unit tests for both form-configurator packages (jest) @@ -39,6 +39,7 @@ npm run storybook:visual # Run visual regression tests npm run build:ui # Full gd-design-library build: readme-stats + ai-docs + type-check + nx build + postbuild-types npm run build:form-configurator npm run build-storybook +npm run storybook:check-links # Built story indexes + source/shipped AI link validation # Verification (run on built dist — requires npm run build:ui first) npm run verify:ui:full # All 10 verification phases + Verdaccio smoke test @@ -67,6 +68,8 @@ 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. The React catalog receives a central `React/` title prefix; do not duplicate that prefix in stories or MDX. 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. Phase 1 contains only the native overview; component coverage and harness cleanup are later phases. + **Package subpaths** — the published package exposes four explicit subpaths (wildcards are intentionally removed to prevent internal path coupling): | Subpath | Contents | diff --git a/Dockerfile.storybook b/Dockerfile.storybook index 7b99bc0..eb26d28 100644 --- a/Dockerfile.storybook +++ b/Dockerfile.storybook @@ -3,13 +3,16 @@ WORKDIR /app COPY package.json package-lock.json nx.json tsconfig.base.json .npmrc eslint.config.mjs ./ COPY libs/ui/ libs/ui/ -COPY bin/export-theme.js bin/export-theme.js +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 +COPY bin/ bin/ +COPY README.md CLAUDE.md ./ RUN npm ci -RUN npx nx build ui \ - && node ./bin/export-theme.js \ - && npx nx run ui:build-storybook +RUN npm run build-storybook FROM nginx:alpine AS runner WORKDIR /usr/share/nginx/html diff --git a/README.md b/README.md index f3f08f3..d8ad8dd 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ export default function App() { } ``` -See the [Storybook](https://github.com/griddynamics/gd-design-system#storybook) for interactive component examples and full API documentation. +See the [React catalog in Storybook](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/react-introduction-welcome--docs) for interactive component examples and full API documentation. ## Setup @@ -52,7 +52,7 @@ npm install ## Development ```bash -npm run storybook # Storybook at http://localhost:6006 +npm run storybook # Shared React + Web Components Storybook at http://localhost:6006 npm test # gd-design-library unit tests npm run test:form-configurator # form-configurator tests npm run type-check # TypeScript check @@ -64,9 +64,35 @@ npm run lint # ESLint ```bash npm run build:ui # Build gd-design-library (ESM + CJS + types) npm run build:form-configurator # Build both form-configurator packages -npm run build-storybook # Build static Storybook +npm run build-storybook # Build and validate the complete static Storybook ``` +## Storybook + +One entry point contains two peer catalogs: **React** (the existing stories and documentation) +and **Web Components** (native custom-element stories). The Web Components catalog currently +contains the Phase 1 overview; Phase 2 adds stories for the six existing atoms and two molecules. + +`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. +`npm run build-storybook` produces one deployable directory at `libs/ui/storybook-static`, +including the composed preview under `web-components/`. + +```bash +npm run storybook:check-links # Validate built Storybooks and shipped AI documentation links +npm run storybook:test # Existing React interaction tests (native coverage follows in Phase 2) +npm run storybook:visual # Existing React visual tests +``` + +React titles receive their `React/` prefix in `.storybook/main.ts`; do not add it again in +individual story/MDX titles. Use the IDs in the generated `index.json` for links. Old published +React manager/iframe links redirect to their new IDs while retaining args, globals, and anchors. +`bin/storybook/legacy-react-ids.json` is the frozen pre-migration inventory, not a generated +list to refresh when a story disappears. Link checks cover source documentation and the +`llms.txt`, AI Markdown/JSON, and AI JavaScript copied into the UI package. + +The Angular/Vue and other Web Components harnesses are unchanged in Phase 1. + ## Verification (gd-design-library) ```bash diff --git a/bin/storybook/check-links.mjs b/bin/storybook/check-links.mjs new file mode 100644 index 0000000..fd1549e --- /dev/null +++ b/bin/storybook/check-links.mjs @@ -0,0 +1,115 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { legacyReactIds } from './routes.mjs'; +import { validateLinks } from './link-validation.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const built = process.argv.includes('--built'); +const checkPackage = built || process.argv.includes('--package'); +const readJson = (path) => JSON.parse(readFileSync(path, 'utf8')); +let indexes; +if (built) { + indexes = { + react: readJson(join(root, 'libs/ui/storybook-static/index.json')), + webComponents: readJson(join(root, 'libs/ui/storybook-static/web-components/index.json')), + }; +} else { + // Indexing does not build the UI or either preview. Package builds can check + // links without creating a UI -> Storybook -> UI build dependency cycle. + const temporary = mkdtempSync(join(tmpdir(), 'gridkit-storybook-index-')); + try { + indexes = {}; + for (const [catalog, project] of [ + ['react', 'ui'], + ['webComponents', 'web-components'], + ]) { + const output = join(temporary, `${catalog}.json`); + execFileSync( + process.execPath, + [ + join(root, 'node_modules/storybook/dist/bin/dispatcher.js'), + 'index', + '-c', + '.storybook', + '-o', + output, + '--disable-telemetry', + ], + { cwd: join(root, 'libs', project), stdio: 'inherit' } + ); + indexes[catalog] = readJson(output); + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } +} + +const errors = []; +for (const id of legacyReactIds) { + if (!indexes.react.entries[`react-${id}`]) errors.push(`Missing migrated React entry: ${id}`); +} +for (const entry of Object.values(indexes.react.entries)) { + if (!entry.title.startsWith('React/')) errors.push(`Entry is outside the React root: ${entry.id}`); +} +if (!indexes.webComponents.entries['introduction-overview--overview']) errors.push('Missing Web Components overview'); + +const excluded = new Set(['node_modules', '.git', 'dist', 'storybook-static', 'coverage', 'output', '.nx']); +const extensions = new Set([ + '.md', + '.mdx', + '.txt', + '.json', + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.html', + '.yml', + '.yaml', +]); +function filesIn(path) { + if (!existsSync(path)) return []; + return readdirSync(path, { withFileTypes: true }).flatMap((entry) => { + if (entry.isSymbolicLink() || excluded.has(entry.name)) return []; + const file = join(path, entry.name); + return entry.isDirectory() ? filesIn(file) : extensions.has(extname(file)) ? [file] : []; + }); +} + +// The frozen migration registry and its tests intentionally contain old IDs. +const sourceFiles = ['README.md', 'CLAUDE.md'] + .map((file) => join(root, file)) + .filter(existsSync) + .concat( + ...['libs', '.github'].map((dir) => filesIn(join(root, dir))), + filesIn(join(root, 'bin')).filter((file) => !file.startsWith(join(root, 'bin', 'storybook') + sep)) + ); +const packageFiles = []; +if (checkPackage) { + for (const file of ['llms.txt', 'ai/README.md']) { + if (!existsSync(join(root, 'dist/libs/ui', file))) errors.push(`Missing package artifact: ${file}`); + } + packageFiles.push(...filesIn(join(root, 'dist/libs/ui/ai'))); + for (const file of readdirSync(join(root, 'dist/libs/ui'))) { + if (/\.(md|txt)$/.test(file)) packageFiles.push(join(root, 'dist/libs/ui', file)); + } +} +let checked = 0; +for (const file of [...sourceFiles, ...packageFiles]) { + const result = validateLinks(readFileSync(file, 'utf8'), indexes); + checked += result.checked; + errors.push(...result.errors.map(({ line, message }) => `${relative(root, file)}:${line}: ${message}`)); +} +if (errors.length) { + console.error(errors.join('\n')); + process.exitCode = 1; +} else { + console.log( + `Verified ${legacyReactIds.length} migrated entries and ${checked} Storybook links in source${checkPackage ? ' and built package artifacts' : ''}.` + ); +} diff --git a/bin/storybook/legacy-react-ids.json b/bin/storybook/legacy-react-ids.json new file mode 100644 index 0000000..1c52f15 --- /dev/null +++ b/bin/storybook/legacy-react-ids.json @@ -0,0 +1,584 @@ +[ + "atoms-avatar--default", + "atoms-avatar--default-tokens", + "atoms-avatar--docs", + "atoms-avatar--with-accessibility", + "atoms-avatar--with-badge", + "atoms-avatar--with-custom-background-color", + "atoms-avatar--with-custom-badge-color", + "atoms-avatar--with-different-size", + "atoms-avatar--with-icon", + "atoms-avatar--with-image-and-fallback", + "atoms-avatar--with-initials", + "atoms-badge--default", + "atoms-badge--default-tokens", + "atoms-badge--disabled", + "atoms-badge--docs", + "atoms-badge--sizes", + "atoms-badge--variants", + "atoms-badge--with-accessibility", + "atoms-badge--with-box-styles", + "atoms-badge--with-icons", + "atoms-box--bordered", + "atoms-box--centered-content", + "atoms-box--default", + "atoms-box--default-tokens", + "atoms-box--docs", + "atoms-box--examples", + "atoms-box--highlighted", + "atoms-box--horizontal-layout", + "atoms-box--vertical-layout", + "atoms-box--with-accessibility", + "atoms-box--with-shadow-hover", + "atoms-button--all-variants", + "atoms-button--button-states-using-class", + "atoms-button--custom-styled-button", + "atoms-button--default", + "atoms-button--default-tokens", + "atoms-button--disabled-button", + "atoms-button--docs", + "atoms-button--full-width-button", + "atoms-button--icon-only", + "atoms-button--is-loading", + "atoms-button--real-world-examples", + "atoms-button--rounded-button", + "atoms-button--with-accessibility", + "atoms-button--with-icons", + "atoms-checkbox--controlled", + "atoms-checkbox--default", + "atoms-checkbox--default-tokens", + "atoms-checkbox--disabled", + "atoms-checkbox--docs", + "atoms-checkbox--indeterminate", + "atoms-checkbox--sizes", + "atoms-icon--all-icons", + "atoms-icon--default", + "atoms-icon--default-tokens", + "atoms-icon--docs", + "atoms-icon--registering-custom-icons", + "atoms-icon--with-accessibility", + "atoms-icon--with-defined-size", + "atoms-image--clickable", + "atoms-image--default", + "atoms-image--default-tokens", + "atoms-image--docs", + "atoms-image--fallback-image", + "atoms-image--with-accessibility", + "atoms-image--with-as-prop", + "atoms-image--with-caption", + "atoms-image--with-caption-as-prop", + "atoms-image--with-placeholder", + "atoms-input--checkbox-with-label", + "atoms-input--custom-styles", + "atoms-input--default-tokens", + "atoms-input--default-with-tailwind", + "atoms-input--disabled", + "atoms-input--docs", + "atoms-input--error-with-label-and-helper-text", + "atoms-input--primary-default-with-label-and-helper-text", + "atoms-input--radio-controlled-with-label", + "atoms-input--radio-group-with-label", + "atoms-input--read-only", + "atoms-input--success-with-label-and-helper-text", + "atoms-input--warning-with-label-and-helper-text", + "atoms-input--with-accessibility", + "atoms-input--with-adornments", + "atoms-input--with-end-adornment-as-icon", + "atoms-input--with-start-adornment", + "atoms-input--wrapper-as-span", + "atoms-inputfile--custom-label", + "atoms-inputfile--default", + "atoms-inputfile--default-tokens", + "atoms-inputfile--disabled", + "atoms-inputfile--docs", + "atoms-inputfile--icon-label", + "atoms-inputfile--multiple", + "atoms-inputfile--with-accept", + "atoms-inputfile--with-accessibility", + "atoms-inputfile--with-custom-styles", + "atoms-label--custom-styles", + "atoms-label--default-tokens", + "atoms-label--default-with-tailwind", + "atoms-label--docs", + "atoms-label--label-default", + "atoms-label--label-with-child-icon", + "atoms-label--with-accessibility", + "atoms-link--custom-styles", + "atoms-link--default-tokens", + "atoms-link--disabled", + "atoms-link--docs", + "atoms-link--inherit-with-button-as-child", + "atoms-link--inherit-with-tailwind", + "atoms-link--inherit-with-typography-as-child", + "atoms-link--inverted", + "atoms-link--target-blank-visited", + "atoms-link--variants", + "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", + "atoms-select--color-success", + "atoms-select--color-warning", + "atoms-select--custom-item-identifier-component", + "atoms-select--default", + "atoms-select--default-tokens", + "atoms-select--disabled", + "atoms-select--docs", + "atoms-select--empty-items", + "atoms-select--multiple-select", + "atoms-select--searchable", + "atoms-select--using-ref-component-with-custom-placeholder", + "atoms-select--with-accessibility", + "atoms-select--with-adornments-and-reset-option-story", + "atoms-select--with-custom-initiator-and-selected-output-value", + "atoms-select--with-file-input-and-custom-dropdown", + "atoms-separator--default", + "atoms-separator--default-tokens", + "atoms-separator--docs", + "atoms-separator--vertical", + "atoms-separator--vertical-with-label", + "atoms-separator--with-accessibility", + "atoms-separator--with-custom-color", + "atoms-separator--with-different-variants", + "atoms-separator--with-label", + "atoms-skeleton--circular", + "atoms-skeleton--composed-layout", + "atoms-skeleton--default", + "atoms-skeleton--default-tokens", + "atoms-skeleton--docs", + "atoms-skeleton--rectangular", + "atoms-skeleton--with-accessibility", + "atoms-skeleton--with-children", + "atoms-skeleton--with-custom-animation", + "atoms-skeleton--with-theme-color", + "atoms-slider--controlled", + "atoms-slider--custom-styles", + "atoms-slider--default", + "atoms-slider--default-tokens", + "atoms-slider--disabled", + "atoms-slider--docs", + "atoms-slider--with-accessibility", + "atoms-slider--with-initial-value", + "atoms-sliderdots--default", + "atoms-sliderdots--default-tokens", + "atoms-sliderdots--docs", + "atoms-sliderdots--interactive", + "atoms-sliderdots--many-dots", + "atoms-switch--checked", + "atoms-switch--controlled", + "atoms-switch--default", + "atoms-switch--default-tokens", + "atoms-switch--disabled", + "atoms-switch--docs", + "atoms-switch--label-left", + "atoms-switch--uncontrolled", + "atoms-switch--with-accessibility", + "atoms-switch--with-loading", + "atoms-textarea--auto-focus", + "atoms-textarea--color-error", + "atoms-textarea--color-primary", + "atoms-textarea--color-success", + "atoms-textarea--color-warning", + "atoms-textarea--default", + "atoms-textarea--default-tokens", + "atoms-textarea--disabled", + "atoms-textarea--docs", + "atoms-textarea--dynamic-height", + "atoms-textarea--max-characters", + "atoms-textarea--read-only", + "atoms-textarea--resizable", + "atoms-textarea--variant-inline", + "atoms-textarea--with-accessibility", + "atoms-textarea--with-char-limit", + "atoms-textarea--with-controlled-state-erase-on-enter-click", + "atoms-toggle--default", + "atoms-toggle--default-tokens", + "atoms-toggle--disabled", + "atoms-toggle--docs", + "atoms-toggle--with-accessibility", + "atoms-toggle--with-custom-render", + "atoms-truncate--default", + "atoms-truncate--docs", + "atoms-truncate--line-truncation", + "atoms-truncate--ref-api-with-tooltip-on-overflow", + "atoms-truncate--with-accessibility", + "atoms-truncate--with-custom-styling", + "atoms-typography--as-custom-component", + "atoms-typography--as-custom-html-tag", + "atoms-typography--body", + "atoms-typography--combined-style-variant", + "atoms-typography--default", + "atoms-typography--default-tokens", + "atoms-typography--disclaimers", + "atoms-typography--display", + "atoms-typography--docs", + "atoms-typography--heading", + "atoms-typography--with-accessibility", + "atoms-wrapper--custom-tag-wrapper", + "atoms-wrapper--default-tokens", + "atoms-wrapper--docs", + "atoms-wrapper--full-page-wrapper", + "atoms-wrapper--inline-wrapper", + "atoms-wrapper--interactive-example", + "atoms-wrapper--section-wrapper", + "atoms-wrapper--with-accessibility", + "domain-solutions-dynamic-builder-actions--docs", + "domain-solutions-dynamic-builder-architecture--docs", + "domain-solutions-dynamic-builder-form-builder--docs", + "domain-solutions-dynamic-builder-how-to-use--docs", + "domain-solutions-dynamic-builder-overview--docs", + "domain-solutions-dynamic-builder-providers--docs", + "domain-solutions-dynamic-builder-validation--docs", + "domain-solutions-e-commerce--docs", + "form-configurator-controls--docs", + "form-configurator-custom-controls--docs", + "form-configurator-data-schema--docs", + "form-configurator-getting-started--docs", + "form-configurator-logger--docs", + "form-configurator-ui-schema-layouts--docs", + "introduction-ai-integration-a2ui-mode--docs", + "introduction-ai-integration-actions--docs", + "introduction-ai-integration-code-mode--docs", + "introduction-ai-integration-custom-components--docs", + "introduction-ai-integration-how-to-use--docs", + "introduction-ai-integration-overview--docs", + "introduction-ai-integration-renderer--docs", + "introduction-ai-integration-schema-and-validation--docs", + "introduction-atomic-design--docs", + "introduction-charts--docs", + "introduction-feedback-contribution--docs", + "introduction-getting-started--docs", + "introduction-logging-and-debugging--docs", + "introduction-theme-tokens-usage--docs", + "introduction-welcome--docs", + "layout-structure-chatcontainer--default", + "layout-structure-chatcontainer--default-tokens", + "layout-structure-chatcontainer--docs", + "layout-structure-chatcontainer--interactive-demo", + "layout-structure-chatcontainer--minimal-layout-with-ref-control", + "layout-structure-column--default", + "layout-structure-column--default-tokens", + "layout-structure-column--docs", + "layout-structure-flexcontainer--default", + "layout-structure-flexcontainer--default-tokens", + "layout-structure-flexcontainer--docs", + "layout-structure-flexcontainer--justify-start", + "layout-structure-portal--default", + "layout-structure-portal--docs", + "layout-structure-portal--rendering-in-target", + "layout-structure-portal--with-wrapper", + "layout-structure-portal--with-wrapper-as-tag-main", + "layout-structure-row--default", + "layout-structure-row--default-tokens", + "layout-structure-row--docs", + "layout-structure-scroll--auto-hide-scrollbars", + "layout-structure-scroll--auto-hide-vertical-only", + "layout-structure-scroll--default-tokens", + "layout-structure-scroll--docs", + "layout-structure-scroll--horizontal-scroll-only", + "layout-structure-scroll--interactive-example", + "layout-structure-scroll--vertical-scroll-only", + "molecules-accordion--allow-multiple-expand-with-icon", + "molecules-accordion--as-inline", + "molecules-accordion--controlled", + "molecules-accordion--default", + "molecules-accordion--default-expanded", + "molecules-accordion--default-tokens", + "molecules-accordion--docs", + "molecules-accordion--with-box-styles", + "molecules-accordion--with-on-change", + "molecules-accordion--without-separator", + "molecules-attachment-file--default", + "molecules-attachment-file--default-tokens", + "molecules-attachment-file--disabled", + "molecules-attachment-file--docs", + "molecules-attachment-file--error", + "molecules-attachment-file--loading", + "molecules-attachment-file--long-file-name", + "molecules-attachment-file--long-file-type", + "molecules-attachment-file--removable", + "molecules-attachment-file--without-meta", + "molecules-attachment-file--without-remove", + "molecules-breadcrumbs--default", + "molecules-breadcrumbs--default-tokens", + "molecules-breadcrumbs--docs", + "molecules-breadcrumbs--with-border", + "molecules-breadcrumbs--with-end-item-link-icon-and-after-last-item", + "molecules-breadcrumbs--with-icon-separator", + "molecules-breadcrumbs--with-start-item-link-icon", + "molecules-breadcrumbs--with-string-separator-and-after-last-item", + "molecules-counter--adjusted-max-value-5", + "molecules-counter--adjusted-min-2-max-value-10", + "molecules-counter--adjusted-min-value-3", + "molecules-counter--default", + "molecules-counter--default-tokens", + "molecules-counter--disabled", + "molecules-counter--docs", + "molecules-counter--with-external-counter-change-handler", + "molecules-dropdown--default", + "molecules-dropdown--default-tokens", + "molecules-dropdown--docs", + "molecules-dropdown--with-accessibility", + "molecules-dropdown--with-checkbox-items", + "molecules-dropdownitem--active", + "molecules-dropdownitem--default", + "molecules-dropdownitem--default-tokens", + "molecules-dropdownitem--docs", + "molecules-dropdownitem--interactive-with-defined-on-select", + "molecules-form--default-tokens", + "molecules-form--docs", + "molecules-form--with-accessibility", + "molecules-form--with-custom-error-handling-on-change-submit", + "molecules-form--with-custom-styles", + "molecules-form--with-on-change", + "molecules-form--with-submit", + "molecules-inline-notification--default-inline-notification", + "molecules-inline-notification--default-tokens", + "molecules-inline-notification--docs", + "molecules-inline-notification--error-inline-notification", + "molecules-inline-notification--info-inline-notification", + "molecules-inline-notification--success-inline-notification", + "molecules-inline-notification--warning-inline-notification", + "molecules-inline-notification--with-custom-content", + "molecules-list--default-tokens", + "molecules-list--docs", + "molecules-list--list-variants", + "molecules-list--size-variants", + "molecules-menu--close-on-select-false", + "molecules-menu--default", + "molecules-menu--default-tokens", + "molecules-menu--docs", + "molecules-menu--placement", + "molecules-menu--with-edit-and-delete-modals", + "molecules-menu--with-height-constraints", + "molecules-menu--with-offset", + "molecules-menu--with-positioning-options", + "molecules-menu--with-ref-control", + "molecules-price--custom-class", + "molecules-price--default", + "molecules-price--default-tokens", + "molecules-price--docs", + "molecules-price--european-convention", + "molecules-price--european-large-price", + "molecules-price--free-price", + "molecules-price--large-price", + "molecules-price--size-variants", + "molecules-price--with-decimals", + "molecules-price--with-old-price", + "molecules-progressbar--custom-styling", + "molecules-progressbar--default", + "molecules-progressbar--default-tokens", + "molecules-progressbar--docs", + "molecules-progressbar--full-width", + "molecules-progressbar--indeterminate", + "molecules-progressbar--interactive", + "molecules-progressbar--with-percentage", + "molecules-radiogroup--default", + "molecules-radiogroup--default-tokens", + "molecules-radiogroup--docs", + "molecules-radiogroup--with-colors", + "molecules-radiogroup--with-custom-items", + "molecules-radiogroup--with-grid-layout", + "molecules-radiogroup--with-horizontal-layout", + "molecules-radiogroup--with-images-and-tooltip", + "molecules-radiogroup--with-size-variants", + "molecules-radiogroup--with-vertical-layout", + "molecules-rating--default-tokens", + "molecules-rating--docs", + "molecules-rating--max-value-custom-10", + "molecules-rating--pre-selected-value", + "molecules-rating--read-only", + "molecules-rating--with-custom-on-change-control", + "molecules-snackbar--default", + "molecules-snackbar--default-tokens", + "molecules-snackbar--docs", + "molecules-snackbar--error", + "molecules-snackbar--example", + "molecules-snackbar--no-auto-dismiss", + "molecules-snackbar--success", + "molecules-snackbar--warning", + "molecules-snackbar--with-action-and-colored", + "molecules-stepper--default-tokens", + "molecules-stepper--docs", + "molecules-stepper--stepper-simple-scenario", + "molecules-stepper--with-active-error-step", + "molecules-stepper--with-custom-icons-view-step", + "molecules-stepper--with-icons-view-active-error-step", + "molecules-stepper--with-icons-view-completed-error-step", + "molecules-table--default", + "molecules-table--default-tokens", + "molecules-table--docs", + "molecules-table--empty-state", + "molecules-table--with-column-widths", + "molecules-table--with-custom-cell-renderers", + "molecules-table--with-custom-header", + "molecules-table--with-expandable-rows", + "molecules-table--with-loading-state", + "molecules-table--with-pagination", + "molecules-table--with-scroll-methods", + "molecules-table--with-sticky-footer", + "molecules-table--with-sticky-header", + "molecules-table--with-sticky-header-and-footer", + "molecules-table--with-sticky-header-and-pagination", + "molecules-table--with-virtualization", + "molecules-tabs--default", + "molecules-tabs--default-tokens", + "molecules-tabs--docs", + "molecules-tabs--with-accessibility", + "molecules-tabs--with-disabled-first-tab", + "molecules-tabs--with-notices-tab", + "molecules-tooltip--custom-delay", + "molecules-tooltip--custom-gap", + "molecules-tooltip--custom-position", + "molecules-tooltip--default", + "molecules-tooltip--default-tokens", + "molecules-tooltip--docs", + "molecules-tooltip--multiple-tooltips", + "molecules-tooltip--with-accessibility", + "organisms-card--default-tokens", + "organisms-card--docs", + "organisms-card--my-card-with-fallback-image", + "organisms-card--with-build-in-components", + "organisms-card--with-horizontal-card", + "organisms-card--with-vertical-card", + "organisms-card--with-wishlist-and-badge", + "organisms-carousel--default", + "organisms-carousel--default-tokens", + "organisms-carousel--docs", + "organisms-carousel--layout-horizontal", + "organisms-carousel--layout-vertical", + "organisms-carousel--responsive-behavior", + "organisms-carousel--with-custom-content", + "organisms-carousel--with-navigation-dots", + "organisms-carousel--without-navigation-arrows", + "organisms-chart--area-chart", + "organisms-chart--axis-config", + "organisms-chart--bar-chart", + "organisms-chart--curve-types", + "organisms-chart--custom-colors", + "organisms-chart--dashboard", + "organisms-chart--default", + "organisms-chart--default-tokens", + "organisms-chart--docs", + "organisms-chart--donut-chart", + "organisms-chart--empty-state", + "organisms-chart--error-state", + "organisms-chart--loading-state", + "organisms-chart--multi-series-with-legend", + "organisms-chart--per-series-colors", + "organisms-chart--pie-chart", + "organisms-chart--stacked-bar-chart", + "organisms-chatbubble--answer-with-actions", + "organisms-chatbubble--answer-with-status", + "organisms-chatbubble--default", + "organisms-chatbubble--default-tokens", + "organisms-chatbubble--docs", + "organisms-chatbubble--with-image-gallery", + "organisms-chatbubble--with-link-preview", + "organisms-chatbubble--with-sizes", + "organisms-contentcarousel--centered-alignment", + "organisms-contentcarousel--custom-scroll-step", + "organisms-contentcarousel--default", + "organisms-contentcarousel--default-tokens", + "organisms-contentcarousel--docs", + "organisms-contentcarousel--multiple-visible-items", + "organisms-draganddropfiles--default", + "organisms-draganddropfiles--default-tokens", + "organisms-draganddropfiles--docs", + "organisms-draganddropfiles--drag-and-drop-lifecycle", + "organisms-draganddropfiles--with-button-styles", + "organisms-draganddropfiles--with-custom-styles", + "organisms-header--complete-header", + "organisms-header--dark-header", + "organisms-header--default-tokens", + "organisms-header--docs", + "organisms-header--interactive-mobile-menu", + "organisms-header--minimal", + "organisms-header--responsive-header", + "organisms-header--with-actions", + "organisms-header--with-custom-children", + "organisms-header--with-menu", + "organisms-header--with-search", + "organisms-imagepreview--default", + "organisms-imagepreview--default-tokens", + "organisms-imagepreview--docs", + "organisms-imagepreview--no-arrows", + "organisms-imagepreview--single-image", + "organisms-imagepreview--thumbnails-left", + "organisms-imagepreview--with-counter", + "organisms-inputarea--attachment-interactive", + "organisms-inputarea--custom-rows", + "organisms-inputarea--custom-send-icon", + "organisms-inputarea--default", + "organisms-inputarea--default-tokens", + "organisms-inputarea--disabled", + "organisms-inputarea--docs", + "organisms-inputarea--recording-states", + "organisms-inputarea--voice-recording-interactive", + "organisms-inputarea--with-character-count", + "organisms-modal--custom-view", + "organisms-modal--default", + "organisms-modal--default-tokens", + "organisms-modal--docs", + "organisms-modal--no-click-outside-close", + "organisms-modal--no-escape-close-content-with-scroll", + "organisms-modal--with-accessibility", + "organisms-search--default", + "organisms-search--default-tokens", + "organisms-search--docs", + "organisms-search--with-items", + "organisms-search--with-loading", + "organisms-search--with-no-results", + "organisms-searchmodal--default", + "organisms-searchmodal--default-tokens", + "organisms-searchmodal--docs", + "organisms-searchmodal--with-search-modal", + "organisms-searchmodal--with-sections", + "organisms-sidebar--collapsed", + "organisms-sidebar--default", + "organisms-sidebar--default-tokens", + "organisms-sidebar--docs", + "organisms-sidebar--with-header-and-footer", + "organisms-sidebar--with-sub-items", + "patterns-best-practices-ai-experience--docs", + "patterns-best-practices-cross-device-ux-notes--docs", + "patterns-best-practices-form-validation-patterns--docs", + "patterns-best-practices-layout-composition-techniques--docs", + "patterns-best-practices-prompt-engineering-ai-integration--docs", + "patterns-best-practices-responsive-design-guide--docs", + "patterns-best-practices-state-handling-strategies--docs", + "patterns-best-practices-ux-ui-design-patterns-and-best-practices--docs", + "support-questions-faq--docs", + "templates-draganddrop--default", + "templates-draganddrop--default-tokens", + "templates-draganddrop--disabled", + "templates-draganddrop--docs", + "templates-draganddrop--error", + "templates-draganddrop--loading", + "templates-draganddrop--usage-as-wrapper", + "theme-tokens-animations--docs", + "theme-tokens-breakpoints--docs", + "theme-tokens-colors--docs", + "theme-tokens-design-tokens-overview--docs", + "theme-tokens-dynamic-theme-switching--docs", + "theme-tokens-fonts--docs", + "theme-tokens-radius--docs", + "theme-tokens-shadows--docs", + "theme-tokens-spacing--docs", + "theme-tokens-theme--docs", + "theme-tokens-values--docs", + "theme-tokens-zindex--docs" +] diff --git a/bin/storybook/link-validation.mjs b/bin/storybook/link-validation.mjs new file mode 100644 index 0000000..a06ac4d --- /dev/null +++ b/bin/storybook/link-validation.mjs @@ -0,0 +1,42 @@ +import { storybookOrigin } from './routes.mjs'; + +// Only Storybook routes, not example application URLs such as /docs/api. +const links = + /https?:\/\/[^\s<>"'`)\]}]+|(?:\.\/)?\?path=\/(?:docs|story)\/[^\s<>"'`)\]}]+|\/(?:docs|story)\/[a-z0-9-]+--[a-z0-9-]+|(?:\.\/|\/)?iframe\.html\?[^\s<>"'`)\]}]+/g; + +export function validateLinks(text, indexes) { + const errors = []; + let checked = 0; + for (const match of text.matchAll(links)) { + const href = match[0].replaceAll('&', '&'); + let url; + try { + url = new URL(href, storybookOrigin); + } catch { + // Other tools' URL templates (e.g. localhost:${PORT}) are not links. + if (href.startsWith(storybookOrigin)) + errors.push({ line: text.slice(0, match.index).split('\n').length, href, message: 'Malformed Storybook URL' }); + continue; + } + if (url.origin !== storybookOrigin && !/^http:\/\/localhost:600[67]$/.test(url.origin)) continue; + const path = url.searchParams.get('path') || url.pathname; + const route = path.match(/^\/(docs|story)\/([^#?]+)/); + let id = route?.[2] || url.searchParams.get('id'); + if (!id) continue; + checked++; + const line = text.slice(0, match.index).split('\n').length; + let catalog = url.pathname.startsWith('/web-components/') || url.port === '6007' ? 'webComponents' : 'react'; + if (id.startsWith('web-components_')) { + catalog = 'webComponents'; + id = id.slice('web-components_'.length); + } + const entry = indexes[catalog].entries[id]; + if (!entry) errors.push({ line, href, message: `Unknown or stale ${catalog} story ID: ${id}` }); + else if (route && entry.type !== route[1]) + errors.push({ line, href, message: `Expected ${entry.type} route for ${id}` }); + if (route && !url.searchParams.has('path')) { + errors.push({ line, href, message: 'Use a manager ?path= URL, not a bare /docs/ or /story/ URL' }); + } + } + return { checked, errors }; +} diff --git a/bin/storybook/link-validation.test.mjs b/bin/storybook/link-validation.test.mjs new file mode 100644 index 0000000..ee73297 --- /dev/null +++ b/bin/storybook/link-validation.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateLinks } from './link-validation.mjs'; + +const indexes = { + react: { entries: { 'react-atoms-button--docs': { type: 'docs' } } }, + webComponents: { entries: { 'introduction-overview--overview': { type: 'story' } } }, +}; + +test('validates canonical, composed and child deep links', () => { + const result = validateLinks( + ` + [Button](?path=/docs/react-atoms-button--docs) + https://storybook.cto-rnd-system-design.griddynamics.net/web-components/?path=/story/introduction-overview--overview + ?path=/story/web-components_introduction-overview--overview + iframe.html?id=react-atoms-button--docs&viewMode=docs + `, + indexes + ); + assert.equal(result.checked, 4); + assert.deepEqual(result.errors, []); +}); + +test('catches stale, nonexistent, incorrectly typed and bare routes', () => { + for (const href of [ + '?path=/docs/atoms-button--docs', + '?path=/docs/react-nonexistent--docs', + '?path=/story/react-atoms-button--docs', + '/docs/react-atoms-button--docs', + ]) + assert.ok(validateLinks(href, indexes).errors.length > 0, href); +}); + +test('ignores example application links, external Storybooks, and portal roots', () => { + assert.deepEqual( + validateLinks( + ` + API + https://example.com/?path=/docs/external--docs + https://storybook.cto-rnd-system-design.griddynamics.net/ + `, + indexes + ), + { checked: 0, errors: [] } + ); +}); diff --git a/bin/storybook/package-build.mjs b/bin/storybook/package-build.mjs new file mode 100644 index 0000000..b5589de --- /dev/null +++ b/bin/storybook/package-build.mjs @@ -0,0 +1,10 @@ +import { cp, access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const host = resolve(root, 'libs/ui/storybook-static'); +const child = resolve(root, 'libs/web-components/storybook-static'); +await Promise.all([access(`${host}/index.json`), access(`${child}/index.json`)]); +await cp(child, `${host}/web-components`, { recursive: true }); +console.log(`Combined Storybook artifact: ${host}`); diff --git a/bin/storybook/routes.d.mts b/bin/storybook/routes.d.mts new file mode 100644 index 0000000..fde6e2b --- /dev/null +++ b/bin/storybook/routes.d.mts @@ -0,0 +1,4 @@ +export const legacyReactIds: string[]; +export const storybookOrigin: string; +export function legacyRedirect(href: string, ids: string[]): string | null; +export function legacyRedirectHead(): string; diff --git a/bin/storybook/routes.mjs b/bin/storybook/routes.mjs new file mode 100644 index 0000000..b5c0176 --- /dev/null +++ b/bin/storybook/routes.mjs @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; + +// Frozen from the pre-migration Storybook index. Do not regenerate from the new +// index: that would hide accidentally removed stories and break old bookmarks. +export const legacyReactIds = JSON.parse(readFileSync(new URL('./legacy-react-ids.json', import.meta.url), 'utf8')); +export const storybookOrigin = 'https://storybook.cto-rnd-system-design.griddynamics.net'; + +/** Resolve legacy manager, iframe and bare /docs links without dropping URL state. */ +export function legacyRedirect(href, ids) { + const url = new URL(href); + const known = new Set(ids); + const upgrade = (id) => (known.has(id) ? `react-${id}` : id); + const original = url.href; + const path = url.searchParams.get('path'); + if (path) { + url.searchParams.set( + 'path', + path.replace(/^(\/(?:docs|story)\/)([^#?]+)/, (_, prefix, id) => prefix + upgrade(id)) + ); + } else { + const bare = url.pathname.match(/^\/(docs|story)\/([^/]+)$/); + if (bare && (known.has(bare[2]) || bare[2].startsWith('react-'))) { + url.pathname = '/'; + url.searchParams.set('path', `/${bare[1]}/${upgrade(bare[2])}`); + } + } + const id = url.searchParams.get('id'); + if (id && known.has(id)) url.searchParams.set('id', upgrade(id)); + // URLSearchParams may normalize encoding even when no route changed. + const before = new URL(original); + return url.pathname !== before.pathname || + url.searchParams.get('path') !== before.searchParams.get('path') || + url.searchParams.get('id') !== before.searchParams.get('id') + ? url.href + : null; +} + +export function legacyRedirectHead() { + return ``; +} diff --git a/bin/storybook/routes.test.mjs b/bin/storybook/routes.test.mjs new file mode 100644 index 0000000..d7d8feb --- /dev/null +++ b/bin/storybook/routes.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { legacyReactIds, legacyRedirect, legacyRedirectHead } from './routes.mjs'; + +const origin = 'https://storybook.example'; + +test('every legacy story and docs route redirects to its React ID', () => { + for (const id of legacyReactIds) { + const kind = id.endsWith('--docs') ? 'docs' : 'story'; + const result = new URL( + legacyRedirect(`${origin}/?path=/${kind}/${id}&args=size:sm&globals=theme:dark#example`, legacyReactIds) + ); + assert.equal(result.searchParams.get('path'), `/${kind}/react-${id}`); + assert.equal(result.searchParams.get('args'), 'size:sm'); + assert.equal(result.searchParams.get('globals'), 'theme:dark'); + assert.equal(result.hash, '#example'); + } +}); + +test('iframe links retain their view mode and args', () => { + const result = new URL( + legacyRedirect(`${origin}/iframe.html?id=atoms-button--docs&viewMode=docs&args=size:sm`, legacyReactIds) + ); + assert.equal(result.pathname, '/iframe.html'); + assert.equal(result.searchParams.get('id'), 'react-atoms-button--docs'); + assert.equal(result.searchParams.get('viewMode'), 'docs'); + assert.equal(result.searchParams.get('args'), 'size:sm'); +}); + +test('bare docs links become manager routes', () => { + const result = new URL(legacyRedirect(`${origin}/docs/introduction-welcome--docs#intro`, legacyReactIds)); + assert.equal(result.pathname, '/'); + assert.equal(result.searchParams.get('path'), '/docs/react-introduction-welcome--docs'); + assert.equal(result.hash, '#intro'); +}); + +test('canonical, composed, unrelated and unknown routes are not redirected', () => { + for (const path of [ + '/?path=/docs/react-introduction-welcome--docs', + '/?path=/story/web-components_introduction-overview--overview', + '/web-components/?path=/story/introduction-overview--overview', + '/?path=/docs/unknown--docs', + '/docs/api', + '/', + ]) + assert.equal(legacyRedirect(origin + path, legacyReactIds), null); +}); + +test('early head script is self-contained', () => { + const calls = []; + const script = legacyRedirectHead().replace(/^`; + return ``; } diff --git a/bin/storybook/routes.test.mjs b/bin/storybook/routes.test.mjs index d7d8feb..8213365 100644 --- a/bin/storybook/routes.test.mjs +++ b/bin/storybook/routes.test.mjs @@ -1,16 +1,15 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { legacyReactIds, legacyRedirect, legacyRedirectHead } from './routes.mjs'; +import { legacyReactIds, sharedDocIds, legacyRedirect, legacyRedirectHead } from './routes.mjs'; const origin = 'https://storybook.example'; -test('every legacy story and docs route redirects to its React ID', () => { +test('every legacy story and docs route resolves to its canonical root', () => { for (const id of legacyReactIds) { const kind = id.endsWith('--docs') ? 'docs' : 'story'; - const result = new URL( - legacyRedirect(`${origin}/?path=/${kind}/${id}&args=size:sm&globals=theme:dark#example`, legacyReactIds) - ); - assert.equal(result.searchParams.get('path'), `/${kind}/react-${id}`); + const href = `${origin}/?path=/${kind}/${id}&args=size:sm&globals=theme:dark#example`; + const result = new URL(legacyRedirect(href, legacyReactIds, sharedDocIds) || href); + assert.equal(result.searchParams.get('path'), `/${kind}/${sharedDocIds.includes(id) ? id : `react-${id}`}`); assert.equal(result.searchParams.get('args'), 'size:sm'); assert.equal(result.searchParams.get('globals'), 'theme:dark'); assert.equal(result.hash, '#example'); @@ -28,22 +27,53 @@ test('iframe links retain their view mode and args', () => { }); test('bare docs links become manager routes', () => { - const result = new URL(legacyRedirect(`${origin}/docs/introduction-welcome--docs#intro`, legacyReactIds)); + const result = new URL( + legacyRedirect(`${origin}/docs/introduction-welcome--docs#intro`, legacyReactIds, sharedDocIds) + ); assert.equal(result.pathname, '/'); - assert.equal(result.searchParams.get('path'), '/docs/react-introduction-welcome--docs'); + assert.equal(result.searchParams.get('path'), '/docs/introduction-welcome--docs'); assert.equal(result.hash, '#intro'); }); test('canonical, composed, unrelated and unknown routes are not redirected', () => { for (const path of [ - '/?path=/docs/react-introduction-welcome--docs', + '/?path=/docs/introduction-welcome--docs', + '/?path=/docs/react-introduction-getting-started--docs', '/?path=/story/web-components_introduction-overview--overview', '/web-components/?path=/story/introduction-overview--overview', '/?path=/docs/unknown--docs', '/docs/api', '/', ]) - assert.equal(legacyRedirect(origin + path, legacyReactIds), null); + assert.equal(legacyRedirect(origin + path, legacyReactIds, sharedDocIds), null); +}); + +test('all interim React shared-doc links redirect once, including iframe and bare routes', () => { + for (const id of sharedDocIds) { + for (const path of [`/?path=/docs/react-${id}`, `/iframe.html?id=react-${id}`, `/docs/react-${id}`]) { + const href = `${origin}${path}&args=size:sm#section`; + // Bare routes have no query marker yet. + const input = path.startsWith('/docs/') ? href.replace('&args=', '?args=') : href; + const result = new URL(legacyRedirect(input, legacyReactIds, sharedDocIds)); + assert.equal( + result.searchParams.get('id') || result.searchParams.get('path'), + path.startsWith('/iframe') ? id : `/docs/${id}` + ); + assert.equal(result.searchParams.get('args'), 'size:sm'); + assert.equal(result.hash, '#section'); + assert.equal(legacyRedirect(result.href, legacyReactIds, sharedDocIds), null); + } + } +}); + +test('early head script also redirects shared pages without external dependencies', () => { + const calls = []; + const script = legacyRedirectHead().replace(/^`; +} + +/** Use the same property-based theme contract as application consumers. */ +export function element(tag: T, props: Partial = {}, text = '') { + const node = document.createElement(tag); + Object.assign(node, { theme: defaultTheme as DesignCoreTheme }, props); + node.textContent = text; + return node; +} + +/** Each render owns its output and listeners; no state leaks between stories. */ +export function observed(node: HTMLElement, eventName: string, initial: unknown) { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:16px;max-width:480px;font-family:"Fira Sans",sans-serif'; + const output = document.createElement('output'); + output.setAttribute('aria-label', 'Last event'); + output.textContent = JSON.stringify(initial); + node.addEventListener(eventName, (event) => { + output.textContent = JSON.stringify((event as CustomEvent).detail); + }); + section.append(node, output); + return section; +} + +export const items = [ + { name: 'Alpha', value: 'a' }, + { name: 'Beta', value: 'b' }, + { name: 'Gamma', value: 'c' }, +]; + +export const portrait = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" fill="%2391b8d8"/%3E%3Ccircle cx="32" cy="24" r="13" fill="%23f1c7a5"/%3E%3Cpath d="M8 64c3-18 14-27 24-27s21 9 24 27" fill="%233e6184"/%3E%3C/svg%3E'; diff --git a/libs/web-components/test/story-source.spec.ts b/libs/web-components/test/story-source.spec.ts new file mode 100644 index 0000000..373598e --- /dev/null +++ b/libs/web-components/test/story-source.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import '../src/index'; +import { element, items, storySource } from '../stories/helpers'; + +function source(...nodes: HTMLElement[]) { + const canvas = document.createElement('main'); + canvas.append(...nodes); + return storySource(canvas, 'fallback'); +} + +describe('Storybook custom-element source', () => { + it('shows the effective properties for each Button state without Lit internals', () => { + const secondary = source(element('gd-button', { variant: 'secondary' }, 'Button')); + const disabled = source(element('gd-button', { variant: 'primary', disabled: true }, 'Button')); + + expect(secondary).toContain('Button'); + expect(disabled).toMatch(/]*variant="primary"[^>]*disabled[^>]*>Button<\/gd-button>/); + expect(secondary).toContain('component.theme = defaultTheme;'); + expect(secondary).toContain("import 'web-components';"); + expect(secondary).not.toContain('_hasContent'); + }); + + it('uses JavaScript properties and native events for non-attribute Select inputs', () => { + const select = element('gd-select', { items, value: items[1], multiple: true }); + const generated = source(select); + + expect(generated).toContain('component.items = ['); + expect(generated).toContain('component.value = {'); + expect(generated).toContain("component.addEventListener('gd-change'"); + }); + + it('keeps named slots in the consumer markup', () => { + const button = element('gd-button', { variant: 'outlined' }, 'Continue'); + const icon = document.createElement('span'); + icon.slot = 'icon-start'; + icon.textContent = '→'; + button.prepend(icon); + + expect(source(button)).toContain('→Continue'); + }); +}); From 6d061785840801f3ae7493e3943b1880374b6c9a Mon Sep 17 00:00:00 2001 From: Zlodej43sm <8863227+Zlodej43sm@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:47:20 +0200 Subject: [PATCH 04/10] feat: CTORNDSD-679 Update Storybook and documentation links for React components, integrate Web Components support, and enhance build workflows. --- CLAUDE.md | 2 +- README.md | 8 +- bin/storybook/check-links.mjs | 11 ++ bin/storybook/native-checks.mjs | 158 ++++++++++++++++++ bin/storybook/smoke-test.mjs | 7 + .../src/stores/createSelectStore.ts | 5 + libs/ui/src/stories/Introduction/Welcome.mdx | 4 +- libs/web-components/.storybook/preview.ts | 9 + libs/web-components/.storybook/vite.config.ts | 10 +- libs/web-components/README.md | 34 +++- libs/web-components/react-parity.json | 153 +++++++++++++++++ .../scripts/check-ported-elements.mjs | 22 ++- .../components/atoms/gd-avatar/gd-avatar.ts | 12 +- .../atoms/gd-checkbox/gd-checkbox.ts | 3 +- .../atoms/gd-input/gd-input.spec.ts | 19 +++ .../src/components/atoms/gd-input/gd-input.ts | 48 +++++- .../atoms/gd-select/gd-select.spec.ts | 66 ++++++++ .../components/atoms/gd-select/gd-select.ts | 99 +++++++++-- .../atoms/gd-typography/gd-typography.spec.ts | 64 +++++++ .../atoms/gd-typography/gd-typography.ts | 64 +++++-- .../molecules/gd-counter/gd-counter.ts | 3 +- .../molecules/gd-menu/gd-menu.spec.ts | 16 ++ .../components/molecules/gd-menu/gd-menu.ts | 20 ++- libs/web-components/stories/Avatar.stories.ts | 51 ++++++ libs/web-components/stories/Button.stories.ts | 79 +++++++++ .../stories/Checkbox.stories.ts | 33 ++++ .../web-components/stories/Counter.stories.ts | 19 +++ libs/web-components/stories/Input.stories.ts | 104 ++++++++++++ libs/web-components/stories/Menu.stories.ts | 68 ++++++++ .../stories/Overview.stories.ts | 8 +- libs/web-components/stories/Select.stories.ts | 88 ++++++++++ .../stories/Typography.stories.ts | 119 +++++++++++++ libs/web-components/stories/helpers.ts | 147 ++++++++++++++++ libs/web-components/test/story-source.spec.ts | 41 +++++ 34 files changed, 1532 insertions(+), 62 deletions(-) create mode 100644 bin/storybook/native-checks.mjs create mode 100644 libs/web-components/react-parity.json create mode 100644 libs/web-components/src/components/atoms/gd-select/gd-select.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-typography/gd-typography.spec.ts create mode 100644 libs/web-components/stories/Avatar.stories.ts create mode 100644 libs/web-components/stories/Button.stories.ts create mode 100644 libs/web-components/stories/Checkbox.stories.ts create mode 100644 libs/web-components/stories/Counter.stories.ts create mode 100644 libs/web-components/stories/Input.stories.ts create mode 100644 libs/web-components/stories/Menu.stories.ts create mode 100644 libs/web-components/stories/Select.stories.ts create mode 100644 libs/web-components/stories/Typography.stories.ts create mode 100644 libs/web-components/stories/helpers.ts create mode 100644 libs/web-components/test/story-source.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 79dd69b..b434a21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ 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. Phase 1 contains only the native overview; component coverage and harness cleanup are later phases. +**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 the six existing atoms and two molecules. `node bin/storybook/smoke-test.mjs` verifies native rendering and interactions after the combined build. Do not migrate additional components or remove Angular/Vue checks; harness cleanup is Phase 3. **Package subpaths** — the published package exposes four explicit subpaths (wildcards are intentionally removed to prevent internal path coupling): diff --git a/README.md b/README.md index e464aaf..309aff4 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,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 the Phase 1 overview; Phase 2 adds stories for the six existing atoms and two molecules. +contains native stories and controls for the six existing atoms and two molecules. `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. @@ -82,7 +82,7 @@ including the composed preview under `web-components/`. ```bash npm run storybook:check-links # Validate built Storybooks and shipped AI documentation links -npm run storybook:test # Existing React interaction tests (native coverage follows in Phase 2) +npm run storybook:test # React interaction tests npm run storybook:visual # Existing React visual tests ``` @@ -98,7 +98,9 @@ Use the IDs in the generated `index.json` for links. Both pre-migration URLs and list to refresh when a story disappears. Link checks cover source documentation and the `llms.txt`, AI Markdown/JSON, and AI JavaScript copied into the UI package. -The Angular/Vue and other Web Components harnesses are unchanged in Phase 1. +Native browser smoke checks run with `node bin/storybook/smoke-test.mjs` after the combined build. +They render every native story and exercise custom events and popover dismissal. +The Angular/Vue and other Web Components harnesses remain unchanged until Phase 3. ## Verification (gd-design-library) diff --git a/bin/storybook/check-links.mjs b/bin/storybook/check-links.mjs index af8ba35..ff519dd 100644 --- a/bin/storybook/check-links.mjs +++ b/bin/storybook/check-links.mjs @@ -67,6 +67,17 @@ 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'); +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', 'default']) { + const id = `${group}-${name}--${variant}`; + if (!indexes.webComponents.entries[id]) errors.push(`Missing native Web Components entry: ${id}`); + } + } +} const excluded = new Set(['node_modules', '.git', 'dist', 'storybook-static', 'coverage', 'output', '.nx']); const extensions = new Set([ diff --git a/bin/storybook/native-checks.mjs b/bin/storybook/native-checks.mjs new file mode 100644 index 0000000..67c3ec1 --- /dev/null +++ b/bin/storybook/native-checks.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; + +/** Trusted browser input is required for native popover light-dismiss. */ +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) + ); + const names = new Set(); + for (const story of stories) { + const name = story.title.split('/')[1].toLowerCase(); + names.add(name); + await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`); + await page.locator(`gd-${name}`).first().waitFor(); + await page.waitForFunction((tag) => { + const el = document.querySelector(tag); + return el?.shadowRoot?.childElementCount > 0; + }, `gd-${name}`); + } + assert.deepEqual([...names].sort(), [ + 'avatar', + 'button', + 'checkbox', + 'counter', + 'input', + 'menu', + 'select', + 'typography', + ]); + const open = async (id) => { + await page.goto(`${base}/web-components/iframe.html?id=${id}&viewMode=story`); + await page.locator('#storybook-root').waitFor(); + }; + const event = async (detail) => + page + .locator('output') + .filter({ hasText: JSON.stringify(detail) }) + .waitFor(); + await open('atoms-button--disabled'); + await page.getByRole('button', { name: 'Button', exact: true }).waitFor(); + assert.ok(await page.getByRole('button', { name: 'Button', exact: true }).isDisabled()); + await open('atoms-input--default'); + await page.getByRole('textbox', { name: 'Label', exact: true }).fill('Updated value'); + await event({ value: 'Updated value' }); + await open('atoms-input--read-only'); + assert.ok( + await page + .getByRole('textbox', { name: 'Label', exact: true }) + .isEditable() + .then((value) => !value) + ); + await open('atoms-checkbox--default'); + // The native input is visually hidden; users activate its visible label. + await page.locator('gd-checkbox label').click(); + await event({ checked: true }); + await page.locator('gd-checkbox label').click(); + await event({ checked: false }); + await open('atoms-select--default'); + await page.getByRole('button', { name: 'Choose an option' }).click(); + await page.getByRole('option', { name: 'Beta' }).click(); + await event({ value: { name: 'Beta', value: 'b' } }); + await page.getByRole('listbox').waitFor({ state: 'hidden' }); + await open('atoms-select--multiple'); + await page.getByRole('button', { name: 'Alpha, Gamma' }).click(); + await page.getByRole('option', { name: 'Beta' }).click(); + await event({ + value: [ + { name: 'Alpha', value: 'a' }, + { name: 'Gamma', value: 'c' }, + { name: 'Beta', value: 'b' }, + ], + }); + await page.getByRole('listbox').waitFor(); + await open('atoms-select--searchable'); + await page.getByRole('button', { name: 'Choose an option' }).click(); + await page.getByRole('searchbox', { name: 'Search options' }).fill('bet'); + await page.getByRole('option', { name: 'Beta' }).waitFor(); + assert.equal(await page.getByRole('option', { name: 'Alpha' }).count(), 0); + await open('atoms-typography--all-variants'); + assert.equal(await page.locator('gd-typography').count(), 18); + assert.deepEqual( + await page + .locator('gd-typography') + .evaluateAll((elements) => + elements.map((element) => element.shadowRoot?.firstElementChild?.tagName.toLowerCase()) + ), + [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'small', + 'div', + 'span', + 'strong', + 'i', + 'code', + 'kbd', + 'span', + 'header', + 'sup', + 'sub', + ] + ); + await open('atoms-typography--display-sizes'); + const displayFontSizes = await page + .locator('gd-typography') + .evaluateAll((elements) => + elements.map((element) => getComputedStyle(element.shadowRoot.firstElementChild).fontSize) + ); + assert.equal(new Set(displayFontSizes).size, 5); + await open('molecules-counter--default'); + await page.getByRole('button', { name: 'Increment counter', exact: true }).click(); + await event({ value: 2 }); + await page.getByRole('button', { name: 'Decrement counter', exact: true }).click(); + await event({ value: 1 }); + assert.ok(await page.getByRole('button', { name: 'Decrement counter', exact: true }).isDisabled()); + await page.getByRole('spinbutton', { name: 'Quantity value' }).fill('99'); + await page.getByRole('spinbutton', { name: 'Quantity value' }).press('Tab'); + await event({ value: 5 }); + assert.ok(await page.getByRole('button', { name: 'Increment counter', exact: true }).isDisabled()); + const menuClosed = () => + page.waitForFunction(() => { + const menu = document.querySelector('gd-menu'); + const content = menu?.shadowRoot.querySelector('[popover]'); + return ( + menu && + content && + !menu.open && + !content.matches(':popover-open') && + getComputedStyle(content).display === 'none' + ); + }); + await open('molecules-menu--default'); + await page.getByRole('button', { name: 'Actions', exact: true }).click(); + await page.getByRole('button', { name: 'Edit', exact: true }).click(); + await event({ data: { name: 'Edit', value: 'edit' }, value: 'edit' }); + await menuClosed(); + await page.getByRole('button', { name: 'Actions', exact: true }).click(); + await page.getByRole('button', { name: 'Edit', exact: true }).waitFor(); + await page.keyboard.press('Escape'); + await menuClosed(); + await page.getByRole('button', { name: 'Actions', exact: true }).click(); + await page.getByRole('button', { name: 'Edit', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Outside menu' }).click(); + await menuClosed(); + await open('molecules-menu--keep-open-on-select'); + await page.getByRole('button', { name: 'Actions', exact: true }).click(); + await page.getByRole('button', { name: 'Archive', exact: true }).click(); + await event({ data: { name: 'Archive', value: 'archive' }, value: 'archive' }); + await page.getByRole('button', { name: 'Edit', exact: true }).waitFor(); + console.log( + `Verified ${stories.length} native component stories and input/checkbox/select/typography/counter/menu interactions.` + ); +} diff --git a/bin/storybook/smoke-test.mjs b/bin/storybook/smoke-test.mjs index 0591924..07a15a5 100644 --- a/bin/storybook/smoke-test.mjs +++ b/bin/storybook/smoke-test.mjs @@ -4,6 +4,7 @@ import { readFile, stat } from 'node:fs/promises'; import { extname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; +import { checkNativeStories } from './native-checks.mjs'; // Serve the exact combined artifact. --url also checks the common dev command. const root = fileURLToPath(new URL('../../libs/ui/storybook-static/', import.meta.url)); @@ -99,10 +100,16 @@ try { const nativeFrame = page.frameLocator('#storybook-ref-web-components'); await nativeFrame.getByRole('heading', { name: 'GridKit Web Components', exact: true }).waitFor(); + await page.goto(`${base}/?path=/story/web-components_atoms-button--default`); + await nativeFrame.getByRole('button', { name: 'Button', exact: true }).waitFor(); + await page.goto(`${base}/?path=/docs/web-components_atoms-button--docs`); + await nativeFrame.getByRole('heading', { name: 'Button', exact: true }).first().waitFor(); + await page.goto(`${base}/iframe.html?id=atoms-button--default&viewMode=story&args=size:sm`); await page.waitForURL((url) => url.searchParams.get('id') === 'react-atoms-button--default'); await page.getByRole('button').first().waitFor(); assert.equal(new URL(page.url()).searchParams.get('args'), 'size:sm'); + if (!process.argv.includes('--url')) await checkNativeStories(page, base); assert.deepEqual(errors, [], 'Storybook should not raise browser runtime errors'); console.log( 'Storybook smoke checks passed: React docs, AI docs, internal links, composition, and legacy manager/iframe routes.' diff --git a/libs/design-core/src/stores/createSelectStore.ts b/libs/design-core/src/stores/createSelectStore.ts index 6348efc..1c4ea0a 100644 --- a/libs/design-core/src/stores/createSelectStore.ts +++ b/libs/design-core/src/stores/createSelectStore.ts @@ -25,6 +25,7 @@ export interface SelectStoreActions { toggle: () => void; setSearchText: (text: string) => void; setDisabled: (disabled: boolean) => void; + setMultiple: (multiple: boolean) => void; /** Call when the consumer's external `value` prop changes — mirrors `Select.tsx`'s value-sync effect. */ syncExternalValue: (value: SelectValue) => void; /** Mirrors `Select.tsx`'s `_onSelect`: single-select replaces + closes, multi-select toggles membership. */ @@ -74,6 +75,10 @@ export function createSelectStore(options: CreateSelectStoreOptions = {}) { }, setSearchText: (text) => set({ searchText: text }), setDisabled: (next) => set({ disabled: next }), + setMultiple: (next) => { + const state = get(); + set({ multiple: next, internalValue: normalizeValue(state.internalValue, next) }); + }, syncExternalValue: (nextValue) => { const state = get(); diff --git a/libs/ui/src/stories/Introduction/Welcome.mdx b/libs/ui/src/stories/Introduction/Welcome.mdx index 3a006f1..0e109d4 100644 --- a/libs/ui/src/stories/Introduction/Welcome.mdx +++ b/libs/ui/src/stories/Introduction/Welcome.mdx @@ -26,8 +26,8 @@ helps teams build consistent, accessible, and high-performing web interfaces acr Shared **Introduction**, **Theme & Tokens**, and framework-neutral **Patterns & Best Practices** appear first in the sidebar. Below them, **React** contains React components and integration -guides; **Web Components** contains the native custom-element catalog. Its Phase 1 -overview is available now, with stories for the eight existing Web Components following in Phase 2. +guides; **Web Components** contains native stories for Avatar, Button, Checkbox, Input, +Select, Typography, Counter, and Menu, with interactive controls and custom-event examples. GridKit follows **Atomic Design** methodology for component organization: diff --git a/libs/web-components/.storybook/preview.ts b/libs/web-components/.storybook/preview.ts index 0ad89e6..4694774 100644 --- a/libs/web-components/.storybook/preview.ts +++ b/libs/web-components/.storybook/preview.ts @@ -3,12 +3,21 @@ import type { Preview } from '@storybook/web-components-vite'; // Generated by the common Storybook workflow, using the production UI build. // eslint-disable-next-line @nx/enforce-module-boundaries import '../../../dist/libs/ui/styles.css'; +import '../src/index'; +import { storySource } from '../stories/helpers'; const preview: Preview = { parameters: { layout: 'padded', options: { storySort: { order: ['Introduction', 'Atoms', 'Molecules'] } }, a11y: { test: 'todo' }, + docs: { + source: { + language: 'html', + transform: (source: string, context: { canvasElement?: HTMLElement }) => + storySource(context.canvasElement, source), + }, + }, }, }; diff --git a/libs/web-components/.storybook/vite.config.ts b/libs/web-components/.storybook/vite.config.ts index c049f46..c113a29 100644 --- a/libs/web-components/.storybook/vite.config.ts +++ b/libs/web-components/.storybook/vite.config.ts @@ -1,3 +1,11 @@ import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; -export default defineConfig({}); +export default defineConfig({ + resolve: { + alias: { + 'gd-design-core': fileURLToPath(new URL('../../design-core/src/index.ts', import.meta.url)), + 'gd-design-library/tokens': fileURLToPath(new URL('../../../dist/libs/ui/tokens/index.js', import.meta.url)), + }, + }, +}); diff --git a/libs/web-components/README.md b/libs/web-components/README.md index 3cf68d0..c61c2c2 100644 --- a/libs/web-components/README.md +++ b/libs/web-components/README.md @@ -99,9 +99,37 @@ const accepted = ref(false); ## Run locally The shared Storybook is started from the repository root with `npm run storybook` and -opened at `http://localhost:6006`. Its **Web Components** section currently contains the -Phase 1 overview; native stories for the eight existing elements follow in Phase 2. -There is no separate Web Components Storybook command. The existing Angular/Vue harness +opened at `http://localhost:6006`. Its **Web Components** section contains native stories, Controls, and docs for all eight +existing elements. Interactive stories show native event payloads below the component. + +| Storybook component | React-contract coverage | +| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| [Avatar](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-avatar--docs) | Image, fallback, badge, every size, custom colors | +| [Button](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-button--docs) | Every variant and radius, full-width, icon-only, loading, disabled, icon slots | +| [Checkbox](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-checkbox--docs) | Checked, indeterminate, disabled, both sizes; `gd-change` → `{ checked }` | +| [Input](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-input--docs) | Types, validation colors, read-only/disabled, adornments; `gd-input`/`gd-change` | +| [Select](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-select--docs) | Single/multiple, search, auto-open, colors, adornments, custom initiator; `gd-change` | +| [Typography](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-typography--docs) | All 18 variants, display sizes, style variants, alignment/color controls, semantic override | +| [Counter](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_molecules-counter--docs) | Minimum/maximum, custom range, disabled; `gd-change` → `{ value: number }` | +| [Menu](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_molecules-menu--docs) | Placement, offsets, height constraints, close/persist behavior; `gd-change` → `{ data, value }` | + +### React-to-custom-element contract + +The ports preserve each source component's component-specific public contract using native +custom-element equivalents. Primitive props remain attributes/properties; objects, arrays, +functions, themes, and style objects are JavaScript properties. React callbacks become native +DOM/custom events, `ReactNode` props become default or named slots, and imperative refs become +public element methods. Generic React/Emotion box props are supplied with host CSS or the +component's `styles` property rather than reproduced as React-only prop names. + +`react-parity.json` records the required mapping and Storybook examples for every shipped port. +`npm run check:web-components-ports` fails when an element, required mapping, or parity story is +missing. Unit tests cover the programmatic property/event contract; the Storybook smoke test also +checks multiple/searchable Select behavior and every Typography variant/display size in Chromium. + +There is no separate Web Components Storybook command. Run `npm run build-storybook` +then `node bin/storybook/smoke-test.mjs` to verify every native story plus input, checkbox, +select, counter, and menu interactions in Chromium. The existing Angular/Vue harness workflow below remains available until the later integration-check cleanup. Start the development server for all Web Components examples: diff --git a/libs/web-components/react-parity.json b/libs/web-components/react-parity.json new file mode 100644 index 0000000..8213919 --- /dev/null +++ b/libs/web-components/react-parity.json @@ -0,0 +1,153 @@ +{ + "gd-avatar": { + "react": "Avatar", + "source": [ + "src", + "alt", + "sizeVariant", + "withBadge", + "badgeColor", + "backgroundColor", + "styles", + "slot name=\"fallback\"" + ], + "stories": ["Default", "Image", "Badge", "AllSizes", "CustomColors", "SlottedFallback"] + }, + "gd-button": { + "react": "Button", + "source": [ + "variant", + "rounded", + "disabled", + "fullWidth", + "isIcon", + "isLoading", + "type", + "role", + "tabIndex", + "ariaLabel", + "ariaPressed", + "styles", + "slot name=\"icon-start\"", + "slot name=\"icon-end\"" + ], + "stories": ["Default", "AllVariants", "Rounded", "FullWidth", "IconOnly", "Loading", "IconSlots"] + }, + "gd-checkbox": { + "react": "Checkbox", + "source": ["checked", "indeterminate", "disabled", "name", "value", "size", "styles", "gd-change"], + "stories": ["Default", "Checked", "Indeterminate", "Disabled", "Sizes"] + }, + "gd-input": { + "react": "Input", + "source": [ + "variant", + "color", + "disabled", + "readOnly", + "value", + "defaultValue", + "checked", + "defaultChecked", + "name", + "width", + "styles", + "placeholder", + "label", + "helperText", + "required", + "ariaRequiredValue", + "ariaDescribedBy", + "role", + "inputmode", + "tabIndex", + "debounceCallbackTime", + "slot name=\"adornment-start\"", + "slot name=\"adornment-end\"", + "gd-input", + "gd-change" + ], + "stories": ["Default", "Empty", "Disabled", "ReadOnly", "ValidationColors", "WithAdornments", "InputTypes"] + }, + "gd-select": { + "react": "Select", + "source": [ + "items", + "itemsCount", + "value", + "disabled", + "color", + "placeholder", + "multiple", + "searchable", + "searchPlaceholder", + "autoOpen", + "activeIndex", + "dropdownMaxHeight", + "itemIdentifier", + "itemStringifier", + "renderOption", + "width", + "minWidth", + "maxWidth", + "styles", + "slot name=\"initiator\"", + "slot name=\"empty\"", + "slot name=\"adornment-start\"", + "slot name=\"adornment-end\"", + "gd-change", + "open()", + "close()" + ], + "stories": [ + "Default", + "Selected", + "Disabled", + "Empty", + "Multiple", + "Searchable", + "AutoOpen", + "ValidationColors", + "WithAdornments", + "CustomInitiator" + ] + }, + "gd-typography": { + "react": "Typography", + "source": ["variant", "as", "color", "size", "align", "styleVariant", "styles", "slot"], + "stories": [ + "Default", + "AllVariants", + "DisplaySizes", + "StyleVariants", + "SemanticOverride", + "CodeAndKeyboard", + "CaptionAndHeader" + ] + }, + "gd-counter": { + "react": "Counter", + "source": ["min", "max", "initial", "isDisabled", "styles", "gd-change"], + "stories": ["Default", "AdjustedMaximum", "AdjustedMinimum", "CustomRange", "Disabled"] + }, + "gd-menu": { + "react": "Menu", + "source": [ + "itemIdentifier", + "closeOnSelect", + "offsetX", + "offsetY", + "minHeight", + "maxHeight", + "placement", + "selectedValue", + "styles", + "slot name=\"trigger\"", + "slot name=\"content\"", + "gd-change", + "openMenu()", + "closeMenu()" + ], + "stories": ["Default", "KeepOpenOnSelect", "Placement", "WithOffset", "WithHeightConstraints"] + } +} diff --git a/libs/web-components/scripts/check-ported-elements.mjs b/libs/web-components/scripts/check-ported-elements.mjs index 8a8c95a..0330af2 100644 --- a/libs/web-components/scripts/check-ported-elements.mjs +++ b/libs/web-components/scripts/check-ported-elements.mjs @@ -37,14 +37,32 @@ if (missingFromManifest.length || missingFromSource.length) { ); } -const [index, ssr] = await Promise.all([ +const [index, ssr, parityText] = await Promise.all([ readFile(resolve(packageRoot, 'src/index.ts'), 'utf8'), readFile(resolve(packageRoot, 'scripts/ssr-dsd-render.ts'), 'utf8'), + readFile(resolve(packageRoot, 'react-parity.json'), 'utf8'), ]); +const parity = JSON.parse(parityText); for (const { tag, category } of ports) { const modulePath = `./components/${category}/${tag}/${tag}`; if (!index.includes(modulePath)) throw new Error(`Missing public export for ${tag}: ${modulePath}`); if (!ssr.includes(`<${tag}`)) throw new Error(`SSR harness does not render ${tag}.`); } -console.log(`Verified ${ports.length} Web Component ports: ${ports.map(({ tag }) => tag).join(', ')}`); +for (const { tag, category } of ports) { + const contract = parity[tag]; + if (!contract) throw new Error(`Missing React parity contract for ${tag}.`); + const source = await readFile(resolve(componentsRoot, category, tag, `${tag}.ts`), 'utf8'); + const storyName = tag.slice(3).replace(/(^|-)([a-z])/g, (_match, _separator, letter) => letter.toUpperCase()); + const stories = await readFile(resolve(packageRoot, 'stories', `${storyName}.stories.ts`), 'utf8'); + for (const needle of contract.source) { + if (!source.includes(needle)) throw new Error(`${tag} is missing React ${contract.react} API mapping: ${needle}`); + } + for (const story of contract.stories) { + if (!stories.includes(`export const ${story}`)) throw new Error(`${tag} is missing parity story: ${story}`); + } +} + +console.log( + `Verified ${ports.length} Web Component ports and React parity contracts: ${ports.map(({ tag }) => tag).join(', ')}` +); diff --git a/libs/web-components/src/components/atoms/gd-avatar/gd-avatar.ts b/libs/web-components/src/components/atoms/gd-avatar/gd-avatar.ts index 456fdb7..1084a78 100644 --- a/libs/web-components/src/components/atoms/gd-avatar/gd-avatar.ts +++ b/libs/web-components/src/components/atoms/gd-avatar/gd-avatar.ts @@ -92,10 +92,13 @@ export class GdAvatar extends LitElement { @property({ type: String, reflect: true }) src?: string; @property({ type: String, reflect: true }) alt = ''; @property({ type: String, reflect: true }) size: AvatarSize = 'md'; + /** Canonical React prop name; `size` remains as a backwards-compatible custom-element alias. */ + @property({ type: String, attribute: 'size-variant' }) sizeVariant?: AvatarSize; @property({ type: Boolean, attribute: 'with-badge', reflect: true }) withBadge = false; @property({ type: String, attribute: 'badge-color', reflect: true }) badgeColor = 'bg.fill.success.primary.default'; @property({ type: String, attribute: 'background-color', reflect: true }) backgroundColor?: string; @property({ type: String, reflect: true }) fallback?: string; + @property({ attribute: false }) styles: Record = {}; @property({ attribute: false }) theme: DesignCoreTheme = {}; @state() private _failed = false; @@ -112,20 +115,21 @@ export class GdAvatar extends LitElement { } render() { - const tokens = resolveAvatarTokens(this.theme, this.size); + const size = this.sizeVariant ?? this.size; + const tokens = resolveAvatarTokens(this.theme, size); const fallbackFontSize = get( this.theme, - `font.size.${this.size === 'xs' ? 'caption' : this.size === 'sm' ? 'small' : this.size === 'md' ? 'h6' : this.size === 'lg' ? 'h5' : this.size === 'xl' ? 'h4' : 'h3'}`, + `font.size.${size === 'xs' ? 'caption' : size === 'sm' ? 'small' : size === 'md' ? 'h6' : size === 'lg' ? 'h5' : size === 'xl' ? 'h4' : 'h3'}`, '16px' ); - const hostStyle = cssLengths(tokens.default); + const hostStyle = { ...cssLengths(tokens.default), ...this.styles }; const imageStyle = { ...tokens.imageWrapper.default, backgroundColor: resolveColor(this.theme, this.backgroundColor, tokens.imageWrapper.default.backgroundColor), }; const badgeStyle = { ...cssLengths(tokens.badge.default), - ...cssLengths(tokens.badge.size[this.size as Exclude] ?? tokens.badge.size.md), + ...cssLengths(tokens.badge.size[size as Exclude] ?? tokens.badge.size.md), backgroundColor: resolveColor(this.theme, this.badgeColor, this.badgeColor), }; const showImage = Boolean(this.src) && !this._failed; diff --git a/libs/web-components/src/components/atoms/gd-checkbox/gd-checkbox.ts b/libs/web-components/src/components/atoms/gd-checkbox/gd-checkbox.ts index 8061dc7..82bed35 100644 --- a/libs/web-components/src/components/atoms/gd-checkbox/gd-checkbox.ts +++ b/libs/web-components/src/components/atoms/gd-checkbox/gd-checkbox.ts @@ -102,6 +102,7 @@ export class GdCheckbox extends LitElement { @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: String }) size: CheckboxSizeName = 'md'; @property({ attribute: false }) theme: DesignCoreTheme = {}; + @property({ attribute: false }) styles: Record = {}; /** Submitted under this key in `FormData`. No `name` means no form submission, same as native. */ @property({ type: String }) name = ''; /** Submitted value when checked. Matches native ``, which submits `'on'`. */ @@ -258,7 +259,7 @@ export class GdCheckbox extends LitElement { const resolved = resolveCheckboxTokens(this.theme, this.size); const currentChecked = state.checked; - const wrapperStyle = { gap: `${resolved.wrapperGap}` }; + const wrapperStyle = { gap: `${resolved.wrapperGap}`, ...this.styles }; const indicatorStyle = { width: `${resolved.indicatorSize}px`, diff --git a/libs/web-components/src/components/atoms/gd-input/gd-input.spec.ts b/libs/web-components/src/components/atoms/gd-input/gd-input.spec.ts index 54391a1..a1ffa25 100644 --- a/libs/web-components/src/components/atoms/gd-input/gd-input.spec.ts +++ b/libs/web-components/src/components/atoms/gd-input/gd-input.spec.ts @@ -163,4 +163,23 @@ describe('gd-input', () => { expect(el.disabled).toBe(true); }); }); + + it('maps React input behavior and accessibility props to the inner native input', async () => { + mount(''); + const el = host.querySelector('#email')!; + el.width = '320px'; + el.inputmode = 'email'; + el.ariaDescribedBy = 'email-help'; + el.tabIndex = 2; + await settle(el); + const inner = el.shadowRoot!.querySelector('input')!; + expect(inner.type).toBe('email'); + expect(inner.readOnly).toBe(true); + expect(inner.required).toBe(true); + expect(inner.getAttribute('aria-required')).toBe('true'); + expect(inner.getAttribute('aria-describedby')).toBe('email-help'); + expect(inner.inputMode).toBe('email'); + expect(inner.tabIndex).toBe(2); + expect(el.style.width).toBe('320px'); + }); }); diff --git a/libs/web-components/src/components/atoms/gd-input/gd-input.ts b/libs/web-components/src/components/atoms/gd-input/gd-input.ts index c5e8752..618ae96 100644 --- a/libs/web-components/src/components/atoms/gd-input/gd-input.ts +++ b/libs/web-components/src/components/atoms/gd-input/gd-input.ts @@ -175,6 +175,13 @@ export class GdInput extends LitElement { @property({ type: String }) value = ''; @property({ type: String }) type = 'text'; + /** React calls the native input type `variant`; keep `type` as a compatibility alias. */ + @property({ type: String }) variant?: string; + @property({ type: String, attribute: 'default-value' }) defaultValue?: string; + @property({ type: Boolean, reflect: true }) readOnly = false; + @property({ type: Boolean }) checked = false; + @property({ type: Boolean, attribute: 'default-checked' }) defaultChecked = false; + @property({ type: String }) width?: string; /** Component-token layout overrides for composed ports; never consumer literal CSS. */ @property({ attribute: false }) styles: Record = {}; @property({ attribute: false }) inputStyles: Record = {}; @@ -182,6 +189,12 @@ export class GdInput extends LitElement { @property({ type: String, attribute: 'aria-label' }) ariaLabel: string | null = null; @property({ type: String, attribute: 'aria-valuemin' }) ariaValueMin: string | null = null; @property({ type: String, attribute: 'aria-valuemax' }) ariaValueMax: string | null = null; + /** React's boolean `ariaRequired` maps to the platform's string ARIA reflection. */ + @property({ type: Boolean, attribute: 'aria-required' }) ariaRequiredValue = false; + @property({ type: String, attribute: 'aria-describedby' }) ariaDescribedBy: string | null = null; + @property({ type: String }) inputmode?: string; + @property({ type: String }) role: string | null = null; + @property({ type: Number }) tabIndex = 0; @property({ type: String }) placeholder = ''; @property({ type: String }) label = ''; @property({ type: String, attribute: 'helper-text' }) helperText = ''; @@ -280,7 +293,7 @@ export class GdInput extends LitElement { connectedCallback() { super.connectedCallback(); this._unsubscribe = this._store.subscribe(() => this.requestUpdate()); - if (this._defaultValue === undefined) this._defaultValue = this.value; + if (this._defaultValue === undefined) this._defaultValue = this.defaultValue ?? this.value; } disconnectedCallback() { @@ -290,6 +303,7 @@ export class GdInput extends LitElement { firstUpdated() { this._input.value = this.value; + if (this.defaultChecked && !this.checked) this.checked = true; this._syncFormState(); } @@ -298,6 +312,7 @@ export class GdInput extends LitElement { this._store.getState().setDebounceCallbackTime(this.debounceCallbackTime); this._debouncedDispatch = undefined; } + if (this.style && changed.has('width')) this.style.width = this.width ?? ''; } updated(changed: PropertyValues) { @@ -339,10 +354,19 @@ export class GdInput extends LitElement { } private _onInput(event: Event) { - const newValue = (event.target as HTMLInputElement).value; + const control = event.target as HTMLInputElement; + const newValue = control.value; + this.checked = control.checked; this._lastInternalValue = newValue; this.value = newValue; this._getDebouncedDispatch()(newValue); + this.dispatchEvent( + new CustomEvent('gd-change', { + detail: { value: newValue, checked: control.checked }, + bubbles: true, + composed: true, + }) + ); } private _onMouseDown() { @@ -363,6 +387,7 @@ export class GdInput extends LitElement { } render() { + const inputType = this.variant ?? this.type; const resolved = resolveInputTokens(this.theme, this.color); const focusColor = (this.theme.colors as { border?: { focus?: string } } | undefined)?.border?.focus ?? '#0069B4'; const textColor = this.disabled ? resolved.disabledColor : resolved.color; @@ -405,20 +430,33 @@ export class GdInput extends LitElement { return html`
${this.label - ? html`` + ? html`` : nothing}
{ + let host: HTMLElement; + beforeEach(() => { + host = document.createElement('div'); + document.body.replaceChildren(host); + }); + async function mount(props: Partial = {}) { + const el = document.createElement('gd-select'); + Object.assign(el, { theme: defaultTheme, items, ...props }); + host.append(el); + await el.updateComplete; + return el; + } + it('supports multiple values and item toggling', async () => { + const el = await mount({ multiple: true, value: [items[0]] }); + const changes: unknown[] = []; + el.addEventListener('gd-change', (event) => changes.push((event as CustomEvent).detail.value)); + el.shadowRoot!.querySelectorAll('[role="option"]')[1].click(); + await el.updateComplete; + expect(el.value).toEqual([items[0], items[1]]); + expect(changes).toEqual([[items[0], items[1]]]); + }); + it('filters searchable options with the configured stringifier', async () => { + const el = await mount({ searchable: true, searchPlaceholder: 'Find' }); + const search = el.shadowRoot!.querySelector('input[type="search"]')!; + search.value = 'bet'; + search.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + await el.updateComplete; + expect([...el.shadowRoot!.querySelectorAll('[role="option"]')].map((item) => item.textContent?.trim())).toEqual([ + 'Beta', + ]); + }); + it('matches React autoOpen click semantics and defaults', async () => { + const automatic = await mount(); + expect(automatic.autoOpen).toBe(true); + automatic.shadowRoot!.querySelector('.trigger')!.click(); + expect(automatic.shadowRoot!.querySelector('.dropdown')!.matches(':popover-open')).toBe(true); + + const manual = await mount({ autoOpen: false }); + manual.shadowRoot!.querySelector('.trigger')!.click(); + expect(manual.shadowRoot!.querySelector('.dropdown')!.matches(':popover-open')).toBe(false); + manual.open(); + expect(manual.shadowRoot!.querySelector('.dropdown')!.matches(':popover-open')).toBe(true); + }); + it('supports placeholder, adornment, initiator, empty, and custom option extension points', async () => { + const el = await mount({ items: [], placeholder: 'Choose', renderOption: ({ item }) => `Custom ${item.name}` }); + el.innerHTML = + 'StartEndInitiatorNothing'; + await el.updateComplete; + expect(el.shadowRoot!.querySelector('slot[name="adornment-start"]')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('slot[name="adornment-end"]')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('slot[name="initiator"]')).toBeTruthy(); + expect(el.shadowRoot!.querySelector('slot[name="empty"]')).toBeTruthy(); + }); +}); diff --git a/libs/web-components/src/components/atoms/gd-select/gd-select.ts b/libs/web-components/src/components/atoms/gd-select/gd-select.ts index bdbc4e9..bfbf4da 100644 --- a/libs/web-components/src/components/atoms/gd-select/gd-select.ts +++ b/libs/web-components/src/components/atoms/gd-select/gd-select.ts @@ -5,7 +5,9 @@ import { select } from 'gd-design-library/tokens'; import { resolveThemeTree, createSelectStore, + filterSelectOptions, type SelectOption, + type SelectItemIdentifier, type InputColorVariantName, type DesignCoreTheme, } from 'gd-design-core'; @@ -68,10 +70,9 @@ function resolveSelectTokens(theme: DesignCoreTheme, color: InputColorVariantNam } /** - * Select port (per the implementation plan's Migration Example) — reduced-scope PoC: - * single-select only, no search, fixed-below positioning. Explicitly deferred from this - * PoC: multi-select, search filtering, full keyboard navigation parity (see FINDINGS.md - * for what a full-parity port would additionally require). + * Select port. Component-specific React props are represented as custom-element properties, + * named slots, native events, and public methods. ReactNode/function props use slots or + * property callbacks because they cannot be serialized as HTML attributes. * * Platform-native replacement evaluated here: the HTML `popover` attribute gives native * top-layer rendering plus light-dismiss (outside-click/Escape close) for free, replacing @@ -165,9 +166,25 @@ export class GdSelect extends LitElement { `; @property({ type: Array }) items: SelectOption[] = []; - @property({ attribute: false }) value: SelectOption | null = null; + @property({ type: Number, attribute: 'items-count' }) itemsCount?: number; + @property({ attribute: false }) value: SelectOption | SelectOption[] | null = null; @property({ type: Boolean, reflect: true }) disabled = false; @property({ type: String }) color: InputColorVariantName = 'primary'; + @property({ type: String }) placeholder = 'Select'; + @property({ type: Boolean }) multiple = false; + @property({ type: Boolean }) searchable = false; + @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder = 'Search...'; + @property({ type: Boolean, attribute: 'auto-open' }) autoOpen = true; + @property({ type: String, attribute: 'dropdown-max-height' }) dropdownMaxHeight = '240px'; + @property({ attribute: false }) activeIndex?: string | number; + @property({ attribute: false }) itemIdentifier?: SelectItemIdentifier; + @property({ attribute: false }) itemStringifier: (item: SelectOption) => string = (item) => item.name; + @property({ attribute: false }) renderOption?: (data: { + item: SelectOption; + index: number; + isActiveItem: boolean; + className: string; + }) => unknown; /** Mirrors `Select.tsx`'s `width`/`minWidth`/`maxWidth` props (real defaults: * `width: '100%'`, `maxWidth: 'initial'`, `minWidth` unset) — applied to the trigger * wrapper. Without an explicit width, an empty trigger (no selected value, no @@ -177,6 +194,7 @@ export class GdSelect extends LitElement { @property({ type: String }) width = '100%'; @property({ type: String, attribute: 'min-width' }) minWidth?: string; @property({ type: String, attribute: 'max-width' }) maxWidth = 'initial'; + @property({ attribute: false }) styles: Record = {}; @property({ attribute: false }) theme: DesignCoreTheme = {}; @query('.trigger') private _trigger!: HTMLButtonElement; @@ -199,7 +217,8 @@ export class GdSelect extends LitElement { } willUpdate(changed: PropertyValues) { - if (changed.has('value')) this._store.getState().syncExternalValue(this.value); + if (changed.has('multiple')) this._store.getState().setMultiple(this.multiple); + if (changed.has('value') || changed.has('multiple')) this._store.getState().syncExternalValue(this.value); if (changed.has('disabled')) this._store.getState().setDisabled(this.disabled); // Applied directly on the host (`:host`'s own inline style), not just the shadow-root // `.wrapper` — a percentage width on a shadow-DOM child only resolves against a @@ -212,6 +231,7 @@ export class GdSelect extends LitElement { if (changed.has('width')) this.style.width = this.width; if (changed.has('maxWidth')) this.style.maxWidth = this.maxWidth; if (changed.has('minWidth')) this.style.minWidth = this.minWidth ?? ''; + if (changed.has('styles')) Object.assign(this.style, this.styles); } } @@ -241,20 +261,43 @@ export class GdSelect extends LitElement { } private _toggleOpen() { - if (this.disabled) return; + if (this.disabled || !this.autoOpen) return; this._store.getState().toggle(); } + open() { + this._store.getState().open(); + } + + close() { + this._store.getState().close(); + } + private _select(option: SelectOption) { - const nextValue = this._store.getState().select(option); - this.value = Array.isArray(nextValue) ? null : nextValue; + const nextValue = this._store.getState().select(option, this.itemIdentifier); + this.value = nextValue; this.dispatchEvent(new CustomEvent('gd-change', { detail: { value: this.value }, bubbles: true, composed: true })); } + private _onSearch(event: Event) { + this._store.getState().setSearchText((event.target as HTMLInputElement).value); + } + + private _onTriggerKeyDown(event: KeyboardEvent) { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.open(); + } + if (event.key === 'Escape') this.close(); + } + render() { const state = this._store.getState(); const resolved = resolveSelectTokens(this.theme, this.color); const selected = Array.isArray(state.internalValue) ? null : state.internalValue; + const selectedItems = Array.isArray(state.internalValue) ? state.internalValue : selected ? [selected] : []; + const visibleItems = filterSelectOptions(this.items, state.searchText, this.itemStringifier) ?? []; + const selectedText = selectedItems.map(this.itemStringifier).join(', '); // Same expression gd-input.ts uses for its own focus ring — the real button.default // `'&:focus-visible'` color path (`colors.border.focus`). const focusColor = (this.theme.colors as { border?: { focus?: string } } | undefined)?.border?.focus ?? '#0069B4'; @@ -286,6 +329,7 @@ export class GdSelect extends LitElement { // field reused for both, since they're the same token value in the real component too. margin: `${resolved.dropdownPadding}`, padding: `${resolved.dropdownPadding}`, + ...(this.dropdownMaxHeight ? { maxHeight: this.dropdownMaxHeight } : {}), '--gd-select-hover-bg': resolved.hoverBackgroundColor, }; @@ -299,8 +343,13 @@ export class GdSelect extends LitElement { aria-haspopup="listbox" aria-expanded=${state.isOpen} @click=${this._toggleOpen} + @keydown=${this._onTriggerKeyDown} > - ${selected?.name ?? html``} + + + ${selectedText || this.placeholder || html``} + +
diff --git a/libs/web-components/src/components/atoms/gd-typography/gd-typography.spec.ts b/libs/web-components/src/components/atoms/gd-typography/gd-typography.spec.ts new file mode 100644 index 0000000..01a09b8 --- /dev/null +++ b/libs/web-components/src/components/atoms/gd-typography/gd-typography.spec.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { defaultTheme } from 'gd-design-library/tokens'; +import './gd-typography'; +import type { GdTypography } from './gd-typography'; + +describe('gd-typography React parity', () => { + let host: HTMLElement; + beforeEach(() => { + host = document.createElement('div'); + document.body.replaceChildren(host); + }); + async function mount(props: Partial) { + const el = document.createElement('gd-typography'); + Object.assign(el, { theme: defaultTheme }, props); + el.textContent = 'Text'; + host.append(el); + await el.updateComplete; + return el; + } + it.each([ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'small', + 'div', + 'span', + 'strong', + 'i', + 'code', + 'kbd', + 'caption', + 'header', + 'sup', + 'sub', + ] as const)('supports the React %s variant', async (variant) => { + const el = await mount({ variant }); + expect(el.shadowRoot!.firstElementChild).toBeTruthy(); + }); + it('applies display size, alignment, token color, style variants, and custom styles', async () => { + const el = await mount({ + variant: 'div', + size: 'xl', + align: 'center', + color: 'text.default', + styleVariant: ['bold', 'italic'], + styles: { letterSpacing: '2px' }, + }); + const inner = el.shadowRoot!.querySelector('div')!; + expect(inner.style.fontSize).toBeTruthy(); + expect(inner.style.textAlign).toBe('center'); + expect(inner.style.color).toBeTruthy(); + expect(inner.style.fontWeight).toBe('700'); + expect(inner.style.fontStyle).toBe('italic'); + expect(inner.style.letterSpacing).toBe('2px'); + }); + it('separates visual variant from semantic tag', async () => { + const el = await mount({ variant: 'h2', as: 'h3' }); + expect(el.shadowRoot!.querySelector('h3')).toBeTruthy(); + }); +}); diff --git a/libs/web-components/src/components/atoms/gd-typography/gd-typography.ts b/libs/web-components/src/components/atoms/gd-typography/gd-typography.ts index 8106d19..9d4f2d7 100644 --- a/libs/web-components/src/components/atoms/gd-typography/gd-typography.ts +++ b/libs/web-components/src/components/atoms/gd-typography/gd-typography.ts @@ -1,4 +1,4 @@ -import { LitElement, css, type PropertyValues } from 'lit'; +import { LitElement, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { literal, html as staticHtml, type StaticValue } from 'lit/static-html.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -6,7 +6,7 @@ import { typography } from 'gd-design-library/tokens'; import { resolveThemeTree, get, - type TypographyVariantName, + type TypographyVariantName as CoreTypographyVariantName, type TypographyStyleVariantName, type DesignCoreTheme, } from 'gd-design-core'; @@ -25,6 +25,22 @@ interface ResolvedTypographyStyle { marginBottom?: string; } +type TypographySize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; +export type TypographyVariantName = CoreTypographyVariantName | 'div' | 'strong' | 'i' | 'sup' | 'sub'; +type TextAlign = + | 'start' + | 'end' + | 'center' + | 'left' + | 'right' + | 'justify' + | 'match-parent' + | 'inherit' + | 'initial' + | 'revert' + | 'revert-layer' + | 'unset'; + /** * Resolves the REAL `typography` object (`gd-design-library/tokens`) against `theme` — the * single-source-of-truth replacement for the old, hand-mirrored `resolveTypographyStyle` in @@ -36,10 +52,15 @@ interface ResolvedTypographyStyle { function resolveTypographyTokens( theme: DesignCoreTheme, variant: TypographyVariantName, + size: TypographySize, styleVariant?: TypographyStyleVariantName | TypographyStyleVariantName[] ): ResolvedTypographyStyle { const resolved = resolveThemeTree(typography, theme) as unknown as Record>; - const style: Record = { fontFamily: resolved.base.fontFamily, ...resolved[variant] }; + const variantStyles = resolved[variant] ?? resolved.span; + const sizeStyles = variant === 'div' ? ((variantStyles[size] as Record | undefined) ?? {}) : {}; + const style: Record = { fontFamily: resolved.base.fontFamily, ...variantStyles, ...sizeStyles }; + // `div` is a map of size blocks. Do not leak those nested objects into CSS. + for (const key of ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']) delete style[key]; if (MONOSPACE_VARIANTS.has(variant)) { // Real token key is the flat property `'family.code'` under `font` (a literal dot in the @@ -81,9 +102,13 @@ export class GdTypography extends LitElement { } `; - @property({ type: String }) variant: TypographyVariantName = 'span'; - @property({ type: String }) as: keyof HTMLElementTagNameMap = 'span'; + @property({ type: String }) variant: TypographyVariantName = 'p'; + @property({ type: String }) as?: keyof HTMLElementTagNameMap; + @property({ type: String }) size: TypographySize = 'md'; + @property({ type: String }) align: TextAlign = 'start'; + @property({ type: String }) color?: string; @property({ attribute: 'style-variant' }) styleVariant?: TypographyStyleVariantName | TypographyStyleVariantName[]; + @property({ attribute: false }) styles: Record = {}; @property({ attribute: false }) theme: DesignCoreTheme = {}; private static readonly TAG_MAP: Partial> = { @@ -101,21 +126,16 @@ export class GdTypography extends LitElement { small: literal`small`, code: literal`code`, kbd: literal`kbd`, + strong: literal`strong`, + i: literal`i`, + sup: literal`sup`, + sub: literal`sub`, }; - willUpdate(changed: PropertyValues) { - if (!changed.has('as') && changed.has('variant') && !this.hasAttribute('as')) { - // Mirrors the React original's `$as || $variant || 'span'` fallback: when no - // explicit `as` is set, the DOM tag tracks `variant` for the variants that map - // to a real HTML tag (h1-h6, p); everything else stays `span`. - const variantTag = (GdTypography.TAG_MAP as Record)[this.variant] ? this.variant : 'span'; - this.as = variantTag as keyof HTMLElementTagNameMap; - } - } - render() { - const resolved = resolveTypographyTokens(this.theme, this.variant, this.styleVariant); - const tag = GdTypography.TAG_MAP[this.as] ?? literal`span`; + const resolved = resolveTypographyTokens(this.theme, this.variant, this.size, this.styleVariant); + const semanticTag = this.as ?? (this.variant as keyof HTMLElementTagNameMap); + const tag = GdTypography.TAG_MAP[semanticTag] ?? literal`span`; const style = { fontFamily: `${resolved.fontFamily}`, @@ -127,6 +147,16 @@ export class GdTypography extends LitElement { ...(resolved.textDecoration ? { textDecoration: resolved.textDecoration } : {}), ...(resolved.marginTop !== undefined ? { marginTop: resolved.marginTop } : {}), ...(resolved.marginBottom !== undefined ? { marginBottom: resolved.marginBottom } : {}), + textAlign: this.align, + ...(this.color + ? { + color: + (get(this.theme.colors ?? {}, this.color, undefined) as string | undefined) ?? + (get(this.theme, this.color, undefined) as string | undefined) ?? + this.color, + } + : {}), + ...this.styles, }; return staticHtml`<${tag} style=${styleMap(style)}>`; diff --git a/libs/web-components/src/components/molecules/gd-counter/gd-counter.ts b/libs/web-components/src/components/molecules/gd-counter/gd-counter.ts index 4bb0a5f..0e43315 100644 --- a/libs/web-components/src/components/molecules/gd-counter/gd-counter.ts +++ b/libs/web-components/src/components/molecules/gd-counter/gd-counter.ts @@ -21,6 +21,7 @@ export class GdCounter extends LitElement { @property({ type: Number }) initial = 1; @property({ type: Boolean, attribute: 'is-disabled', reflect: true }) isDisabled = false; @property({ attribute: false }) theme: DesignCoreTheme = {}; + @property({ attribute: false }) styles: Styles = {}; private value = 1; private draft = '1'; private initialized = false; @@ -65,7 +66,7 @@ export class GdCounter extends LitElement { const inputStyles = Object.fromEntries( Object.entries(t.webComponent.inputControl).filter(([, value]) => typeof value !== 'object') ) as Styles; - return html`
+ return html`
{ expect(content.style.width).toBe('fit-content'); expect(content.style.height).toBe('fit-content'); expect(content.style.display).toBe('inline-block'); + expect(getComputedStyle(content).display).toBe('none'); + }); + + it('is visually hidden again after the native popover closes', async () => { + const menu = mount(); + await settle(menu); + menu.openMenu(); + await settle(menu); + const content = menu.shadowRoot!.querySelector('[part="content"]')!; + expect(content.matches(':popover-open')).toBe(true); + expect(getComputedStyle(content).display).not.toBe('none'); + + menu.closeMenu(); + await settle(menu); + expect(content.matches(':popover-open')).toBe(false); + expect(getComputedStyle(content).display).toBe('none'); }); it('keeps the opened popover intrinsically sized and positioned beside its trigger with a slotted wrapper', async () => { diff --git a/libs/web-components/src/components/molecules/gd-menu/gd-menu.ts b/libs/web-components/src/components/molecules/gd-menu/gd-menu.ts index 57caf2b..e294953 100644 --- a/libs/web-components/src/components/molecules/gd-menu/gd-menu.ts +++ b/libs/web-components/src/components/molecules/gd-menu/gd-menu.ts @@ -1,4 +1,4 @@ -import { LitElement, html, type PropertyValues } from 'lit'; +import { LitElement, css, html, type PropertyValues } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { menu } from 'gd-design-library/tokens'; @@ -43,6 +43,21 @@ function resolveMenuTokens(theme: DesignCoreTheme): { */ @customElement('gd-menu') export class GdMenu extends LitElement { + static styles = css` + /* The menu token intentionally gives the open surface an inline-block display. + Because it is applied as an inline style, it otherwise overrides Chromium's + user-agent closed-popover display rule and leaves a + closed menu painted in normal flow. Keep the token for intrinsic open sizing, + but make the native popover state authoritative for visibility. */ + .content:not(:popover-open) { + display: none !important; + } + `; + + @property({ attribute: false }) itemIdentifier?: ( + selected: { value?: unknown } | null, + current: { value?: unknown } + ) => boolean; @property({ type: Boolean, reflect: true }) open = false; @property({ type: Boolean, attribute: 'close-on-select' }) closeOnSelect = true; @property({ type: Number, attribute: 'min-height' }) minHeight = 80; @@ -52,6 +67,7 @@ export class GdMenu extends LitElement { @property({ type: String }) placement: MenuPlacement = 'bottom-right'; @property({ attribute: false }) selectedValue: unknown = undefined; @property({ attribute: false }) theme: DesignCoreTheme = {}; + @property({ attribute: false }) styles: Record = {}; @query('.trigger') private _trigger!: HTMLButtonElement; @query('.content') private _content!: HTMLElement; @@ -78,7 +94,7 @@ export class GdMenu extends LitElement { updated(changed: PropertyValues) { const tokens = resolveMenuTokens(this.theme); - this.style.display = tokens.webComponent.host.display; + Object.assign(this.style, { display: tokens.webComponent.host.display, ...this.styles }); if (!changed.has('open')) return; if (this.open) this._show(); else this._hide(); diff --git a/libs/web-components/stories/Avatar.stories.ts b/libs/web-components/stories/Avatar.stories.ts new file mode 100644 index 0000000..f8189d5 --- /dev/null +++ b/libs/web-components/stories/Avatar.stories.ts @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdAvatar } from '../src'; +import { element, portrait } from './helpers'; + +type Args = Pick; +const meta = { + title: 'Atoms/Avatar', + tags: ['autodocs'], + args: { + src: '', + alt: 'Ada Lovelace', + fallback: 'AL', + size: 'md', + withBadge: false, + badgeColor: 'bg.fill.success.primary.default', + backgroundColor: undefined, + }, + argTypes: { + size: { control: 'select', options: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] }, + badgeColor: { control: 'color' }, + backgroundColor: { control: 'color' }, + }, + render: (args: Args) => element('gd-avatar', args), +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Image: Story = { args: { src: portrait } }; +export const Badge: Story = { args: { withBadge: true, size: 'xl' } }; +export const Small: Story = { args: { size: 'sm' } }; +export const Large: Story = { args: { size: 'lg' } }; +export const AllSizes: Story = { + render: (args) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:16px;align-items:center'; + for (const size of ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const) + row.append(element('gd-avatar', { ...args, size, alt: `${size} avatar` })); + return row; + }, +}; +export const CustomColors: Story = { args: { withBadge: true, backgroundColor: '#6b46c1', badgeColor: '#22c55e' } }; +export const SlottedFallback: Story = { + render: () => { + const avatar = element('gd-avatar', { alt: 'Custom fallback', size: 'sm' }); + const fallback = document.createElement('span'); + fallback.slot = 'fallback'; + fallback.textContent = '★'; + avatar.append(fallback); + return avatar; + }, +}; diff --git a/libs/web-components/stories/Button.stories.ts b/libs/web-components/stories/Button.stories.ts new file mode 100644 index 0000000..338ea5b --- /dev/null +++ b/libs/web-components/stories/Button.stories.ts @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdButton } from '../src'; +import { element } from './helpers'; + +type Args = Pick< + GdButton, + | 'variant' + | 'rounded' + | 'disabled' + | 'isLoading' + | 'isIcon' + | 'fullWidth' + | 'type' + | 'role' + | 'tabIndex' + | 'ariaLabel' + | 'ariaPressed' +> & { label: string }; +const variants = ['primary', 'secondary', 'tertiary', 'outlined', 'text', 'inherit'] as const; +const roundedOptions = ['none', 'default', 'round', 'xs', 'sm', 'md', 'lg', 'xl'] as const; +const meta = { + title: 'Atoms/Button', + tags: ['autodocs'], + args: { + variant: 'primary', + rounded: 'none', + disabled: false, + isLoading: false, + isIcon: false, + fullWidth: false, + type: 'button', + role: 'button', + tabIndex: 0, + ariaLabel: null, + ariaPressed: null, + label: 'Button', + }, + argTypes: { + variant: { control: 'select', options: variants }, + rounded: { control: 'select', options: roundedOptions }, + }, + render: ({ label, ...props }: Args) => element('gd-button', props, label), +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Secondary: Story = { args: { variant: 'secondary' } }; +export const Outlined: Story = { args: { variant: 'outlined' } }; +export const Disabled: Story = { args: { disabled: true } }; +export const Loading: Story = { args: { isLoading: true } }; +export const AllVariants: Story = { + render: (args) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap'; + for (const variant of variants) row.append(element('gd-button', { ...args, variant }, variant)); + return row; + }, +}; +export const Rounded: Story = { + render: (args) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap'; + for (const rounded of roundedOptions) row.append(element('gd-button', { ...args, rounded }, rounded)); + return row; + }, +}; +export const FullWidth: Story = { args: { fullWidth: true, label: 'Full-width action' } }; +export const IconOnly: Story = { args: { isIcon: true, ariaLabel: 'Add item', label: '+' } }; +export const IconSlots: Story = { + render: (args) => { + const button = element('gd-button', args, 'Continue'); + const icon = document.createElement('span'); + icon.slot = 'icon-end'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '→'; + button.append(icon); + return button; + }, +}; diff --git a/libs/web-components/stories/Checkbox.stories.ts b/libs/web-components/stories/Checkbox.stories.ts new file mode 100644 index 0000000..c9a962e --- /dev/null +++ b/libs/web-components/stories/Checkbox.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdCheckbox } from '../src'; +import { element, observed } from './helpers'; + +type Args = Pick; +const meta = { + title: 'Atoms/Checkbox', + tags: ['autodocs'], + args: { checked: false, disabled: false, indeterminate: false, size: 'md', name: '', value: 'on', required: false }, + argTypes: { size: { control: 'select', options: ['sm', 'md'] } }, + render: (args: Args) => { + const checkbox = element('gd-checkbox', args, 'Accept terms'); + checkbox.addEventListener('gd-change', (event) => { + checkbox.checked = (event as CustomEvent<{ checked: boolean }>).detail.checked; + }); + return observed(checkbox, 'gd-change', { checked: args.checked }); + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Checked: Story = { args: { checked: true } }; +export const Indeterminate: Story = { args: { indeterminate: true } }; +export const Disabled: Story = { args: { checked: true, disabled: true } }; +export const Sizes: Story = { + render: (args) => { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:20px;align-items:center'; + row.append(element('gd-checkbox', { ...args, size: 'sm' }, 'Small')); + row.append(element('gd-checkbox', { ...args, size: 'md' }, 'Medium')); + return row; + }, +}; diff --git a/libs/web-components/stories/Counter.stories.ts b/libs/web-components/stories/Counter.stories.ts new file mode 100644 index 0000000..aa6c17b --- /dev/null +++ b/libs/web-components/stories/Counter.stories.ts @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdCounter } from '../src'; +import { element, observed } from './helpers'; + +type Args = Pick; +const meta = { + title: 'Molecules/Counter', + tags: ['autodocs'], + args: { min: 1, max: 5, initial: 1, isDisabled: false }, + render: (args: Args) => observed(element('gd-counter', args), 'gd-change', { value: args.initial }), +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const AtMaximum: Story = { args: { initial: 5 } }; +export const Disabled: Story = { args: { isDisabled: true } }; +export const AdjustedMaximum: Story = { args: { max: 10, initial: 8 } }; +export const AdjustedMinimum: Story = { args: { min: 3, initial: 3 } }; +export const CustomRange: Story = { args: { min: 2, max: 10, initial: 5 } }; diff --git a/libs/web-components/stories/Input.stories.ts b/libs/web-components/stories/Input.stories.ts new file mode 100644 index 0000000..3ee0c6f --- /dev/null +++ b/libs/web-components/stories/Input.stories.ts @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdInput } from '../src'; +import { element, observed } from './helpers'; + +type Args = Pick< + GdInput, + | 'value' + | 'variant' + | 'label' + | 'helperText' + | 'placeholder' + | 'disabled' + | 'readOnly' + | 'required' + | 'color' + | 'width' + | 'inputmode' + | 'role' + | 'tabIndex' + | 'debounceCallbackTime' +>; +const inputVariants = [ + 'text', + 'password', + 'email', + 'search', + 'url', + 'tel', + 'date', + 'time', + 'month', + 'week', + 'color', + 'range', + 'number', + 'radio', + 'checkbox', +]; +const colors = ['primary', 'success', 'warning', 'error'] as const; +const meta = { + title: 'Atoms/Input', + tags: ['autodocs'], + args: { + value: 'Default Input', + variant: 'text', + label: 'Label', + helperText: 'Helper text', + placeholder: 'Enter text', + disabled: false, + readOnly: false, + required: false, + color: 'primary', + width: '320px', + inputmode: undefined, + role: null, + tabIndex: 0, + debounceCallbackTime: undefined, + }, + argTypes: { variant: { control: 'select', options: inputVariants }, color: { control: 'select', options: colors } }, + render: (args: Args) => { + const input = element('gd-input', args); + input.addEventListener('gd-input', (event) => { + input.value = (event as CustomEvent<{ value: string }>).detail.value; + }); + return observed(input, 'gd-input', { value: args.value }); + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Empty: Story = { args: { value: '' } }; +export const Disabled: Story = { args: { disabled: true } }; +export const ReadOnly: Story = { args: { readOnly: true } }; +export const ValidationColors: Story = { + render: (args) => { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:20px;max-width:360px'; + for (const color of colors) + section.append(element('gd-input', { ...args, color, label: color, helperText: `${color} helper text` })); + return section; + }, +}; +export const WithAdornments: Story = { + render: (args) => { + const input = element('gd-input', args); + const start = document.createElement('span'); + start.slot = 'adornment-start'; + start.textContent = '🔎'; + const end = document.createElement('span'); + end.slot = 'adornment-end'; + end.textContent = '⌘K'; + input.append(start, end); + return input; + }, +}; +export const InputTypes: Story = { + render: (args) => { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:16px;max-width:360px'; + for (const variant of ['text', 'email', 'password', 'search', 'number', 'date']) + section.append(element('gd-input', { ...args, variant, value: '', label: variant })); + return section; + }, +}; diff --git a/libs/web-components/stories/Menu.stories.ts b/libs/web-components/stories/Menu.stories.ts new file mode 100644 index 0000000..a5fda66 --- /dev/null +++ b/libs/web-components/stories/Menu.stories.ts @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdMenu } from '../src'; +import { element, observed } from './helpers'; + +type Args = Pick; +const placements = ['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const; +const meta = { + title: 'Molecules/Menu', + tags: ['autodocs'], + args: { + open: false, + closeOnSelect: true, + placement: 'bottom-right', + offsetX: 4, + offsetY: 4, + minHeight: 80, + maxHeight: 400, + }, + argTypes: { placement: { control: 'select', options: placements } }, + render: (args: Args) => { + const menu = element('gd-menu', args); + const trigger = document.createElement('span'); + trigger.slot = 'trigger'; + trigger.textContent = 'Actions'; + const content = document.createElement('div'); + content.slot = 'content'; + content.style.cssText = 'display:grid;gap:8px;padding:12px'; + for (const name of ['Edit', 'Archive']) { + const button = document.createElement('button'); + button.textContent = name; + button.dataset.gdMenuName = name; + button.dataset.gdMenuValue = name.toLowerCase(); + content.append(button); + } + menu.append(trigger, content); + const section = observed(menu, 'gd-change', null); + const outside = document.createElement('button'); + outside.textContent = 'Outside menu'; + section.append(outside); + return section; + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Open: Story = { args: { open: true } }; +export const KeepOpenOnSelect: Story = { args: { closeOnSelect: false } }; +export const Placement: Story = { + render: (args) => { + const grid = document.createElement('div'); + grid.style.cssText = 'display:grid;grid-template-columns:repeat(2,180px);gap:120px;padding:120px'; + for (const placement of placements) { + const menu = element('gd-menu', { ...args, placement }); + const trigger = document.createElement('span'); + trigger.slot = 'trigger'; + trigger.textContent = placement; + const content = document.createElement('div'); + content.slot = 'content'; + content.style.padding = '12px'; + content.textContent = `${placement} content`; + menu.append(trigger, content); + grid.append(menu); + } + return grid; + }, +}; +export const WithOffset: Story = { args: { offsetX: 20, offsetY: 16 } }; +export const WithHeightConstraints: Story = { args: { minHeight: 120, maxHeight: 160 } }; diff --git a/libs/web-components/stories/Overview.stories.ts b/libs/web-components/stories/Overview.stories.ts index df96d5d..5d332d1 100644 --- a/libs/web-components/stories/Overview.stories.ts +++ b/libs/web-components/stories/Overview.stories.ts @@ -9,13 +9,15 @@ const meta = { page.innerHTML = `

GridKit Web Components

This native custom-element catalog shares one Storybook with the React library.

-

Component stories will be added in Phase 2 for the existing supported elements only:

+

Browse the existing supported elements under Atoms and Molecules:

  • Atoms: gd-avatar, gd-button, gd-checkbox, gd-input, gd-select, gd-typography
  • Molecules: gd-counter, gd-menu
-

Stories will consume custom elements directly, without React adapters, using the production theme and styles.

-

The Angular and Vue integration checks remain in place during this scaffold phase.

+

Stories consume custom elements directly, without React adapters, using the production defaultTheme property and built stylesheet/fonts.

+

Use Controls to explore properties. Interactive stories display native gd-input / gd-change event details below the element. Objects such as theme and items are JavaScript properties, not string attributes.

+

Menu demonstrates trigger/content slots, selection, Escape and outside-click dismissal. Counter demonstrates bounded quantity changes. Other components are intentionally postponed.

+

The Angular and Vue integration checks remain in place; harness cleanup is a later phase.

`; return page; }, diff --git a/libs/web-components/stories/Select.stories.ts b/libs/web-components/stories/Select.stories.ts new file mode 100644 index 0000000..16b19b7 --- /dev/null +++ b/libs/web-components/stories/Select.stories.ts @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdSelect } from '../src'; +import { element, observed, items } from './helpers'; + +type Args = Pick< + GdSelect, + | 'items' + | 'value' + | 'disabled' + | 'color' + | 'placeholder' + | 'multiple' + | 'searchable' + | 'searchPlaceholder' + | 'autoOpen' + | 'dropdownMaxHeight' + | 'width' + | 'minWidth' + | 'maxWidth' +>; +const colors = ['primary', 'success', 'warning', 'error'] as const; +const meta = { + title: 'Atoms/Select', + tags: ['autodocs'], + args: { + items, + value: null, + disabled: false, + color: 'primary', + placeholder: 'Choose an option', + multiple: false, + searchable: false, + searchPlaceholder: 'Search options', + autoOpen: true, + dropdownMaxHeight: '240px', + width: '280px', + minWidth: undefined, + maxWidth: 'initial', + }, + argTypes: { color: { control: 'select', options: colors } }, + render: (args: Args) => { + const select = element('gd-select', args); + select.addEventListener('gd-change', (event) => { + select.value = (event as CustomEvent<{ value: GdSelect['value'] }>).detail.value; + }); + return observed(select, 'gd-change', { value: args.value }); + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Selected: Story = { args: { value: items[1] } }; +export const Disabled: Story = { args: { disabled: true, value: items[0] } }; +export const Empty: Story = { args: { items: [] } }; +export const Multiple: Story = { args: { multiple: true, value: [items[0], items[2]] } }; +export const Searchable: Story = { args: { searchable: true } }; +export const AutoOpen: Story = { args: { autoOpen: false }, name: 'Manual open (autoOpen=false)' }; +export const ValidationColors: Story = { + render: (args) => { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:16px;width:280px'; + for (const color of colors) section.append(element('gd-select', { ...args, color, value: items[0] })); + return section; + }, +}; +export const WithAdornments: Story = { + render: (args) => { + const select = element('gd-select', args); + const start = document.createElement('span'); + start.slot = 'adornment-start'; + start.textContent = '★'; + const end = document.createElement('span'); + end.slot = 'adornment-end'; + end.textContent = 'Required'; + select.append(start, end); + return select; + }, +}; +export const CustomInitiator: Story = { + render: (args) => { + const select = element('gd-select', args); + const initiator = document.createElement('span'); + initiator.slot = 'initiator'; + initiator.textContent = 'Custom trigger content'; + select.append(initiator); + return select; + }, +}; diff --git a/libs/web-components/stories/Typography.stories.ts b/libs/web-components/stories/Typography.stories.ts new file mode 100644 index 0000000..0ab4d67 --- /dev/null +++ b/libs/web-components/stories/Typography.stories.ts @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import type { GdTypography } from '../src'; +import { element } from './helpers'; + +type Args = Pick & { text: string }; +const variants = [ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'small', + 'div', + 'span', + 'strong', + 'i', + 'code', + 'kbd', + 'caption', + 'header', + 'sup', + 'sub', +] as const; +const styleVariants = [ + 'light', + 'normal', + 'semibold', + 'bold', + 'italic', + 'small', + 'uppercase', + 'lowercase', + 'underline', + 'strike', +] as const; +const meta = { + title: 'Atoms/Typography', + tags: ['autodocs'], + args: { + variant: 'p', + as: 'p', + size: 'md', + align: 'start', + color: '', + styleVariant: [], + text: 'Body paragraph text for comparison.', + }, + argTypes: { + variant: { control: 'select', options: variants }, + as: { control: 'select', options: variants }, + size: { control: 'select', options: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] }, + align: { control: 'select', options: ['start', 'end', 'center', 'left', 'right', 'justify'] }, + color: { control: 'color' }, + styleVariant: { control: 'multi-select', options: styleVariants }, + }, + render: ({ text, ...props }: Args) => element('gd-typography', props, text), +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; +export const Heading1: Story = { args: { variant: 'h1', as: 'h1', text: 'Heading 1' } }; +export const Heading2: Story = { args: { variant: 'h2', as: 'h2', text: 'Heading 2' } }; +export const AllVariants: Story = { + render: (args) => { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:8px'; + for (const variant of variants) + section.append(element('gd-typography', { ...args, variant, as: variant }, `${variant} — GridKit typography`)); + return section; + }, +}; +export const DisplaySizes: Story = { + render: (args) => { + const section = document.createElement('section'); + for (const size of ['xl', 'lg', 'md', 'sm', 'xs'] as const) + section.append(element('gd-typography', { ...args, variant: 'div', as: 'div', size }, `Display ${size}`)); + return section; + }, +}; +export const StyleVariants: Story = { + render: (args) => { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:8px'; + for (const styleVariant of styleVariants) + section.append( + element('gd-typography', { ...args, variant: 'p', as: 'p', styleVariant }, `${styleVariant} text`) + ); + section.append( + element( + 'gd-typography', + { ...args, variant: 'p', as: 'p', styleVariant: ['bold', 'italic', 'underline'] }, + 'Combined: bold + italic + underline' + ) + ); + return section; + }, +}; +export const SemanticOverride: Story = { + args: { variant: 'h2', as: 'h3', text: 'H2 appearance rendered as an H3' }, +}; +export const CodeAndKeyboard: Story = { + render: (args) => { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:12px'; + section.append(element('gd-typography', { ...args, variant: 'code', as: 'code' }, 'const gridkit = true;')); + section.append(element('gd-typography', { ...args, variant: 'kbd', as: 'kbd' }, '⌘ + K')); + return section; + }, +}; +export const CaptionAndHeader: Story = { + render: (args) => { + const section = document.createElement('section'); + section.append(element('gd-typography', { ...args, variant: 'caption', as: 'span' }, 'Caption text')); + section.append(element('gd-typography', { ...args, variant: 'header', as: 'header' }, 'Header text')); + return section; + }, +}; diff --git a/libs/web-components/stories/helpers.ts b/libs/web-components/stories/helpers.ts new file mode 100644 index 0000000..6c53b07 --- /dev/null +++ b/libs/web-components/stories/helpers.ts @@ -0,0 +1,147 @@ +import { defaultTheme } from 'gd-design-library/tokens'; +import type { DesignCoreTheme } from 'gd-design-core'; +import type {} from '../src/index'; + +type Tag = Extract; + +const sourceKeyProperties: Partial> = { + 'gd-avatar': ['sizeVariant'], + 'gd-button': ['variant', 'rounded'], + 'gd-checkbox': ['size'], + 'gd-input': ['variant', 'color'], + 'gd-select': ['color'], + 'gd-typography': ['variant', 'as', 'size', 'align'], + 'gd-counter': ['min', 'max', 'initial'], + 'gd-menu': ['placement', 'closeOnSelect'], +}; + +const sourceEvents: Partial> = { + 'gd-checkbox': ['gd-change'], + 'gd-input': ['gd-input', 'gd-change'], + 'gd-select': ['gd-change'], + 'gd-counter': ['gd-change'], + 'gd-menu': ['gd-change'], +}; + +const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + +const kebab = (value: string) => value.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`); + +function equivalent(left: unknown, right: unknown) { + if (Object.is(left, right)) return true; + if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) { + try { + return JSON.stringify(left) === JSON.stringify(right); + } catch { + return false; + } + } + return false; +} + +function lightDom(node: Element) { + return Array.from(node.childNodes) + .map((child) => { + if (child.nodeType === Node.TEXT_NODE) return escapeHtml(child.textContent ?? ''); + return child instanceof Element ? child.outerHTML : ''; + }) + .join('') + .trim(); +} + +/** Produce consumer-ready source from the custom elements rendered by a story. + * Storybook cannot infer useful source from our imperative DOM render functions. */ +export function storySource(canvas: HTMLElement | undefined, fallback: string) { + if (!canvas) return fallback; + const nodes = Array.from(canvas.querySelectorAll('*')).filter((node) => + node.localName.startsWith('gd-') + ); + if (!nodes.length) return fallback; + + const markup: string[] = []; + const setup: string[] = ["import 'web-components';", "import { defaultTheme } from 'gd-design-library/tokens';", '']; + nodes.forEach((node, index) => { + const tag = node.localName as Tag; + const baseline = document.createElement(tag) as unknown as HTMLElement & Record; + const current = node as unknown as HTMLElement & Record; + const constructor = customElements.get(tag) as + | (CustomElementConstructor & { + elementProperties?: Map; + }) + | undefined; + const attributes = new Map(Array.from(node.attributes).map(({ name, value }) => [name, value])); + const assignments: Array<[string, unknown]> = []; + for (const [property, options] of constructor?.elementProperties ?? []) { + if (typeof property !== 'string' || property === 'theme' || options.state || property.startsWith('_')) continue; + const value = current[property]; + const requiredForUnderstanding = sourceKeyProperties[tag]?.includes(property) ?? false; + if (!requiredForUnderstanding && equivalent(value, baseline[property])) continue; + const attribute = + options.attribute === false + ? false + : typeof options.attribute === 'string' + ? options.attribute + : kebab(property); + if (attribute && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')) { + if (typeof value === 'boolean') { + if (value) attributes.set(attribute, ''); + else if (!equivalent(value, baseline[property])) assignments.push([property, value]); + } else attributes.set(attribute, String(value)); + } else if (typeof value !== 'function' && value !== undefined) assignments.push([property, value]); + } + const attributeText = [...attributes] + .map(([name, value]) => (value === '' ? name : `${name}="${escapeHtml(value)}"`)) + .join(' '); + const content = lightDom(node); + markup.push(`<${tag}${attributeText ? ` ${attributeText}` : ''}>${content}`); + + const variable = nodes.length === 1 ? 'component' : `component${index + 1}`; + const sameTagIndex = nodes.slice(0, index).filter((candidate) => candidate.localName === tag).length; + setup.push( + nodes.length === 1 + ? `const ${variable} = document.querySelector('${tag}');` + : `const ${variable} = document.querySelectorAll('${tag}')[${sameTagIndex}];` + ); + setup.push(`${variable}.theme = defaultTheme;`); + for (const [property, value] of assignments) + setup.push(`${variable}.${property} = ${JSON.stringify(value, null, 2)};`); + for (const eventName of sourceEvents[tag] ?? []) { + setup.push(`${variable}.addEventListener('${eventName}', (event) => {`, ' console.log(event.detail);', '});'); + } + setup.push(''); + }); + + return `${markup.join('\n\n')}\n\n`; +} + +/** Use the same property-based theme contract as application consumers. */ +export function element(tag: T, props: Partial = {}, text = '') { + const node = document.createElement(tag); + Object.assign(node, { theme: defaultTheme as DesignCoreTheme }, props); + node.textContent = text; + return node; +} + +/** Each render owns its output and listeners; no state leaks between stories. */ +export function observed(node: HTMLElement, eventName: string, initial: unknown) { + const section = document.createElement('section'); + section.style.cssText = 'display:grid;gap:16px;max-width:480px;font-family:"Fira Sans",sans-serif'; + const output = document.createElement('output'); + output.setAttribute('aria-label', 'Last event'); + output.textContent = JSON.stringify(initial); + node.addEventListener(eventName, (event) => { + output.textContent = JSON.stringify((event as CustomEvent).detail); + }); + section.append(node, output); + return section; +} + +export const items = [ + { name: 'Alpha', value: 'a' }, + { name: 'Beta', value: 'b' }, + { name: 'Gamma', value: 'c' }, +]; + +export const portrait = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" fill="%2391b8d8"/%3E%3Ccircle cx="32" cy="24" r="13" fill="%23f1c7a5"/%3E%3Cpath d="M8 64c3-18 14-27 24-27s21 9 24 27" fill="%233e6184"/%3E%3C/svg%3E'; diff --git a/libs/web-components/test/story-source.spec.ts b/libs/web-components/test/story-source.spec.ts new file mode 100644 index 0000000..373598e --- /dev/null +++ b/libs/web-components/test/story-source.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import '../src/index'; +import { element, items, storySource } from '../stories/helpers'; + +function source(...nodes: HTMLElement[]) { + const canvas = document.createElement('main'); + canvas.append(...nodes); + return storySource(canvas, 'fallback'); +} + +describe('Storybook custom-element source', () => { + it('shows the effective properties for each Button state without Lit internals', () => { + const secondary = source(element('gd-button', { variant: 'secondary' }, 'Button')); + const disabled = source(element('gd-button', { variant: 'primary', disabled: true }, 'Button')); + + expect(secondary).toContain('Button'); + expect(disabled).toMatch(/]*variant="primary"[^>]*disabled[^>]*>Button<\/gd-button>/); + expect(secondary).toContain('component.theme = defaultTheme;'); + expect(secondary).toContain("import 'web-components';"); + expect(secondary).not.toContain('_hasContent'); + }); + + it('uses JavaScript properties and native events for non-attribute Select inputs', () => { + const select = element('gd-select', { items, value: items[1], multiple: true }); + const generated = source(select); + + expect(generated).toContain('component.items = ['); + expect(generated).toContain('component.value = {'); + expect(generated).toContain("component.addEventListener('gd-change'"); + }); + + it('keeps named slots in the consumer markup', () => { + const button = element('gd-button', { variant: 'outlined' }, 'Continue'); + const icon = document.createElement('span'); + icon.slot = 'icon-start'; + icon.textContent = '→'; + button.prepend(icon); + + expect(source(button)).toContain('→Continue'); + }); +}); From dd14f770548c9ce21c6e7450ff43115ecb0e9555 Mon Sep 17 00:00:00 2001 From: Zlodej43sm <8863227+Zlodej43sm@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:38:24 +0200 Subject: [PATCH 05/10] feat: CTORNDSD-679: Add Icon catalog to design-core and expand web components with 13 new atoms Integrate shared icon catalog from gd-design-core into React Icon component. Add 13 new web components: badge, box, icon, image, input-file, label, link, loader, separator, skeleton, slider, slider-dots, switch, textarea, toggle, truncate, and wrapper. Update Storybook stories with detailed descriptions and expand native component test coverage. Include migration manifest for loader, separator, and skeleton components. --- CLAUDE.md | 2 +- README.md | 7 +- bin/generate-icon-catalog.ts | 28 ++ bin/storybook/native-checks.mjs | 56 ++- libs/design-core/src/icons/catalog.ts | 226 ++++++++++ libs/design-core/src/icons/index.ts | 1 + libs/design-core/src/index.ts | 1 + libs/ui/package.json | 1 + libs/ui/src/components/atoms/Icon/Icon.tsx | 40 +- .../Icon/__snapshots__/Icon.test.tsx.snap | 6 +- libs/ui/src/tokens/index.ts | 3 + libs/ui/tsconfig.json | 2 + libs/ui/tsconfig.lib.json | 5 +- libs/ui/vitest.alias.ts | 1 + ...TION-MANIFEST.loader-separator-skeleton.md | 11 + .../MIGRATION-MANIFEST.slider-controls.md | 13 + ...FEST.toggle-truncate-typography-wrapper.md | 12 + libs/web-components/README.md | 48 +- .../harness/fidelity-check-vue.ts | 135 ++++++ libs/web-components/harness/fidelity-check.ts | 85 ++++ .../web-components/published-atoms-audit.json | 29 ++ libs/web-components/react-parity.json | 411 +++++++++++++++++- .../scripts/check-ported-elements.mjs | 87 +++- libs/web-components/scripts/ssr-dsd-render.ts | 64 ++- .../components/atoms/gd-avatar/gd-avatar.ts | 14 +- .../atoms/gd-badge/gd-badge.spec.ts | 24 + .../src/components/atoms/gd-badge/gd-badge.ts | 55 +++ .../components/atoms/gd-box/gd-box.spec.ts | 21 + .../src/components/atoms/gd-box/gd-box.ts | 50 +++ .../components/atoms/gd-icon/gd-icon.spec.ts | 16 + .../src/components/atoms/gd-icon/gd-icon.ts | 46 ++ .../atoms/gd-image/gd-image.spec.ts | 26 ++ .../src/components/atoms/gd-image/gd-image.ts | 84 ++++ .../atoms/gd-input-file/gd-input-file.spec.ts | 16 + .../atoms/gd-input-file/gd-input-file.ts | 64 +++ .../atoms/gd-label/gd-label.spec.ts | 18 + .../src/components/atoms/gd-label/gd-label.ts | 23 + .../components/atoms/gd-link/gd-link.spec.ts | 29 ++ .../src/components/atoms/gd-link/gd-link.ts | 66 +++ .../atoms/gd-loader/gd-loader.spec.ts | 20 + .../components/atoms/gd-loader/gd-loader.ts | 76 ++++ .../atoms/gd-separator/gd-separator.spec.ts | 41 ++ .../atoms/gd-separator/gd-separator.ts | 102 +++++ .../atoms/gd-skeleton/gd-skeleton.spec.ts | 40 ++ .../atoms/gd-skeleton/gd-skeleton.ts | 57 +++ .../gd-slider-dots/gd-slider-dots.spec.ts | 21 + .../atoms/gd-slider-dots/gd-slider-dots.ts | 57 +++ .../atoms/gd-slider/gd-slider.spec.ts | 22 + .../components/atoms/gd-slider/gd-slider.ts | 64 +++ .../atoms/gd-switch/gd-switch.spec.ts | 29 ++ .../components/atoms/gd-switch/gd-switch.ts | 74 ++++ .../atoms/gd-textarea/gd-textarea.spec.ts | 40 ++ .../atoms/gd-textarea/gd-textarea.ts | 108 +++++ .../atoms/gd-toggle/gd-toggle.spec.ts | 21 + .../components/atoms/gd-toggle/gd-toggle.ts | 64 +++ .../atoms/gd-truncate/gd-truncate.spec.ts | 16 + .../atoms/gd-truncate/gd-truncate.ts | 48 ++ .../atoms/gd-wrapper/gd-wrapper.spec.ts | 18 + .../components/atoms/gd-wrapper/gd-wrapper.ts | 34 ++ libs/web-components/src/index.ts | 17 + libs/web-components/stories/Avatar.stories.ts | 17 + libs/web-components/stories/Badge.stories.ts | 67 +++ libs/web-components/stories/Box.stories.ts | 68 +++ libs/web-components/stories/Button.stories.ts | 20 + .../stories/Checkbox.stories.ts | 10 + .../web-components/stories/Counter.stories.ts | 13 + libs/web-components/stories/Icon.stories.ts | 73 ++++ libs/web-components/stories/Image.stories.ts | 92 ++++ libs/web-components/stories/Input.stories.ts | 35 ++ .../stories/InputFile.stories.ts | 66 +++ libs/web-components/stories/Label.stories.ts | 57 +++ libs/web-components/stories/Link.stories.ts | 96 ++++ libs/web-components/stories/Loader.stories.ts | 80 ++++ libs/web-components/stories/Menu.stories.ts | 13 + .../stories/Overview.stories.ts | 8 +- libs/web-components/stories/Select.stories.ts | 26 ++ .../stories/Separator.stories.ts | 118 +++++ .../stories/Skeleton.stories.ts | 97 +++++ libs/web-components/stories/Slider.stories.ts | 58 +++ .../stories/SliderDots.stories.ts | 56 +++ libs/web-components/stories/Switch.stories.ts | 69 +++ .../stories/Textarea.stories.ts | 159 +++++++ libs/web-components/stories/Toggle.stories.ts | 62 +++ .../stories/Truncate.stories.ts | 72 +++ .../stories/Typography.stories.ts | 17 + .../web-components/stories/Wrapper.stories.ts | 79 ++++ libs/web-components/stories/helpers.ts | 28 +- libs/web-components/vitest.config.ts | 3 + package-lock.json | 1 + 89 files changed, 4289 insertions(+), 66 deletions(-) create mode 100644 bin/generate-icon-catalog.ts create mode 100644 libs/design-core/src/icons/catalog.ts create mode 100644 libs/design-core/src/icons/index.ts create mode 100644 libs/web-components/MIGRATION-MANIFEST.loader-separator-skeleton.md create mode 100644 libs/web-components/MIGRATION-MANIFEST.slider-controls.md create mode 100644 libs/web-components/MIGRATION-MANIFEST.toggle-truncate-typography-wrapper.md create mode 100644 libs/web-components/published-atoms-audit.json create mode 100644 libs/web-components/src/components/atoms/gd-badge/gd-badge.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-badge/gd-badge.ts create mode 100644 libs/web-components/src/components/atoms/gd-box/gd-box.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-box/gd-box.ts create mode 100644 libs/web-components/src/components/atoms/gd-icon/gd-icon.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-icon/gd-icon.ts create mode 100644 libs/web-components/src/components/atoms/gd-image/gd-image.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-image/gd-image.ts create mode 100644 libs/web-components/src/components/atoms/gd-input-file/gd-input-file.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-input-file/gd-input-file.ts create mode 100644 libs/web-components/src/components/atoms/gd-label/gd-label.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-label/gd-label.ts create mode 100644 libs/web-components/src/components/atoms/gd-link/gd-link.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-link/gd-link.ts create mode 100644 libs/web-components/src/components/atoms/gd-loader/gd-loader.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-loader/gd-loader.ts create mode 100644 libs/web-components/src/components/atoms/gd-separator/gd-separator.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-separator/gd-separator.ts create mode 100644 libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.ts create mode 100644 libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.ts create mode 100644 libs/web-components/src/components/atoms/gd-slider/gd-slider.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-slider/gd-slider.ts create mode 100644 libs/web-components/src/components/atoms/gd-switch/gd-switch.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-switch/gd-switch.ts create mode 100644 libs/web-components/src/components/atoms/gd-textarea/gd-textarea.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-textarea/gd-textarea.ts create mode 100644 libs/web-components/src/components/atoms/gd-toggle/gd-toggle.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-toggle/gd-toggle.ts create mode 100644 libs/web-components/src/components/atoms/gd-truncate/gd-truncate.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-truncate/gd-truncate.ts create mode 100644 libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.spec.ts create mode 100644 libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.ts create mode 100644 libs/web-components/stories/Badge.stories.ts create mode 100644 libs/web-components/stories/Box.stories.ts create mode 100644 libs/web-components/stories/Icon.stories.ts create mode 100644 libs/web-components/stories/Image.stories.ts create mode 100644 libs/web-components/stories/InputFile.stories.ts create mode 100644 libs/web-components/stories/Label.stories.ts create mode 100644 libs/web-components/stories/Link.stories.ts create mode 100644 libs/web-components/stories/Loader.stories.ts create mode 100644 libs/web-components/stories/Separator.stories.ts create mode 100644 libs/web-components/stories/Skeleton.stories.ts create mode 100644 libs/web-components/stories/Slider.stories.ts create mode 100644 libs/web-components/stories/SliderDots.stories.ts create mode 100644 libs/web-components/stories/Switch.stories.ts create mode 100644 libs/web-components/stories/Textarea.stories.ts create mode 100644 libs/web-components/stories/Toggle.stories.ts create mode 100644 libs/web-components/stories/Truncate.stories.ts create mode 100644 libs/web-components/stories/Wrapper.stories.ts diff --git a/CLAUDE.md b/CLAUDE.md index b434a21..404c6bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ 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 the six existing atoms and two molecules. `node bin/storybook/smoke-test.mjs` verifies native rendering and interactions after the combined build. Do not migrate additional components or remove Angular/Vue checks; harness cleanup is Phase 3. +**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): diff --git a/README.md b/README.md index 309aff4..d62c857 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,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 the six existing atoms and two molecules. +contains native stories and controls for all 23 published atoms and two molecules. `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. @@ -99,8 +99,9 @@ list to refresh when a story disappears. Link checks cover source documentation `llms.txt`, AI Markdown/JSON, and AI JavaScript copied into the UI package. Native browser smoke checks run with `node bin/storybook/smoke-test.mjs` after the combined build. -They render every native story and exercise custom events and popover dismissal. -The Angular/Vue and other Web Components harnesses remain unchanged until Phase 3. +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. ## Verification (gd-design-library) diff --git a/bin/generate-icon-catalog.ts b/bin/generate-icon-catalog.ts new file mode 100644 index 0000000..c755546 --- /dev/null +++ b/bin/generate-icon-catalog.ts @@ -0,0 +1,28 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { renderToStaticMarkup } from 'react-dom/server'; +import React, { createElement } from 'react'; +import { IconsList } from '../libs/ui/src/components/atoms/Icon/constants'; + +Object.assign(globalThis, { React }); + +const catalog = Object.fromEntries( + Object.entries(IconsList).map(([name, Component]) => { + const markup = renderToStaticMarkup( + createElement(Component, { fill: 'var(--gd-icon-fill)', fillSvg: 'var(--gd-icon-fill-svg)' }) + ); + const match = markup.match(/^]*)>([\s\S]*)<\/svg>$/); + if (!match) throw new Error(`Icon ${name} did not render one SVG root.`); + const viewBox = match[1].match(/viewBox="([^"]+)"/)?.[1]; + if (!viewBox) throw new Error(`Icon ${name} has no viewBox.`); + return [name, { viewBox, body: match[2] }]; + }) +); + +const output = + `// Generated from GridKit SVG assets by bin/generate-icon-catalog.ts.\n` + + `// Edit the source SVG and regenerate; React and Web Components consume this catalog.\n` + + `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); diff --git a/bin/storybook/native-checks.mjs b/bin/storybook/native-checks.mjs index 67c3ec1..343da5f 100644 --- a/bin/storybook/native-checks.mjs +++ b/bin/storybook/native-checks.mjs @@ -4,28 +4,48 @@ import assert from 'node:assert/strict'; 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) => + entry.type === 'story' && /^(Atoms|Molecules)\//.test(entry.title) && !entry.id.endsWith('--default-tokens') ); const names = new Set(); + const tags = { inputfile: 'input-file', sliderdots: 'slider-dots' }; for (const story of stories) { const name = story.title.split('/')[1].toLowerCase(); names.add(name); await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`); - await page.locator(`gd-${name}`).first().waitFor(); + const tag = `gd-${tags[name] ?? name}`; + await page.locator(tag).first().waitFor({ state: 'attached' }); await page.waitForFunction((tag) => { const el = document.querySelector(tag); return el?.shadowRoot?.childElementCount > 0; - }, `gd-${name}`); + }, tag); } assert.deepEqual([...names].sort(), [ 'avatar', + 'badge', + 'box', 'button', 'checkbox', 'counter', + 'icon', + 'image', 'input', + 'inputfile', + 'label', + 'link', + 'loader', 'menu', 'select', + 'separator', + 'skeleton', + 'slider', + 'sliderdots', + 'switch', + 'textarea', + 'toggle', + 'truncate', 'typography', + 'wrapper', ]); const open = async (id) => { await page.goto(`${base}/web-components/iframe.html?id=${id}&viewMode=story`); @@ -112,6 +132,34 @@ export async function checkNativeStories(page, base) { elements.map((element) => getComputedStyle(element.shadowRoot.firstElementChild).fontSize) ); assert.equal(new Set(displayFontSizes).size, 5); + await open('atoms-slider--default'); + const slider = page.getByRole('slider', { name: 'Slider' }); + await slider.fill('55'); + await event({ value: 55 }); + await open('atoms-sliderdots--default'); + await page.getByRole('tab', { name: 'Go to slide 3' }).click(); + await event({ index: 2 }); + await open('atoms-switch--default'); + await page.locator('gd-switch label').click(); + await event({ checked: true }); + await open('atoms-textarea--default'); + await page.getByRole('textbox').fill('Updated comment'); + await event({ value: 'Updated comment' }); + await open('atoms-toggle--default'); + await page.getByRole('button', { name: 'Option 2', exact: true }).click(); + await event({ value: 'Option 2' }); + await open('atoms-truncate--line-truncation'); + assert.equal( + await page + .locator('gd-truncate') + .evaluate((element) => getComputedStyle(element.shadowRoot.querySelector('[part="content"]')).webkitLineClamp), + '2' + ); + await open('atoms-wrapper--custom-tag-wrapper'); + assert.equal( + await page.locator('gd-wrapper').evaluate((element) => element.shadowRoot.firstElementChild.tagName), + 'SECTION' + ); await open('molecules-counter--default'); await page.getByRole('button', { name: 'Increment counter', exact: true }).click(); await event({ value: 2 }); @@ -153,6 +201,6 @@ export async function checkNativeStories(page, base) { await event({ data: { name: 'Archive', value: 'archive' }, value: 'archive' }); await page.getByRole('button', { name: 'Edit', exact: true }).waitFor(); console.log( - `Verified ${stories.length} native component stories and input/checkbox/select/typography/counter/menu interactions.` + `Verified ${stories.length} native component stories and representative interactions for every interactive atom and molecule.` ); } diff --git a/libs/design-core/src/icons/catalog.ts b/libs/design-core/src/icons/catalog.ts new file mode 100644 index 0000000..1a27593 --- /dev/null +++ b/libs/design-core/src/icons/catalog.ts @@ -0,0 +1,226 @@ +// Generated from GridKit SVG assets by bin/generate-icon-catalog.ts. +// Edit the source SVG and regenerate; React and Web Components consume this catalog. +export const iconCatalog = { + cross: { + viewBox: '0 0 24 24', + body: '', + }, + success: { + viewBox: '0 0 24 24', + body: '', + }, + error: { + viewBox: '0 0 24 24', + body: '', + }, + warning: { + viewBox: '0 0 24 24', + body: '', + }, + info: { + viewBox: '0 0 24 24', + body: '', + }, + dot: { + viewBox: '0 0 6 6', + body: '', + }, + check: { + viewBox: '0 0 24 24', + body: '', + }, + arrowDown: { + viewBox: '0 0 24 24', + body: '', + }, + arrowRight: { + viewBox: '0 0 24 24', + body: '', + }, + arrowLeft: { + viewBox: '0 0 24 24', + body: '', + }, + mobileMenu: { + viewBox: '0 0 24 24', + body: '', + }, + home: { + viewBox: '0 0 24 24', + body: '', + }, + slash: { + viewBox: '0 0 5 15', + body: '', + }, + arrowForward: { + viewBox: '0 0 24 24', + body: '', + }, + localShipping: { + viewBox: '0 0 24 24', + body: '', + }, + favorite: { + viewBox: '0 0 24 24', + body: '', + }, + favoriteOutlined: { + viewBox: '0 0 24 24', + body: '', + }, + deleteOutlined: { + viewBox: '0 0 24 24', + body: '', + }, + accountCircle: { + viewBox: '0 0 24 24', + body: '', + }, + shoppingBag: { + viewBox: '0 0 24 24', + body: '', + }, + errorOutline: { + viewBox: '0 0 24 24', + body: '', + }, + star: { + viewBox: '0 0 24 24', + body: '', + }, + starOutlined: { + viewBox: '0 0 24 24', + body: '', + }, + starHalf: { + viewBox: '0 0 24 24', + body: '', + }, + minus: { + viewBox: '0 0 24 24', + body: '', + }, + plus: { + viewBox: '0 0 24 24', + body: '', + }, + filter: { + viewBox: '0 0 24 24', + body: '', + }, + ruler: { + viewBox: '0 0 16 16', + body: '', + }, + processing: { + viewBox: '0 0 20 20', + body: '', + }, + paymentCard: { + viewBox: '0 0 24 24', + body: '', + }, + eye: { + viewBox: '0 0 24 24', + body: '', + }, + attachment: { + viewBox: '0 0 24 24', + body: '', + }, + upload: { + viewBox: '0 0 24 24', + body: '', + }, + folder: { + viewBox: '0 0 24 24', + body: '', + }, + folderOpen: { + viewBox: '0 0 24 24', + body: '', + }, + wifiTethering: { + viewBox: '0 0 24 24', + body: '', + }, + portrait: { + viewBox: '0 0 24 24', + body: '', + }, + search: { + viewBox: '0 0 24 24', + body: '', + }, + edit: { + viewBox: '0 0 24 24', + body: '', + }, + volumeUp: { + viewBox: '0 0 24 24', + body: '', + }, + contentCopy: { + viewBox: '0 0 24 24', + body: '', + }, + thumbUp: { + viewBox: '0 0 16 16', + body: '', + }, + thumbDown: { + viewBox: '0 0 16 16', + body: '', + }, + fileCopy: { + viewBox: '0 0 24 24', + body: '', + }, + keyboardArrowDown: { + viewBox: '0 0 24 24', + body: '', + }, + keyboardArrowUp: { + viewBox: '0 0 24 24', + body: '', + }, + send: { + viewBox: '0 0 24 24', + body: '', + }, + thumbUpFilled: { + viewBox: '0 0 24 24', + body: '', + }, + thumbDownFilled: { + viewBox: '0 0 24 24', + body: '', + }, + chat: { + viewBox: '0 0 24 24', + body: '', + }, + chatBubble: { + viewBox: '0 0 24 24', + body: '', + }, + phone: { + viewBox: '0 0 24 24', + body: '', + }, + mic: { + viewBox: '0 0 24 24', + body: '', + }, + fullscreen: { + viewBox: '0 0 24 24', + body: '', + }, + fullscreenExit: { + viewBox: '0 0 24 24', + body: '', + }, +} as const; + +export type GridKitIconName = keyof typeof iconCatalog; diff --git a/libs/design-core/src/icons/index.ts b/libs/design-core/src/icons/index.ts new file mode 100644 index 0000000..1f72407 --- /dev/null +++ b/libs/design-core/src/icons/index.ts @@ -0,0 +1 @@ +export { iconCatalog, type GridKitIconName } from './catalog'; diff --git a/libs/design-core/src/index.ts b/libs/design-core/src/index.ts index 47946d3..cc0ca44 100644 --- a/libs/design-core/src/index.ts +++ b/libs/design-core/src/index.ts @@ -3,3 +3,4 @@ export * from './stores'; export type { DesignCoreTheme } from './types'; export { resolveThemeTree } from './utils/resolveThemeTree'; export { get } from './utils/get'; +export { iconCatalog, type GridKitIconName } from './icons'; diff --git a/libs/ui/package.json b/libs/ui/package.json index ce05976..fed0c2d 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -58,6 +58,7 @@ } }, "dependencies": { + "gd-design-core": "*", "@react-spring/web": "^10.1.2", "@visx/curve": "^3.12.0", "embla-carousel-react": "^8.6.0", diff --git a/libs/ui/src/components/atoms/Icon/Icon.tsx b/libs/ui/src/components/atoms/Icon/Icon.tsx index 6813016..a13d796 100644 --- a/libs/ui/src/components/atoms/Icon/Icon.tsx +++ b/libs/ui/src/components/atoms/Icon/Icon.tsx @@ -1,12 +1,13 @@ 'use client'; -import { forwardRef, ComponentType } from 'react'; +import { forwardRef, ComponentType, type CSSProperties, type Ref, type RefAttributes } from 'react'; +import { iconCatalog, type GridKitIconName } from 'gd-design-core'; import { get } from '@utils'; import { useTheme } from '@hooks/useTheme'; import { getBoxStyles, resolveThemeColor } from '@tokens/utils'; import type { BoxStyles } from '@types'; -import { IconsList as DefaultIconsList, COMPONENT_NAME } from './constants'; +import { COMPONENT_NAME } from './constants'; import { IconProps } from './Icon.types'; let CustomIconsList: Record> = {}; @@ -20,10 +21,10 @@ export const Icon = forwardRef( const { theme } = useTheme(); const { icon, colors } = theme || {}; const iconSize = get(icon, ['size', size], { width, height }); - const IconsList = { ...DefaultIconsList, ...CustomIconsList }; - const SelectedIcon = IconsList[name as keyof typeof IconsList]; + const CustomIcon = CustomIconsList[name]; + const definition = iconCatalog[name as GridKitIconName]; - if (!SelectedIcon) { + if (!CustomIcon && !definition) { console.warn(`Icon "${String(name)}" not found.`); return null; } @@ -31,15 +32,36 @@ export const Icon = forwardRef( const { boxStyles, restProps: restNotStyledProps } = getBoxStyles(rest as BoxStyles); const componentStyles = [boxStyles, styles]; + if (CustomIcon) { + const RegisteredIcon = CustomIcon as ComponentType>; + return ( + } + name={name} + data-testid={`${COMPONENT_NAME}-${name}`} + fill={resolveThemeColor(colors, fill)} + fillSvg={resolveThemeColor(colors, fillSvg)} + {...iconSize} + css={componentStyles} + {...restNotStyledProps} + /> + ); + } + + const resolvedFill = resolveThemeColor(colors, fill) || 'currentColor'; + const resolvedFillSvg = resolveThemeColor(colors, fillSvg) || 'none'; return ( - } data-testid={`${COMPONENT_NAME}-${name}`} - fill={resolveThemeColor(colors, fill)} - fillSvg={resolveThemeColor(colors, fillSvg)} + viewBox={definition.viewBox} + fill={resolvedFillSvg} + xmlns="http://www.w3.org/2000/svg" + style={{ '--gd-icon-fill': resolvedFill, '--gd-icon-fill-svg': resolvedFillSvg } as CSSProperties} {...iconSize} css={componentStyles} {...restNotStyledProps} + dangerouslySetInnerHTML={{ __html: definition.body }} /> ); } diff --git a/libs/ui/src/components/atoms/Icon/__snapshots__/Icon.test.tsx.snap b/libs/ui/src/components/atoms/Icon/__snapshots__/Icon.test.tsx.snap index 972262a..29b13b4 100644 --- a/libs/ui/src/components/atoms/Icon/__snapshots__/Icon.test.tsx.snap +++ b/libs/ui/src/components/atoms/Icon/__snapshots__/Icon.test.tsx.snap @@ -7,13 +7,14 @@ exports[`Icon Component > SHOULD render correctly when a valid icon name is prov data-testid="Icon-cross" fill="none" height="24" + style="--gd-icon-fill: #000; --gd-icon-fill-svg: none;" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" >
@@ -26,13 +27,14 @@ exports[`Icon Component > SHOULD render with specified color and fill 1`] = ` data-testid="Icon-cross" fill="#2ecc71" height="32" + style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: #2ecc71;" viewBox="0 0 24 24" width="32" xmlns="http://www.w3.org/2000/svg" >
diff --git a/libs/ui/src/tokens/index.ts b/libs/ui/src/tokens/index.ts index 859036c..b62830d 100644 --- a/libs/ui/src/tokens/index.ts +++ b/libs/ui/src/tokens/index.ts @@ -54,6 +54,7 @@ import { checkbox } from './checkbox'; import { zIndex } from './zIndex'; import { sidebar } from './sidebar'; import { imagePreview } from './imagePreview'; +import { image } from './image'; import { inputArea } from './inputArea'; import { sliderDots } from './sliderDots'; import { attachmentFile } from './attachmentFile'; @@ -119,6 +120,7 @@ export * from './truncate'; export * from './zIndex'; export * from './sidebar'; export * from './imagePreview'; +export * from './image'; export * from './inputArea'; export * from './sliderDots'; export * from './attachmentFile'; @@ -133,6 +135,7 @@ export const defaultTokens = { separator, draganddrop, avatar, + image, chatbubble, radiogroup, draganddropfiles, diff --git a/libs/ui/tsconfig.json b/libs/ui/tsconfig.json index 4f5cb15..65b2206 100644 --- a/libs/ui/tsconfig.json +++ b/libs/ui/tsconfig.json @@ -13,6 +13,8 @@ "jsxImportSource": "@emotion/react", "baseUrl": "./", "paths": { + "gd-design-core": ["../design-core/src/index.ts"], + "gd-design-core/*": ["../design-core/src/*"], "@/*": ["src/*"], "@types": ["src/types/"], "@types/*": ["src/types/*"], diff --git a/libs/ui/tsconfig.lib.json b/libs/ui/tsconfig.lib.json index bd30911..2da4c84 100644 --- a/libs/ui/tsconfig.lib.json +++ b/libs/ui/tsconfig.lib.json @@ -14,6 +14,8 @@ // Aliases "baseUrl": ".", "paths": { + "gd-design-core": ["../design-core/src/index.ts"], + "gd-design-core/*": ["../design-core/src/*"], "@types": ["src/types/"], "@types/*": ["src/types/*"], "@constants": ["src/constants/"], @@ -60,5 +62,6 @@ "**/*.integration.tsx", "**/*.integration.helpers.tsx" ], - "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx", "src/**/*.json"] + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx", "src/**/*.json"], + "references": [{ "path": "../design-core/tsconfig.lib.json" }] } diff --git a/libs/ui/vitest.alias.ts b/libs/ui/vitest.alias.ts index 978312d..b150b5d 100644 --- a/libs/ui/vitest.alias.ts +++ b/libs/ui/vitest.alias.ts @@ -1,6 +1,7 @@ import * as path from 'path'; export const sharedAlias = { + 'gd-design-core': path.resolve(__dirname, '../design-core/src/index.ts'), '@': path.resolve(__dirname, './src'), '@types': path.resolve(__dirname, './src/types/'), '@constants': path.resolve(__dirname, './src/constants/'), diff --git a/libs/web-components/MIGRATION-MANIFEST.loader-separator-skeleton.md b/libs/web-components/MIGRATION-MANIFEST.loader-separator-skeleton.md new file mode 100644 index 0000000..cd576d5 --- /dev/null +++ b/libs/web-components/MIGRATION-MANIFEST.loader-separator-skeleton.md @@ -0,0 +1,11 @@ +# Loader, Separator, and Skeleton migration manifest + +`react-component-ast` and `web-component-generator` were unavailable for this run. This reviewed manual manifest follows the self-migration-agent fallback and was derived from each React component's source, public types, tokens, tests, exports, local stories, and published Storybook inventory. + +| React component | Classification | Custom element | Attributes / properties | Slots | Parts | Events / methods | Accessibility and compatibility | +| --------------- | -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Loader | element | `gd-loader` | `name`, `variant`, `size`, `rounded`, `animationProps`, `withWrapper`, `styles`, `theme` | default custom content | `wrapper`, `loader`, `dot` | none | Inner loader exposes `role=status` and an accessible loading name. Full-page uses the native popover top layer. React `as` becomes the stable custom-element host plus `withWrapper`. | +| Separator | element | `gd-separator` | `orientation`, `length`, `color`, `size`, `variant`, `as`, `label`, `labelPosition`, `labelColor`, `styles`, `theme` | none; the React API accepts a string label | `separator`, `line`, `label` | none | `as` selects the internal `div`, `hr`, or `span`. An `hr` with a label uses a `div` wrapper and `hr` line segments, matching React. React box-spacing props remain available through `styles` or host CSS. | +| Skeleton | element | `gd-skeleton` | `variant`, `width`, `height`, `backgroundColor`, `animationName`, `animationProps`, `styles`, `theme` | default child content | `skeleton` | none | The native host accepts ARIA attributes. `animationName = null` disables the built-in animation. React `className` maps to the custom-element `class` attribute; children map to the default slot. | + +All three elements resolve their source token trees. They define no component-owned `static styles`; runtime stylesheets are generated from token blocks and documented public style properties. diff --git a/libs/web-components/MIGRATION-MANIFEST.slider-controls.md b/libs/web-components/MIGRATION-MANIFEST.slider-controls.md new file mode 100644 index 0000000..29f9ae8 --- /dev/null +++ b/libs/web-components/MIGRATION-MANIFEST.slider-controls.md @@ -0,0 +1,13 @@ +# Slider controls migration manifest + +The generators were unavailable. This reviewed manual manifest uses the complete React source, public types, tokens, tests, exports, and published Storybook evidence allowed by the self-migration-agent fallback. + +| Component | Classification | Platform contract | +| ---------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Skeleton | element, re-audited | `gd-skeleton`; scalar shape/dimension/animation attributes, object `styles`/`theme` properties, default slot, `skeleton` part, native host ARIA. | +| Slider | element | `gd-slider`; numeric `min`, `max`, `value`, `step`; boolean `disabled`; `styles`/`theme`; `slider` part; bubbling/composed `gd-change` with `{ value }`; native range keyboard and ARIA behavior. | +| SliderDots | element | `gd-slider-dots`; numeric `count` and `activeIndex`; `styles`/`theme`; `dots` and `dot` parts; bubbling/composed `gd-change` with `{ index }`; tablist/tab roles and accessible names. | +| Switch | element | `gd-switch`; `checked`, `disabled`, `isLoading`, `label`, `name`; default label slot; exposed parts; bubbling/composed `gd-change` with `{ checked }`; native checkbox keyboard behavior; composes `gd-loader`. | +| Textarea | element | `gd-textarea`; native value, naming, placeholder, disabled, readonly, rows and length properties plus resize, variant, color, dynamic height, sizing, counter and style properties; exposed parts; `gd-input`/`gd-change` with `{ value }`; public `focus()` method. | + +React callbacks map to native custom events. React children map to the Switch default slot. React synthetic change events map to value-bearing DOM custom events. No React-only runtime is embedded. diff --git a/libs/web-components/MIGRATION-MANIFEST.toggle-truncate-typography-wrapper.md b/libs/web-components/MIGRATION-MANIFEST.toggle-truncate-typography-wrapper.md new file mode 100644 index 0000000..9bb4cd8 --- /dev/null +++ b/libs/web-components/MIGRATION-MANIFEST.toggle-truncate-typography-wrapper.md @@ -0,0 +1,12 @@ +# Toggle, Truncate, Typography, and Wrapper migration manifest + +The generators were unavailable. This reviewed manual manifest uses the complete React source, public types, tokens, tests, exports, and the published React Storybook evidence allowed by the self-migration-agent fallback. + +| Component | Classification | Platform contract | +| ---------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Toggle | element | `gd-toggle`; string/object `items`, controlled `value`, `disabled`, `renderItemContent`, `styles` and `theme`; composes `gd-button`; bubbling/composed `gd-change` with `{ value }`; group semantics. | +| Truncate | element | `gd-truncate`; numeric `lines`, `styles` and `theme`; default slot; `content` part; reactive overflow measurement through `isTruncated` and public `measure()`; ResizeObserver support. | +| Typography | element, re-audited | `gd-typography`; all React variants, display sizes, style variants, alignment, color, semantic `as`, token typography selection, default slot, and `typography` part remain covered. | +| Wrapper | element | `gd-wrapper`; `inline`, `section`, and `fullPage` variants, semantic `as`, `styles` and `theme`; default slot and `wrapper` part. The explicit migration request overrides the earlier CSS-only classification. | + +React callbacks map to native custom events, React children map to default slots, and function/object props remain JavaScript properties. The published Atoms inventory is recorded in `published-atoms-audit.json` and enforced against the local atom ports and parity story counts. diff --git a/libs/web-components/README.md b/libs/web-components/README.md index c61c2c2..45a15ea 100644 --- a/libs/web-components/README.md +++ b/libs/web-components/README.md @@ -2,7 +2,7 @@ GridKit Web Components provides design-system elements that work in any web application. They are native custom elements built with Lit, so they can be used in plain HTML, Angular, Vue, React, or another framework. -Available elements: `gd-button`, `gd-checkbox`, `gd-input`, `gd-select`, `gd-typography`, `gd-avatar`, `gd-menu`, and `gd-counter`. +Available elements: `gd-avatar`, `gd-badge`, `gd-box`, `gd-button`, `gd-checkbox`, `gd-icon`, `gd-image`, `gd-input`, `gd-input-file`, `gd-label`, `gd-link`, `gd-loader`, `gd-select`, `gd-separator`, `gd-skeleton`, `gd-slider`, `gd-slider-dots`, `gd-switch`, `gd-textarea`, `gd-toggle`, `gd-truncate`, `gd-typography`, `gd-wrapper`, `gd-counter`, and `gd-menu`. ## Install @@ -99,19 +99,36 @@ const accepted = ref(false); ## Run locally The shared Storybook is started from the repository root with `npm run storybook` and -opened at `http://localhost:6006`. Its **Web Components** section contains native stories, Controls, and docs for all eight +opened at `http://localhost:6006`. Its **Web Components** section contains native stories, Controls, and docs for all twenty-five existing elements. Interactive stories show native event payloads below the component. -| Storybook component | React-contract coverage | -| ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | -| [Avatar](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-avatar--docs) | Image, fallback, badge, every size, custom colors | -| [Button](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-button--docs) | Every variant and radius, full-width, icon-only, loading, disabled, icon slots | -| [Checkbox](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-checkbox--docs) | Checked, indeterminate, disabled, both sizes; `gd-change` → `{ checked }` | -| [Input](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-input--docs) | Types, validation colors, read-only/disabled, adornments; `gd-input`/`gd-change` | -| [Select](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-select--docs) | Single/multiple, search, auto-open, colors, adornments, custom initiator; `gd-change` | -| [Typography](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-typography--docs) | All 18 variants, display sizes, style variants, alignment/color controls, semantic override | -| [Counter](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_molecules-counter--docs) | Minimum/maximum, custom range, disabled; `gd-change` → `{ value: number }` | -| [Menu](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_molecules-menu--docs) | Placement, offsets, height constraints, close/persist behavior; `gd-change` → `{ data, value }` | +| Storybook component | React-contract coverage | +| ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| [Avatar](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-avatar--docs) | Image, fallback, badge, every size, custom colors | +| [Badge](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-badge--docs) | Variants, appearances, sizes, disabled state, and icon slots | +| [Box](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-box--docs) | Vertical/horizontal layout, borders, highlight, shadow hover, and slotted content | +| [Button](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-button--docs) | Every variant and radius, full-width, icon-only, loading, disabled, icon slots | +| [Checkbox](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-checkbox--docs) | Checked, indeterminate, disabled, both sizes; `gd-change` → `{ checked }` | +| [Input](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-input--docs) | Types, validation colors, read-only/disabled, adornments; `gd-input`/`gd-change` | +| [Image](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-image--docs) | Loading placeholder, fallback slot, caption, sizing, object fit, and load/error events | +| [Icon](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-icon--docs) | All built-in icons from the shared core catalog, token sizes, and theme-aware fills | +| [InputFile](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-inputfile--docs) | Accept/capture/multiple/disabled behavior, custom label, icon label, and `gd-change` file detail | +| [Label](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-label--docs) | Native label association, slotted content, icons, and style overrides | +| [Link component](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-link--docs) | Variants, sizes, underline states, disabled semantics, targets, and href | +| [Loader](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-loader--docs) | Circle/dots, sizes, rounded dots, wrappers, section, and native full-page top layer | +| [Select](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-select--docs) | Single/multiple, search, auto-open, colors, adornments, custom initiator; `gd-change` | +| [Separator](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-separator--docs) | Orientations, line variants, thickness, labels, semantic elements, lengths, and colors | +| [Skeleton](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-skeleton--docs) | Rounded/rectangular/circular shapes, dimensions, colors, animation control, and child content | +| [Slider](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-slider--docs) | Range limits, controlled values, keyboard input, disabled state, visual fill, and events | +| [SliderDots](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-sliderdots--docs) | Accessible carousel tabs, active state, larger sets, and selection events | +| [Switch](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-switch--docs) | Checked, disabled, loading, controlled/uncontrolled behavior, label placement, and events | +| [Textarea](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-textarea--docs) | Values, colors, resize modes, dynamic height, focus, character limits, and input/change events | +| [Toggle](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-toggle--docs) | String/object items, selected value, disabled state, custom item rendering, and change events | +| [Truncate](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-truncate--docs) | Single/multiple-line truncation, style overrides, overflow measurement, and accessibility | +| [Typography](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-typography--docs) | All 18 variants, display sizes, style variants, alignment/color controls, semantic override | +| [Wrapper](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_atoms-wrapper--docs) | Inline, section and full-page layout variants, semantic tag override, content, and styles | +| [Counter](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_molecules-counter--docs) | Minimum/maximum, custom range, disabled; `gd-change` → `{ value: number }` | +| [Menu](https://storybook.cto-rnd-system-design.griddynamics.net/?path=/docs/web-components_molecules-menu--docs) | Placement, offsets, height constraints, close/persist behavior; `gd-change` → `{ data, value }` | ### React-to-custom-element contract @@ -125,12 +142,11 @@ component's `styles` property rather than reproduced as React-only prop names. `react-parity.json` records the required mapping and Storybook examples for every shipped port. `npm run check:web-components-ports` fails when an element, required mapping, or parity story is missing. Unit tests cover the programmatic property/event contract; the Storybook smoke test also -checks multiple/searchable Select behavior and every Typography variant/display size in Chromium. +checks representative interactions, semantic output, and visual state for the interactive ports in Chromium. There is no separate Web Components Storybook command. Run `npm run build-storybook` -then `node bin/storybook/smoke-test.mjs` to verify every native story plus input, checkbox, -select, counter, and menu interactions in Chromium. The existing Angular/Vue harness -workflow below remains available until the later integration-check cleanup. +then `node bin/storybook/smoke-test.mjs` to verify every native story and representative +interactions in Chromium. The Angular and Vue harnesses cover the complete supported catalog. Start the development server for all Web Components examples: diff --git a/libs/web-components/harness/fidelity-check-vue.ts b/libs/web-components/harness/fidelity-check-vue.ts index 2bb9a52..7d7678f 100644 --- a/libs/web-components/harness/fidelity-check-vue.ts +++ b/libs/web-components/harness/fidelity-check-vue.ts @@ -101,6 +101,141 @@ const App = { h('gd-counter', { min: 1, max: 5, initial: 1, theme: defaultTheme }), h('output', `Value: ${counterValue.value}`), ]), + section('Box — vertical / horizontal / bordered / highlighted', [ + h('gd-box', { variant: 'vertical', theme: defaultTheme }, 'Vertical box'), + h('gd-box', { variant: 'horizontal', isBordered: true, theme: defaultTheme }, 'Bordered horizontal box'), + h( + 'gd-box', + { isBordered: true, isHighlighted: true, withShadowHover: true, theme: defaultTheme }, + 'Interactive box' + ), + ]), + section('Badge — variants / sizes / icons / disabled', [ + h('gd-badge', { variant: 'primary', appearance: 'filled', size: 'xs', theme: defaultTheme }, 'Primary'), + h('gd-badge', { variant: 'secondary', appearance: 'outline', size: 'md', theme: defaultTheme }, [ + h('span', { slot: 'icon-start' }, '★'), + 'With icon', + ]), + h( + 'gd-badge', + { variant: 'tertiary', appearance: 'filledLight', size: 'lg', disabled: true, theme: defaultTheme }, + 'Disabled' + ), + ]), + section('Image — image / caption / fallback', [ + h('gd-image', { + src: portrait, + alt: 'Ada Lovelace', + width: 96, + height: 96, + caption: 'Portrait', + theme: defaultTheme, + }), + h( + 'gd-image', + { src: '/missing-image.png', alt: 'Unavailable image', width: 96, height: 96, theme: defaultTheme }, + [h('span', { slot: 'fallback' }, 'Image unavailable')] + ), + ]), + section('Icon — shared React/Web Components catalog', [ + h('gd-icon', { name: 'star', size: 'xs', theme: defaultTheme }), + h('gd-icon', { name: 'search', size: 'md', theme: defaultTheme }), + h('gd-icon', { name: 'edit', size: 'xl', 'aria-label': 'Edit', theme: defaultTheme }), + ]), + section('InputFile — default / multiple / accept / icon', [ + h('gd-input-file', { theme: defaultTheme }, 'Browse Files'), + h('gd-input-file', { multiple: true, accept: 'image/*', theme: defaultTheme }, 'Choose images'), + h('gd-input-file', { isIcon: true, 'aria-label': 'Upload file', theme: defaultTheme }, [ + h('gd-icon', { name: 'upload' }), + ]), + h('gd-input-file', { disabled: true, theme: defaultTheme }, 'Disabled'), + ]), + section('Label — text / icon / association', [ + h('gd-label', { for: 'vue-labelled-input', theme: defaultTheme }, 'Account name'), + h('input', { id: 'vue-labelled-input' }), + h('gd-label', { theme: defaultTheme }, [h('gd-icon', { name: 'star' }), ' Required label']), + ]), + section('Link — variants / underline / size / disabled', [ + h('gd-link', { href: '#vue-link', variant: 'primary', size: 'sm', theme: defaultTheme }, 'Primary'), + h( + 'gd-link', + { href: '#vue-link', variant: 'inherit', underline: 'highlight', size: 'md', theme: defaultTheme }, + 'Highlighted' + ), + h('gd-link', { variant: 'inverted', size: 'lg', disabled: true, theme: defaultTheme }, 'Disabled'), + ]), + section('Loader — circle / dots / sizes / wrapper variants', [ + h('gd-loader', { name: 'circle', size: 'xs', theme: defaultTheme }), + h('gd-loader', { name: 'dots', size: 'md', rounded: 'round', theme: defaultTheme }), + h('gd-loader', { name: 'circle', size: 'lg', variant: 'section', theme: defaultTheme }), + h('gd-loader', { name: 'circle', size: 'sm', withWrapper: false, theme: defaultTheme }), + ]), + section('Separator — horizontal / vertical / labels / variants', [ + h('gd-separator', { length: '180px', theme: defaultTheme }), + h('gd-separator', { + length: '180px', + label: 'OR', + labelPosition: 'center', + size: 'md', + variant: 'dashed', + theme: defaultTheme, + }), + h('gd-separator', { length: '80px', orientation: 'vertical', label: 'Or', size: 'md', theme: defaultTheme }), + ]), + section('Skeleton — rounded / circular / rectangular / children', [ + h('gd-skeleton', { width: '180px', height: '15px', theme: defaultTheme }), + h('gd-skeleton', { width: '60px', height: '60px', variant: 'circular', theme: defaultTheme }), + h( + 'gd-skeleton', + { + width: '180px', + height: '50px', + variant: 'rectangular', + backgroundColor: 'theme.palette.success.main', + theme: defaultTheme, + }, + 'Loading Content...' + ), + ]), + section('Slider / dots — range and carousel navigation', [ + h('gd-slider', { min: 0, max: 100, value: 45, 'aria-label': 'Volume', theme: defaultTheme }), + h('gd-slider-dots', { count: 5, activeIndex: 1, theme: defaultTheme }), + ]), + section('Switch — default / checked / loading', [ + h('gd-switch', { theme: defaultTheme }, 'Notifications'), + h('gd-switch', { checked: true, label: 'left', theme: defaultTheme }, 'Enabled'), + h('gd-switch', { isLoading: true, theme: defaultTheme }, 'Saving'), + ]), + section('Textarea — colors / counter / resize', [ + h('gd-textarea', { placeholder: 'Comment', theme: defaultTheme }), + h('gd-textarea', { color: 'success', maxCharacters: 100, defaultValue: 'Looks good', theme: defaultTheme }), + h('gd-textarea', { resize: 'both', rows: 3, theme: defaultTheme }), + ]), + section('Toggle — selected / disabled', [ + h('gd-toggle', { items: ['Option 1', 'Option 2', 'Option 3'], value: 'Option 1', theme: defaultTheme }), + h('gd-toggle', { + items: ['Option 1', 'Option 2', 'Option 3'], + value: 'Option 2', + disabled: true, + theme: defaultTheme, + }), + ]), + section('Truncate — single / multiple lines', [ + h( + 'gd-truncate', + { style: 'width: 180px', theme: defaultTheme }, + 'A long single-line value that must be truncated.' + ), + h( + 'gd-truncate', + { style: 'width: 180px', lines: 2, theme: defaultTheme }, + 'A longer block of content constrained to two lines for the Vue integration harness.' + ), + ]), + section('Wrapper — inline / section', [ + h('gd-wrapper', { variant: 'inline', theme: defaultTheme }, 'Inline content'), + h('gd-wrapper', { variant: 'section', as: 'section', theme: defaultTheme }, 'Section content'), + ]), ]); }, }; diff --git a/libs/web-components/harness/fidelity-check.ts b/libs/web-components/harness/fidelity-check.ts index 0377694..ce56013 100644 --- a/libs/web-components/harness/fidelity-check.ts +++ b/libs/web-components/harness/fidelity-check.ts @@ -79,6 +79,90 @@ const componentMetadata = { Value: {{ counterValue }} + +

Box — vertical / horizontal / bordered / highlighted

+ Vertical box + Bordered horizontal box + Interactive box +
+ +

Badge — variants / sizes / icons / disabled

+ Primary + ★With icon + Disabled +
+ +

Image — image / caption / fallback

+ + Image unavailable +
+ +

Icon — shared React/Web Components catalog

+ + + +
+ +

InputFile — default / multiple / accept / icon

+ Browse Files + Choose images + + Disabled +
+ +

Label — text / icon / association

+ Account name + + Required label +
+ +

Link — variants / underline / size / disabled

+ Primary + Highlighted + Disabled +
+ +

Loader — circle / dots / sizes / wrapper variants

+ + + + +
+ +

Separator — horizontal / vertical / labels / variants

+ + + +
+ +

Skeleton — rounded / circular / rectangular / children

+ + + Loading Content... +
+ +

Slider / dots — range and carousel navigation

+ + +
+

Switch — default / checked / loading

+ NotificationsEnabledSaving +
+

Textarea — colors / counter / resize

+ +
+

Toggle — selected / disabled

+ + +
+

Truncate — single / multiple lines

+ A long single-line value that must be truncated. + A longer block of content constrained to two lines for the Angular integration harness. +
+

Wrapper — inline / section / full page

+ Inline content + Section content +
`, }; @@ -89,6 +173,7 @@ class FidelityHarnessComponent { { name: 'Beta', value: 'b' }, { name: 'Gamma', value: 'c' }, ]; + readonly toggleItems = ['Option 1', 'Option 2', 'Option 3']; readonly portrait = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"%3E%3Crect width="64" height="64" fill="%2391b8d8"/%3E%3Ccircle cx="32" cy="24" r="13" fill="%23f1c7a5"/%3E%3Cpath d="M8 64c3-18 14-27 24-27s21 9 24 27" fill="%233e6184"/%3E%3C/svg%3E'; inputValue = 'Default Input'; diff --git a/libs/web-components/published-atoms-audit.json b/libs/web-components/published-atoms-audit.json new file mode 100644 index 0000000..598b2ab --- /dev/null +++ b/libs/web-components/published-atoms-audit.json @@ -0,0 +1,29 @@ +{ + "source": "https://storybook.cto-rnd-system-design.griddynamics.net/index.json", + "auditedAt": "2026-10-01", + "components": { + "gd-avatar": 10, + "gd-badge": 8, + "gd-box": 10, + "gd-button": 13, + "gd-checkbox": 6, + "gd-icon": 6, + "gd-image": 9, + "gd-input": 17, + "gd-input-file": 9, + "gd-label": 6, + "gd-link": 12, + "gd-loader": 11, + "gd-select": 16, + "gd-separator": 8, + "gd-skeleton": 9, + "gd-slider": 7, + "gd-slider-dots": 4, + "gd-switch": 9, + "gd-textarea": 16, + "gd-toggle": 5, + "gd-truncate": 5, + "gd-typography": 10, + "gd-wrapper": 7 + } +} diff --git a/libs/web-components/react-parity.json b/libs/web-components/react-parity.json index 8213919..0ca7a12 100644 --- a/libs/web-components/react-parity.json +++ b/libs/web-components/react-parity.json @@ -1,4 +1,307 @@ { + "gd-toggle": { + "react": "Toggle", + "source": ["items", "value", "disabled", "renderItemContent", "styles", "gd-change", "gd-button", "role=\"group\""], + "stories": ["Default", "Disabled", "WithCustomRender", "WithAccessibility", "DefaultTokens"] + }, + "gd-truncate": { + "react": "Truncate", + "source": ["lines", "styles", "slot", "isTruncated", "measure()", "part=\"content\""], + "stories": ["Default", "LineTruncation", "WithCustomStyling", "RefAPIWithTooltipOnOverflow", "WithAccessibility"] + }, + "gd-wrapper": { + "react": "Wrapper", + "source": ["variant", "as", "styles", "slot", "part=\"wrapper\""], + "stories": [ + "InteractiveExample", + "SectionWrapper", + "InlineWrapper", + "FullPageWrapper", + "CustomTagWrapper", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-slider": { + "react": "Slider", + "source": ["min", "max", "value", "step", "disabled", "styles", "gd-change", "part=\"slider\""], + "stories": [ + "Default", + "WithInitialValue", + "Disabled", + "CustomStyles", + "Controlled", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-slider-dots": { + "react": "SliderDots", + "source": ["count", "activeIndex", "styles", "gd-change", "role=\"tablist\"", "role=\"tab\""], + "stories": ["Default", "Interactive", "ManyDots", "DefaultTokens"] + }, + "gd-switch": { + "react": "Switch", + "source": ["checked", "disabled", "isLoading", "label", "name", "styles", "gd-change", "slot", "gd-loader"], + "stories": [ + "Default", + "Checked", + "Disabled", + "LabelLeft", + "Controlled", + "Uncontrolled", + "WithLoading", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-textarea": { + "react": "Textarea", + "source": [ + "value", + "defaultValue", + "name", + "placeholder", + "resize", + "variant", + "color", + "ariaDescribedBy", + "dynamicHeightAdjustment", + "autoFocus", + "disabled", + "readOnly", + "rows", + "maxLength", + "maxCharacters", + "minHeight", + "maxHeight", + "styles", + "gd-input", + "gd-change", + "focus(" + ], + "stories": [ + "Default", + "Disabled", + "ReadOnly", + "VariantInline", + "ColorPrimary", + "ColorSuccess", + "ColorWarning", + "ColorError", + "AutoFocus", + "DynamicHeight", + "WithCharLimit", + "Resizable", + "WithControlledStateEraseOnEnterClick", + "MaxCharacters", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-separator": { + "react": "Separator", + "source": [ + "orientation", + "length", + "color", + "size", + "variant", + "as", + "label", + "labelPosition", + "labelColor", + "styles", + "part=\"line\"", + "part=\"label\"" + ], + "stories": [ + "Default", + "WithCustomColor", + "WithDifferentVariants", + "WithLabel", + "Vertical", + "VerticalWithLabel", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-skeleton": { + "react": "Skeleton", + "source": [ + "variant", + "width", + "height", + "backgroundColor", + "animationName", + "animationProps", + "styles", + "slot", + "part=\"skeleton\"" + ], + "stories": [ + "Default", + "Circular", + "Rectangular", + "WithThemeColor", + "WithCustomAnimation", + "WithChildren", + "ComposedLayout", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-icon": { + "react": "Icon", + "source": ["name", "width", "height", "size", "fill", "fillSvg", "theme", "part=\"svg\""], + "stories": [ + "AllIcons", + "Default", + "WithDefinedSize", + "RegisteringCustomIcons", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-input-file": { + "react": "InputFile", + "source": ["accept", "capture", "multiple", "disabled", "isIcon", "buttonVariant", "styles", "gd-change", "open()"], + "stories": [ + "Default", + "Disabled", + "Multiple", + "WithAccept", + "CustomLabel", + "IconLabel", + "WithCustomStyles", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-label": { + "react": "Label", + "source": ["htmlFor", "styles", "slot", "part=\"label\""], + "stories": [ + "LabelDefault", + "LabelWithChildIcon", + "CustomStyles", + "DefaultWithTailwind", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-link": { + "react": "Link", + "source": [ + "variant", + "size", + "underline", + "color", + "cursor", + "disabled", + "target", + "href", + "rel", + "styles", + "slot" + ], + "stories": [ + "Variants", + "Disabled", + "TargetBlankVisited", + "InheritWithButtonAsChild", + "Inverted", + "InheritWithTypographyAsChild", + "InheritWithTailwind", + "CustomStyles", + "WithUnderline", + "WithSizes", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-loader": { + "react": "Loader", + "source": ["name", "variant", "size", "rounded", "animationProps", "withWrapper", "styles", "popover", "slot"], + "stories": [ + "Default", + "LoaderNames", + "LoaderSizes", + "LoaderRounded", + "LoaderVariantsWithWrapperViewAsHeaderTag", + "CustomLoaderTailwindClassBounceAnimation", + "LoaderSectionVariant", + "SectionLoaderButtonVariant", + "InlineLoaderButtonVariant", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-badge": { + "react": "Badge", + "source": [ + "variant", + "appearance", + "size", + "disabled", + "styles", + "slot name=\"icon-start\"", + "slot name=\"icon-end\"" + ], + "stories": [ + "Default", + "Sizes", + "Variants", + "WithIcons", + "WithBoxStyles", + "Disabled", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-box": { + "react": "Box", + "source": ["variant", "isBordered", "isHighlighted", "withShadowHover", "tabIndex", "styles", "slot"], + "stories": [ + "Default", + "Bordered", + "Highlighted", + "WithShadowHover", + "VerticalLayout", + "HorizontalLayout", + "CenteredContent", + "Examples", + "WithAccessibility", + "DefaultTokens" + ] + }, + "gd-image": { + "react": "Image", + "source": [ + "src", + "alt", + "width", + "height", + "caption", + "objectFit", + "styles", + "slot name=\"placeholder\"", + "slot name=\"fallback\"", + "gd-load", + "gd-error" + ], + "stories": [ + "Default", + "WithCaption", + "WithPlaceholder", + "Clickable", + "FallbackImage", + "WithAsProp", + "WithCaptionAsProp", + "WithAccessibility", + "DefaultTokens" + ] + }, "gd-avatar": { "react": "Avatar", "source": [ @@ -11,7 +314,18 @@ "styles", "slot name=\"fallback\"" ], - "stories": ["Default", "Image", "Badge", "AllSizes", "CustomColors", "SlottedFallback"] + "stories": [ + "Default", + "WithBadge", + "WithCustomBadgeColor", + "WithInitials", + "WithCustomBackgroundColor", + "WithIcon", + "WithImageAndFallback", + "WithDifferentSize", + "WithAccessibility", + "DefaultTokens" + ] }, "gd-button": { "react": "Button", @@ -31,12 +345,26 @@ "slot name=\"icon-start\"", "slot name=\"icon-end\"" ], - "stories": ["Default", "AllVariants", "Rounded", "FullWidth", "IconOnly", "Loading", "IconSlots"] + "stories": [ + "AllVariants", + "Default", + "WithIcons", + "IconOnly", + "FullWidthButton", + "RoundedButton", + "DisabledButton", + "ButtonStatesUsingClass", + "CustomStyledButton", + "RealWorldExamples", + "IsLoading", + "WithAccessibility", + "DefaultTokens" + ] }, "gd-checkbox": { "react": "Checkbox", "source": ["checked", "indeterminate", "disabled", "name", "value", "size", "styles", "gd-change"], - "stories": ["Default", "Checked", "Indeterminate", "Disabled", "Sizes"] + "stories": ["Default", "Controlled", "Indeterminate", "Disabled", "Sizes", "DefaultTokens"] }, "gd-input": { "react": "Input", @@ -67,7 +395,25 @@ "gd-input", "gd-change" ], - "stories": ["Default", "Empty", "Disabled", "ReadOnly", "ValidationColors", "WithAdornments", "InputTypes"] + "stories": [ + "PrimaryDefaultWithLabelAndHelperText", + "SuccessWithLabelAndHelperText", + "WarningWithLabelAndHelperText", + "ErrorWithLabelAndHelperText", + "Disabled", + "ReadOnly", + "WrapperAsSpan", + "WithAdornments", + "WithStartAdornment", + "WithEndAdornmentAsIcon", + "DefaultWithTailwind", + "CustomStyles", + "CheckboxWithLabel", + "RadioControlledWithLabel", + "RadioGroupWithLabel", + "WithAccessibility", + "DefaultTokens" + ] }, "gd-select": { "react": "Select", @@ -101,15 +447,21 @@ ], "stories": [ "Default", - "Selected", + "MultipleSelect", + "WithAdornmentsAndResetOptionStory", + "WithCustomInitiatorAndSelectedOutputValue", + "UsingRefComponentWithCustomPlaceholder", + "WithFileInputAndCustomDropdown", + "CustomItemIdentifierComponent", + "WithAccessibility", "Disabled", - "Empty", - "Multiple", + "EmptyItems", + "ColorPrimary", + "ColorSuccess", + "ColorWarning", + "ColorError", "Searchable", - "AutoOpen", - "ValidationColors", - "WithAdornments", - "CustomInitiator" + "DefaultTokens" ] }, "gd-typography": { @@ -117,18 +469,29 @@ "source": ["variant", "as", "color", "size", "align", "styleVariant", "styles", "slot"], "stories": [ "Default", - "AllVariants", - "DisplaySizes", - "StyleVariants", - "SemanticOverride", - "CodeAndKeyboard", - "CaptionAndHeader" + "AsCustomComponent", + "AsCustomHtmlTag", + "CombinedStyleVariant", + "Disclaimers", + "Display", + "Heading", + "Body", + "WithAccessibility", + "DefaultTokens" ] }, "gd-counter": { "react": "Counter", "source": ["min", "max", "initial", "isDisabled", "styles", "gd-change"], - "stories": ["Default", "AdjustedMaximum", "AdjustedMinimum", "CustomRange", "Disabled"] + "stories": [ + "Default", + "AdjustedMaxValue5", + "AdjustedMinValue3", + "AdjustedMin2MaxValue10", + "WithExternalCounterChangeHandler", + "Disabled", + "DefaultTokens" + ] }, "gd-menu": { "react": "Menu", @@ -148,6 +511,16 @@ "openMenu()", "closeMenu()" ], - "stories": ["Default", "KeepOpenOnSelect", "Placement", "WithOffset", "WithHeightConstraints"] + "stories": [ + "Default", + "CloseOnSelectFalse", + "Placement", + "WithRefControl", + "WithOffset", + "WithHeightConstraints", + "WithPositioningOptions", + "WithEditAndDeleteModals", + "DefaultTokens" + ] } } diff --git a/libs/web-components/scripts/check-ported-elements.mjs b/libs/web-components/scripts/check-ported-elements.mjs index 0330af2..62df637 100644 --- a/libs/web-components/scripts/check-ported-elements.mjs +++ b/libs/web-components/scripts/check-ported-elements.mjs @@ -4,6 +4,34 @@ import { resolve } from 'node:path'; const packageRoot = resolve(import.meta.dirname, '..'); const componentsRoot = resolve(packageRoot, 'src/components'); +const repoRoot = resolve(packageRoot, '../..'); +const fullStoryParity = new Set([ + 'gd-avatar', + 'gd-box', + 'gd-badge', + 'gd-button', + 'gd-checkbox', + 'gd-image', + 'gd-icon', + 'gd-input', + 'gd-input-file', + 'gd-label', + 'gd-link', + 'gd-loader', + 'gd-separator', + 'gd-skeleton', + 'gd-slider', + 'gd-slider-dots', + 'gd-switch', + 'gd-textarea', + 'gd-toggle', + 'gd-truncate', + 'gd-wrapper', + 'gd-select', + 'gd-typography', + 'gd-counter', + 'gd-menu', +]); // This is the deliberately supported Web Component surface. It used to live in // `migration-ir`, which was removed along with the other one-off migration @@ -11,9 +39,26 @@ const componentsRoot = resolve(packageRoot, 'src/components'); // accidental missing port, public export, or SSR fixture. const ports = [ { tag: 'gd-avatar', category: 'atoms' }, + { tag: 'gd-badge', category: 'atoms' }, + { tag: 'gd-box', category: 'atoms' }, { tag: 'gd-button', category: 'atoms' }, { tag: 'gd-checkbox', category: 'atoms' }, { tag: 'gd-input', category: 'atoms' }, + { tag: 'gd-image', category: 'atoms' }, + { tag: 'gd-icon', category: 'atoms' }, + { tag: 'gd-input-file', category: 'atoms' }, + { tag: 'gd-label', category: 'atoms' }, + { tag: 'gd-link', category: 'atoms' }, + { tag: 'gd-loader', category: 'atoms' }, + { tag: 'gd-separator', category: 'atoms' }, + { tag: 'gd-skeleton', category: 'atoms' }, + { tag: 'gd-slider', category: 'atoms' }, + { tag: 'gd-slider-dots', category: 'atoms' }, + { tag: 'gd-switch', category: 'atoms' }, + { tag: 'gd-textarea', category: 'atoms' }, + { tag: 'gd-toggle', category: 'atoms' }, + { tag: 'gd-truncate', category: 'atoms' }, + { tag: 'gd-wrapper', category: 'atoms' }, { tag: 'gd-select', category: 'atoms' }, { tag: 'gd-typography', category: 'atoms' }, { tag: 'gd-counter', category: 'molecules' }, @@ -33,16 +78,31 @@ const missingFromManifest = [...actual].filter((entry) => !expected.has(entry)); const missingFromSource = [...expected].filter((entry) => !actual.has(entry)); if (missingFromManifest.length || missingFromSource.length) { throw new Error( - `Port manifest mismatch. Missing manifest: ${missingFromManifest.join(', ') || 'none'}; missing source: ${missingFromSource.join(', ') || 'none'}.` + `Port manifest mismatch. Missing manifest: ${missingFromManifest.join(', ') || 'none'}; missing source: ${ + missingFromSource.join(', ') || 'none' + }.` ); } -const [index, ssr, parityText] = await Promise.all([ +const [index, ssr, parityText, publishedAuditText] = await Promise.all([ readFile(resolve(packageRoot, 'src/index.ts'), 'utf8'), readFile(resolve(packageRoot, 'scripts/ssr-dsd-render.ts'), 'utf8'), readFile(resolve(packageRoot, 'react-parity.json'), 'utf8'), + readFile(resolve(packageRoot, 'published-atoms-audit.json'), 'utf8'), ]); const parity = JSON.parse(parityText); +const publishedAudit = JSON.parse(publishedAuditText); +const atomTags = ports.filter(({ category }) => category === 'atoms').map(({ tag }) => tag); +const publishedAtomTags = Object.keys(publishedAudit.components); +const missingPublishedAtoms = publishedAtomTags.filter((tag) => !atomTags.includes(tag)); +const unpublishedPorts = atomTags.filter((tag) => !publishedAtomTags.includes(tag)); +if (missingPublishedAtoms.length || unpublishedPorts.length) { + throw new Error( + `Published Atoms inventory mismatch. Missing ports: ${ + missingPublishedAtoms.join(', ') || 'none' + }; unverified ports: ${unpublishedPorts.join(', ') || 'none'}.` + ); +} for (const { tag, category } of ports) { const modulePath = `./components/${category}/${tag}/${tag}`; if (!index.includes(modulePath)) throw new Error(`Missing public export for ${tag}: ${modulePath}`); @@ -55,12 +115,35 @@ for (const { tag, category } of ports) { const source = await readFile(resolve(componentsRoot, category, tag, `${tag}.ts`), 'utf8'); const storyName = tag.slice(3).replace(/(^|-)([a-z])/g, (_match, _separator, letter) => letter.toUpperCase()); const stories = await readFile(resolve(packageRoot, 'stories', `${storyName}.stories.ts`), 'utf8'); + if (fullStoryParity.has(tag) && !stories.includes('description:')) + throw new Error(`${tag} Storybook docs are missing component/prop descriptions.`); for (const needle of contract.source) { if (!source.includes(needle)) throw new Error(`${tag} is missing React ${contract.react} API mapping: ${needle}`); } for (const story of contract.stories) { if (!stories.includes(`export const ${story}`)) throw new Error(`${tag} is missing parity story: ${story}`); } + if (fullStoryParity.has(tag)) { + const reactStories = await readFile( + resolve(repoRoot, 'libs/ui/src/components', category, contract.react, `${contract.react}.stories.tsx`), + 'utf8' + ); + const exported = [...reactStories.matchAll(/^export const (\w+)/gm)].map((match) => match[1]); + const missing = exported.filter((story) => !contract.stories.includes(story)); + const stale = contract.stories.filter((story) => !exported.includes(story)); + if (missing.length || stale.length) { + throw new Error( + `${tag} React Storybook inventory mismatch. Missing: ${missing.join(', ') || 'none'}; stale: ${ + stale.join(', ') || 'none' + }.` + ); + } + if (category === 'atoms' && publishedAudit.components[tag] !== contract.stories.length) { + throw new Error( + `${tag} published Storybook count mismatch: expected ${publishedAudit.components[tag]}, recorded ${contract.stories.length}.` + ); + } + } } console.log( diff --git a/libs/web-components/scripts/ssr-dsd-render.ts b/libs/web-components/scripts/ssr-dsd-render.ts index 6028d20..3169ef8 100644 --- a/libs/web-components/scripts/ssr-dsd-render.ts +++ b/libs/web-components/scripts/ssr-dsd-render.ts @@ -17,9 +17,26 @@ import { resolve, dirname } from 'path'; import { fileURLToPath } from 'url'; import { defaultTheme } from 'gd-design-library/tokens'; import '../src/components/atoms/gd-avatar/gd-avatar'; +import '../src/components/atoms/gd-badge/gd-badge'; +import '../src/components/atoms/gd-box/gd-box'; import '../src/components/atoms/gd-button/gd-button'; import '../src/components/atoms/gd-checkbox/gd-checkbox'; import '../src/components/atoms/gd-input/gd-input'; +import '../src/components/atoms/gd-image/gd-image'; +import '../src/components/atoms/gd-icon/gd-icon'; +import '../src/components/atoms/gd-input-file/gd-input-file'; +import '../src/components/atoms/gd-label/gd-label'; +import '../src/components/atoms/gd-link/gd-link'; +import '../src/components/atoms/gd-loader/gd-loader'; +import '../src/components/atoms/gd-separator/gd-separator'; +import '../src/components/atoms/gd-skeleton/gd-skeleton'; +import '../src/components/atoms/gd-slider/gd-slider'; +import '../src/components/atoms/gd-slider-dots/gd-slider-dots'; +import '../src/components/atoms/gd-switch/gd-switch'; +import '../src/components/atoms/gd-textarea/gd-textarea'; +import '../src/components/atoms/gd-toggle/gd-toggle'; +import '../src/components/atoms/gd-truncate/gd-truncate'; +import '../src/components/atoms/gd-wrapper/gd-wrapper'; import '../src/components/atoms/gd-select/gd-select'; import '../src/components/atoms/gd-typography/gd-typography'; import '../src/components/molecules/gd-menu/gd-menu'; @@ -38,9 +55,26 @@ export async function runSsrDsdCheck() { // (`fidelity-check.ts`, `form-participation-check.ts`). const template = html` + Status + Box content Submit Accept terms + No image + + Upload + Name + Docs + + + + + + Notifications + + + Long content + Wrapped content Heading [tag, new RegExp(`<${tag}[^>]*>\\s*