diff --git a/CLAUDE.md b/CLAUDE.md
index 79dd69b..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. 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 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 e464aaf..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 the Phase 1 overview; Phase 2 adds stories 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.
@@ -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,10 @@ 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 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/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..6a58332
--- /dev/null
+++ b/bin/storybook/native-checks.mjs
@@ -0,0 +1,221 @@
+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) && !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`);
+ 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;
+ }, 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 tokenStories = Object.values(index.entries).filter(
+ (entry) => entry.type === 'story' && entry.id.endsWith('--default-tokens')
+ );
+ for (const story of tokenStories) {
+ await page.goto(`${base}/web-components/iframe.html?id=${story.id}&viewMode=story`);
+ const viewer = page.getByRole('region', { name: 'Token viewer' });
+ await viewer.waitFor();
+ const text = await viewer.textContent();
+ assert.ok(text.length > 50, `${story.id} should render its theme token values`);
+ assert.doesNotMatch(text, /^defaultTheme\./, `${story.id} should not render a token placeholder`);
+ const branch = viewer.getByRole('button').first();
+ await branch.click();
+ assert.equal(await branch.getAttribute('aria-expanded'), 'false');
+ }
+ assert.equal(tokenStories.length, 24, 'Every React token story port should render Web Component tokens');
+ 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('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 });
+ 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 representative interactions for every interactive atom and molecule.`
+ );
+}
diff --git a/bin/storybook/smoke-test.mjs b/bin/storybook/smoke-test.mjs
index 0591924..eac2bde 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));
@@ -97,12 +98,18 @@ try {
// Composition must load the child's own native renderer, not a React wrapper.
await page.goto(`${base}/?path=/story/web-components_introduction-overview--overview`);
const nativeFrame = page.frameLocator('#storybook-ref-web-components');
- await nativeFrame.getByRole('heading', { name: 'GridKit Web Components', exact: true }).waitFor();
+ await nativeFrame.getByRole('heading', { name: 'Using 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/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/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/form-configurator/adapter-react/tsconfig.lib.json b/libs/form-configurator/adapter-react/tsconfig.lib.json
index b9d6f7e..56a5ac5 100644
--- a/libs/form-configurator/adapter-react/tsconfig.lib.json
+++ b/libs/form-configurator/adapter-react/tsconfig.lib.json
@@ -11,6 +11,8 @@
],
"baseUrl": ".",
"paths": {
+ "gd-design-core": ["../../design-core/src/index.ts"],
+ "gd-design-core/*": ["../../design-core/src/*"],
"gd-form-configurator": ["../core/src/index.ts"],
"@form-configurator/adapter-react": ["./src/index.ts"],
"@griddynamics/ui": ["../../../libs/ui/src/index.ts"],
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/project.json b/libs/ui/project.json
index 96d0479..8607982 100644
--- a/libs/ui/project.json
+++ b/libs/ui/project.json
@@ -6,6 +6,13 @@
"tags": [],
"// targets": "to see all targets run: nx show project ui --web",
"targets": {
+ "typecheck": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "tsc -b libs/design-core/tsconfig.lib.json && tsc --noEmit -p libs/ui/tsconfig.lib.json",
+ "cwd": "{workspaceRoot}"
+ }
+ },
"build-storybook": {
"inputs": ["default", "^production", "{workspaceRoot}/bin/storybook/**/*"]
},
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/components/layout/ChatContainer/__snapshots__/ChatContainer.test.tsx.snap b/libs/ui/src/components/layout/ChatContainer/__snapshots__/ChatContainer.test.tsx.snap
index 6de555c..f4b18cd 100644
--- a/libs/ui/src/components/layout/ChatContainer/__snapshots__/ChatContainer.test.tsx.snap
+++ b/libs/ui/src/components/layout/ChatContainer/__snapshots__/ChatContainer.test.tsx.snap
@@ -36,13 +36,14 @@ exports[`ChatContainer > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowRight"
fill="none"
height="24"
+ style="--gd-icon-fill: #000000; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -89,13 +90,14 @@ exports[`ChatContainer > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowRight"
fill="none"
height="24"
+ style="--gd-icon-fill: #000000; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/molecules/AttachmentFile/__snapshots__/AttachmentFile.test.tsx.snap b/libs/ui/src/components/molecules/AttachmentFile/__snapshots__/AttachmentFile.test.tsx.snap
index 149819a..e5b3725 100644
--- a/libs/ui/src/components/molecules/AttachmentFile/__snapshots__/AttachmentFile.test.tsx.snap
+++ b/libs/ui/src/components/molecules/AttachmentFile/__snapshots__/AttachmentFile.test.tsx.snap
@@ -11,13 +11,14 @@ exports[`AttachmentFile > SHOULD match snapshot with all props 1`] = `
data-testid="Icon-fileCopy"
fill="#474747"
height="32"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: #474747;"
viewBox="0 0 24 24"
width="32"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot with all props 1`] = `
data-testid="Icon-cross"
fill="none"
height="18"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
@@ -98,13 +100,14 @@ exports[`AttachmentFile > SHOULD match snapshot without meta 1`] = `
data-testid="Icon-fileCopy"
fill="#474747"
height="32"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: #474747;"
viewBox="0 0 24 24"
width="32"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot without meta 1`] = `
data-testid="Icon-cross"
fill="none"
height="18"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
@@ -165,13 +169,14 @@ exports[`AttachmentFile > SHOULD match snapshot without remove button 1`] = `
data-testid="Icon-fileCopy"
fill="#474747"
height="32"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: #474747;"
viewBox="0 0 24 24"
width="32"
xmlns="http://www.w3.org/2000/svg"
>
isLoading > SHOULD match snapshot when isLoading 1`] =
data-testid="Icon-fileCopy"
fill="#474747"
height="32"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: #474747;"
viewBox="0 0 24 24"
width="32"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-minus"
fill="none"
height="14"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
@@ -80,13 +81,14 @@ exports[`Counter > SHOULD match snapshot 1`] = `
data-testid="Icon-plus"
fill="none"
height="14"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/molecules/InlineNotification/__snapshots__/InlineNotification.test.tsx.snap b/libs/ui/src/components/molecules/InlineNotification/__snapshots__/InlineNotification.test.tsx.snap
index fbb07ab..0834cd8 100644
--- a/libs/ui/src/components/molecules/InlineNotification/__snapshots__/InlineNotification.test.tsx.snap
+++ b/libs/ui/src/components/molecules/InlineNotification/__snapshots__/InlineNotification.test.tsx.snap
@@ -31,13 +31,14 @@ exports[`InlineNotification > SHOULD match error snapshot 1`] = `
data-testid="Icon-error"
fill="none"
height="18"
+ style="--gd-icon-fill: #D21C1C; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match error snapshot 1`] = `
/>
SHOULD match info snapshot 1`] = `
data-testid="Icon-info"
fill="none"
height="18"
+ style="--gd-icon-fill: #0069B4; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match success snapshot 1`] = `
data-testid="Icon-success"
fill="none"
height="18"
+ style="--gd-icon-fill: #34A853; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match success snapshot 1`] = `
/>
SHOULD match warning snapshot 1`] = `
data-testid="Icon-warning"
fill="none"
height="18"
+ style="--gd-icon-fill: #FF8700; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="24"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -52,13 +53,14 @@ exports[`Rating > SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="24"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -81,13 +83,14 @@ exports[`Rating > SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="24"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -109,13 +112,14 @@ exports[`Rating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="24"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -137,13 +141,14 @@ exports[`Rating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="24"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/molecules/Snackbar/__snapshots__/Snackbar.test.tsx.snap b/libs/ui/src/components/molecules/Snackbar/__snapshots__/Snackbar.test.tsx.snap
index 958e92a..0421329 100644
--- a/libs/ui/src/components/molecules/Snackbar/__snapshots__/Snackbar.test.tsx.snap
+++ b/libs/ui/src/components/molecules/Snackbar/__snapshots__/Snackbar.test.tsx.snap
@@ -19,13 +19,14 @@ exports[`Snackbar > SHOULD render the snackbar with correct message and title 1`
data-testid="Icon-cross"
fill="none"
height="10"
+ style="--gd-icon-fill: #0069B4; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="10"
xmlns="http://www.w3.org/2000/svg"
>
@@ -37,13 +38,14 @@ exports[`Snackbar > SHOULD render the snackbar with correct message and title 1`
data-testid="Icon-info"
fill="none"
height="20"
+ style="--gd-icon-fill: #0069B4; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-arrowLeft"
fill="none"
height="16"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
@@ -147,13 +148,14 @@ exports[`TablePagination > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowRight"
fill="none"
height="16"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/Card/CardCounter/__snapshots__/CardCounter.test.tsx.snap b/libs/ui/src/components/organisms/Card/CardCounter/__snapshots__/CardCounter.test.tsx.snap
index 5946cbf..34bedc0 100644
--- a/libs/ui/src/components/organisms/Card/CardCounter/__snapshots__/CardCounter.test.tsx.snap
+++ b/libs/ui/src/components/organisms/Card/CardCounter/__snapshots__/CardCounter.test.tsx.snap
@@ -28,13 +28,14 @@ exports[`CardCounter > SHOULD match snapshot 1`] = `
data-testid="Icon-minus"
fill="none"
height="14"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
@@ -83,13 +84,14 @@ exports[`CardCounter > SHOULD match snapshot 1`] = `
data-testid="Icon-plus"
fill="none"
height="14"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="14"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/Card/CardRating/__snapshots__/CardRating.test.tsx.snap b/libs/ui/src/components/organisms/Card/CardRating/__snapshots__/CardRating.test.tsx.snap
index 6e17858..24cfe0f 100644
--- a/libs/ui/src/components/organisms/Card/CardRating/__snapshots__/CardRating.test.tsx.snap
+++ b/libs/ui/src/components/organisms/Card/CardRating/__snapshots__/CardRating.test.tsx.snap
@@ -19,13 +19,14 @@ exports[`CardRating > SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="12"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="12"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="12"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="12"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
SHOULD match snapshot 1`] = `
data-testid="Icon-star"
fill="none"
height="12"
+ style="--gd-icon-fill: #FFB800; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
@@ -104,13 +109,14 @@ exports[`CardRating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="12"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
@@ -133,13 +139,14 @@ exports[`CardRating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="12"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
@@ -162,13 +169,14 @@ exports[`CardRating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="12"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
@@ -191,13 +199,14 @@ exports[`CardRating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="12"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
@@ -220,13 +229,14 @@ exports[`CardRating > SHOULD match snapshot 1`] = `
data-testid="Icon-starOutlined"
fill="none"
height="12"
+ style="--gd-icon-fill: #E5E5E5; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="12"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/Carousel/__snapshots__/Carousel.test.tsx.snap b/libs/ui/src/components/organisms/Carousel/__snapshots__/Carousel.test.tsx.snap
index 59656e6..c8005cf 100644
--- a/libs/ui/src/components/organisms/Carousel/__snapshots__/Carousel.test.tsx.snap
+++ b/libs/ui/src/components/organisms/Carousel/__snapshots__/Carousel.test.tsx.snap
@@ -32,13 +32,14 @@ exports[`Carousel > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowLeft"
fill="none"
height="24"
+ style="--gd-icon-fill: #8F8F8F; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -59,13 +60,14 @@ exports[`Carousel > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowLeft"
fill="none"
height="24"
+ style="--gd-icon-fill: #474747; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -133,13 +135,14 @@ exports[`Carousel > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowLeft"
fill="none"
height="24"
+ style="--gd-icon-fill: #8F8F8F; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
@@ -202,13 +205,14 @@ exports[`Carousel > SHOULD match snapshot 1`] = `
data-testid="Icon-arrowRight"
fill="none"
height="24"
+ style="--gd-icon-fill: #474747; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/Header/__snapshots__/Header.test.tsx.snap b/libs/ui/src/components/organisms/Header/__snapshots__/Header.test.tsx.snap
index 8291d73..29ebc05 100644
--- a/libs/ui/src/components/organisms/Header/__snapshots__/Header.test.tsx.snap
+++ b/libs/ui/src/components/organisms/Header/__snapshots__/Header.test.tsx.snap
@@ -26,13 +26,14 @@ exports[`Header > SHOULD match snapshot 1`] = `
data-testid="Icon-mobileMenu"
fill="none"
height="40"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="40"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/Modal/__snapshots__/Modal.test.tsx.snap b/libs/ui/src/components/organisms/Modal/__snapshots__/Modal.test.tsx.snap
index 3eefe39..a97e852 100644
--- a/libs/ui/src/components/organisms/Modal/__snapshots__/Modal.test.tsx.snap
+++ b/libs/ui/src/components/organisms/Modal/__snapshots__/Modal.test.tsx.snap
@@ -30,13 +30,14 @@ exports[`Modal > SHOULD match snapshot & render correctly when isOpen is true 1`
data-testid="Icon-cross"
fill="none"
height="24"
+ style="--gd-icon-fill: #000000; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/SearchModal/SearchInput/__snapshots__/SearchInput.test.tsx.snap b/libs/ui/src/components/organisms/SearchModal/SearchInput/__snapshots__/SearchInput.test.tsx.snap
index 053c939..f807703 100644
--- a/libs/ui/src/components/organisms/SearchModal/SearchInput/__snapshots__/SearchInput.test.tsx.snap
+++ b/libs/ui/src/components/organisms/SearchModal/SearchInput/__snapshots__/SearchInput.test.tsx.snap
@@ -36,13 +36,14 @@ exports[`SearchInput > SHOULD match snapshot 1`] = `
data-testid="Icon-cross"
fill="none"
height="18"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="18"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/organisms/SearchModal/SearchItems/__snapshots__/SearchItems.test.tsx.snap b/libs/ui/src/components/organisms/SearchModal/SearchItems/__snapshots__/SearchItems.test.tsx.snap
index 413a775..241f4ca 100644
--- a/libs/ui/src/components/organisms/SearchModal/SearchItems/__snapshots__/SearchItems.test.tsx.snap
+++ b/libs/ui/src/components/organisms/SearchModal/SearchItems/__snapshots__/SearchItems.test.tsx.snap
@@ -15,13 +15,14 @@ exports[`SearchItems > SHOULD match snapshot 1`] = `
data-testid="Icon-search"
fill="none"
height="16"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
diff --git a/libs/ui/src/components/widget/DragAndDrop/__snapshots__/DragAndDrop.test.tsx.snap b/libs/ui/src/components/widget/DragAndDrop/__snapshots__/DragAndDrop.test.tsx.snap
index 67834ff..647b34e 100644
--- a/libs/ui/src/components/widget/DragAndDrop/__snapshots__/DragAndDrop.test.tsx.snap
+++ b/libs/ui/src/components/widget/DragAndDrop/__snapshots__/DragAndDrop.test.tsx.snap
@@ -23,13 +23,14 @@ exports[`DragAndDrop > SHOULD match snapshot 1`] = `
data-testid="Icon-upload"
fill="none"
height="24"
+ style="--gd-icon-fill: currentColor; --gd-icon-fill-svg: none;"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
+ 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/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 3cf68d0..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,10 +99,54 @@ 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
-workflow below remains available until the later integration-check cleanup.
+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 |
+| [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
+
+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 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 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
+
+
+
+
+ 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
+ Notifications Enabled Saving
+
+ 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
new file mode 100644
index 0000000..0ca7a12
--- /dev/null
+++ b/libs/web-components/react-parity.json
@@ -0,0 +1,526 @@
+{
+ "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": [
+ "src",
+ "alt",
+ "sizeVariant",
+ "withBadge",
+ "badgeColor",
+ "backgroundColor",
+ "styles",
+ "slot name=\"fallback\""
+ ],
+ "stories": [
+ "Default",
+ "WithBadge",
+ "WithCustomBadgeColor",
+ "WithInitials",
+ "WithCustomBackgroundColor",
+ "WithIcon",
+ "WithImageAndFallback",
+ "WithDifferentSize",
+ "WithAccessibility",
+ "DefaultTokens"
+ ]
+ },
+ "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": [
+ "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", "Controlled", "Indeterminate", "Disabled", "Sizes", "DefaultTokens"]
+ },
+ "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": [
+ "PrimaryDefaultWithLabelAndHelperText",
+ "SuccessWithLabelAndHelperText",
+ "WarningWithLabelAndHelperText",
+ "ErrorWithLabelAndHelperText",
+ "Disabled",
+ "ReadOnly",
+ "WrapperAsSpan",
+ "WithAdornments",
+ "WithStartAdornment",
+ "WithEndAdornmentAsIcon",
+ "DefaultWithTailwind",
+ "CustomStyles",
+ "CheckboxWithLabel",
+ "RadioControlledWithLabel",
+ "RadioGroupWithLabel",
+ "WithAccessibility",
+ "DefaultTokens"
+ ]
+ },
+ "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",
+ "MultipleSelect",
+ "WithAdornmentsAndResetOptionStory",
+ "WithCustomInitiatorAndSelectedOutputValue",
+ "UsingRefComponentWithCustomPlaceholder",
+ "WithFileInputAndCustomDropdown",
+ "CustomItemIdentifierComponent",
+ "WithAccessibility",
+ "Disabled",
+ "EmptyItems",
+ "ColorPrimary",
+ "ColorSuccess",
+ "ColorWarning",
+ "ColorError",
+ "Searchable",
+ "DefaultTokens"
+ ]
+ },
+ "gd-typography": {
+ "react": "Typography",
+ "source": ["variant", "as", "color", "size", "align", "styleVariant", "styles", "slot"],
+ "stories": [
+ "Default",
+ "AsCustomComponent",
+ "AsCustomHtmlTag",
+ "CombinedStyleVariant",
+ "Disclaimers",
+ "Display",
+ "Heading",
+ "Body",
+ "WithAccessibility",
+ "DefaultTokens"
+ ]
+ },
+ "gd-counter": {
+ "react": "Counter",
+ "source": ["min", "max", "initial", "isDisabled", "styles", "gd-change"],
+ "stories": [
+ "Default",
+ "AdjustedMaxValue5",
+ "AdjustedMinValue3",
+ "AdjustedMin2MaxValue10",
+ "WithExternalCounterChangeHandler",
+ "Disabled",
+ "DefaultTokens"
+ ]
+ },
+ "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",
+ "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 8a8c95a..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,18 +78,74 @@ 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] = 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}`);
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');
+ 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(
+ `Verified ${ports.length} Web Component ports and React parity contracts: ${ports.map(({ tag }) => tag).join(', ')}`
+);
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*`).test(out)]
- )
+ [
+ 'gd-avatar',
+ 'gd-badge',
+ 'gd-box',
+ 'gd-button',
+ 'gd-checkbox',
+ 'gd-counter',
+ '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-menu',
+ 'gd-select',
+ 'gd-typography',
+ ].map((tag) => [tag, new RegExp(`<${tag}[^>]*>\\s*`).test(out)])
);
const staticHtmlPath = resolve(__dirname, '../harness/ssr-dsd-static.html');
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..d9a759f 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,33 @@ 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-badge/gd-badge.spec.ts b/libs/web-components/src/components/atoms/gd-badge/gd-badge.spec.ts
new file mode 100644
index 0000000..b824fed
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-badge/gd-badge.spec.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-badge';
+
+describe('gd-badge', () => {
+ it('resolves variant, appearance, size, disabled state, and icon slots from Badge tokens', async () => {
+ const element = document.createElement('gd-badge');
+ element.theme = defaultTheme;
+ element.variant = 'secondary';
+ element.appearance = 'outline';
+ element.size = 'lg';
+ element.disabled = true;
+ element.innerHTML = '! Warning';
+ document.body.append(element);
+ await element.updateComplete;
+ const root = element.shadowRoot!.querySelector('[part="root"]')!;
+ expect(root.style.height).toBe('38px');
+ expect(root.style.border).not.toBe('');
+ expect(root.style.opacity).toBe('0.7');
+ expect(root.getAttribute('aria-disabled')).toBe('true');
+ expect(element.querySelector('[slot="icon-start"]')?.textContent).toBe('!');
+ element.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-badge/gd-badge.ts b/libs/web-components/src/components/atoms/gd-badge/gd-badge.ts
new file mode 100644
index 0000000..7234aaf
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-badge/gd-badge.ts
@@ -0,0 +1,55 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { styleMap } from 'lit/directives/style-map.js';
+import { badge } from 'gd-design-library/tokens';
+import { resolveThemeTree, type DesignCoreTheme } from 'gd-design-core';
+
+export type BadgeVariant = 'primary' | 'secondary' | 'tertiary' | 'quaternary' | 'quinary';
+export type BadgeAppearance = 'outline' | 'outlineFilledLight' | 'filled' | 'filledLight';
+export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg';
+type CssValues = Record;
+interface ResolvedBadgeTokens {
+ default: CssValues;
+ size: Record;
+ content: { default: CssValues };
+ startIcon: { default: CssValues };
+ endIcon: { default: CssValues };
+ primary: Record;
+ secondary: Record;
+ tertiary: Record;
+ quaternary: Record;
+ quinary: Record;
+}
+
+@customElement('gd-badge')
+export class GdBadge extends LitElement {
+ @property({ type: String, reflect: true }) variant: BadgeVariant = 'primary';
+ @property({ type: String, reflect: true }) appearance: BadgeAppearance = 'filled';
+ @property({ type: String, reflect: true }) size: BadgeSize = 'md';
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ attribute: false }) styles: CssValues = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ render() {
+ const tokens = resolveThemeTree(badge, this.theme) as unknown as ResolvedBadgeTokens;
+ const appearance = tokens[this.variant][this.appearance];
+ const root = {
+ ...tokens.default,
+ ...appearance.default,
+ ...(this.disabled ? appearance.disabled : {}),
+ ...tokens.size?.[this.size],
+ ...this.styles,
+ };
+ return html`
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-badge': GdBadge;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-box/gd-box.spec.ts b/libs/web-components/src/components/atoms/gd-box/gd-box.spec.ts
new file mode 100644
index 0000000..64815fb
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-box/gd-box.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-box';
+
+describe('gd-box', () => {
+ it('renders slotted content with the selected layout and public part', async () => {
+ const element = document.createElement('gd-box');
+ element.theme = defaultTheme;
+ element.variant = 'horizontal';
+ element.isBordered = true;
+ element.textContent = 'Content';
+ document.body.append(element);
+ await element.updateComplete;
+ const root = element.shadowRoot!.querySelector('[part="root"]')!;
+ expect(getComputedStyle(root).display).toBe('flex');
+ expect(getComputedStyle(root).flexDirection).toBe('row');
+ expect(getComputedStyle(root).borderStyle).not.toBe('');
+ expect(element.textContent).toBe('Content');
+ element.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-box/gd-box.ts b/libs/web-components/src/components/atoms/gd-box/gd-box.ts
new file mode 100644
index 0000000..52dd7f2
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-box/gd-box.ts
@@ -0,0 +1,50 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { box } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type DesignCoreTheme } from 'gd-design-core';
+
+export type BoxVariant = 'horizontal' | 'vertical';
+type CssValues = Record;
+interface ResolvedBoxTokens {
+ default: CssValues;
+ vertical: { default: CssValues; bordered: CssValues; highlighted: CssValues };
+ horizontal: { default: CssValues; bordered: CssValues; highlighted: CssValues };
+ shadowHover: CssValues;
+}
+
+@customElement('gd-box')
+export class GdBox extends LitElement {
+ @property({ type: String, reflect: true }) variant: BoxVariant = 'vertical';
+ @property({ type: Boolean, attribute: 'is-bordered', reflect: true }) isBordered = false;
+ @property({ type: Boolean, attribute: 'is-highlighted', reflect: true }) isHighlighted = false;
+ @property({ type: Boolean, attribute: 'with-shadow-hover', reflect: true }) withShadowHover = false;
+ @property({ type: Number, attribute: 'tabindex', reflect: true }) override tabIndex = -1;
+ @property({ attribute: false }) styles: CssValues = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ render() {
+ const tokens = resolveThemeTree(box, this.theme) as unknown as ResolvedBoxTokens;
+ const variant = tokens[this.variant];
+ const cssText = [
+ buttonCssBlockToText('[part="root"]', tokens.default),
+ buttonCssBlockToText('[part="root"]', variant.default),
+ this.isBordered ? buttonCssBlockToText('[part="root"]', variant.bordered) : '',
+ this.isHighlighted ? buttonCssBlockToText('[part="root"]', variant.highlighted) : '',
+ this.withShadowHover ? buttonCssBlockToText('[part="root"]', tokens.shadowHover) : '',
+ buttonCssBlockToText('[part="root"]', this.styles),
+ ].join('\n');
+ const root = this.shadowRoot;
+ if (root && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ root.adoptedStyleSheets = [sheet];
+ }
+ return html`
`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-box': GdBox;
+ }
+}
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-icon/gd-icon.spec.ts b/libs/web-components/src/components/atoms/gd-icon/gd-icon.spec.ts
new file mode 100644
index 0000000..7522ca0
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-icon/gd-icon.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { iconCatalog } from 'gd-design-core';
+import './gd-icon';
+
+describe('gd-icon', () => {
+ it('renders every shared catalog icon', async () => {
+ for (const name of Object.keys(iconCatalog)) {
+ const el = document.createElement('gd-icon');
+ el.name = name as keyof typeof iconCatalog;
+ document.body.append(el);
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelectorAll('svg').length).toBe(1);
+ el.remove();
+ }
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-icon/gd-icon.ts b/libs/web-components/src/components/atoms/gd-icon/gd-icon.ts
new file mode 100644
index 0000000..7428b7b
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-icon/gd-icon.ts
@@ -0,0 +1,46 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { styleMap } from 'lit/directives/style-map.js';
+import { unsafeSVG } from 'lit/directives/unsafe-svg.js';
+import { icon } from 'gd-design-library/tokens';
+import { get, iconCatalog, resolveThemeTree, type DesignCoreTheme, type GridKitIconName } from 'gd-design-core';
+
+type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
+
+@customElement('gd-icon')
+export class GdIcon extends LitElement {
+ @property({ type: String, reflect: true }) name: GridKitIconName = 'star';
+ @property({ type: Number, reflect: true }) width = 18;
+ @property({ type: Number, reflect: true }) height = 18;
+ @property({ type: String, reflect: true }) size?: IconSize;
+ @property({ type: String, reflect: true }) fill = 'currentColor';
+ @property({ type: String, attribute: 'fill-svg', reflect: true }) fillSvg = 'none';
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ render() {
+ const definition = iconCatalog[this.name];
+ if (!definition) return html``;
+ const tokens = resolveThemeTree(icon, this.theme) as { size: Record };
+ const dimensions = this.size ? tokens.size[this.size] : { width: this.width, height: this.height };
+ const fill = get(this.theme, `colors.${this.fill}`, this.fill) as string;
+ const fillSvg = get(this.theme, `colors.${this.fillSvg}`, this.fillSvg) as string;
+ return html`
+ ${unsafeSVG(definition.body)}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-icon': GdIcon;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-image/gd-image.spec.ts b/libs/web-components/src/components/atoms/gd-image/gd-image.spec.ts
new file mode 100644
index 0000000..7842984
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-image/gd-image.spec.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-image';
+
+describe('gd-image', () => {
+ it('renders caption and switches to slotted fallback after an image error', async () => {
+ const element = document.createElement('gd-image');
+ element.theme = defaultTheme;
+ element.src = '/missing.png';
+ element.alt = 'Missing portrait';
+ element.caption = 'Portrait';
+ element.innerHTML = 'Unavailable ';
+ const onError = vi.fn();
+ element.addEventListener('gd-error', onError);
+ document.body.append(element);
+ await element.updateComplete;
+ const image = element.shadowRoot!.querySelector('img')!;
+ image.dispatchEvent(new Event('error'));
+ await element.updateComplete;
+ expect(onError).toHaveBeenCalledOnce();
+ expect(element.shadowRoot!.querySelector('img')).toBeNull();
+ expect(element.shadowRoot!.querySelector('slot[name="fallback"]')).not.toBeNull();
+ expect(element.shadowRoot!.querySelector('figcaption')?.textContent).toBe('Portrait');
+ element.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-image/gd-image.ts b/libs/web-components/src/components/atoms/gd-image/gd-image.ts
new file mode 100644
index 0000000..c3a5bed
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-image/gd-image.ts
@@ -0,0 +1,84 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { styleMap } from 'lit/directives/style-map.js';
+import { image } from 'gd-design-library/tokens';
+import { resolveThemeTree, type DesignCoreTheme } from 'gd-design-core';
+
+export type ImageObjectFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
+type CssValues = Record;
+
+@customElement('gd-image')
+export class GdImage extends LitElement {
+ @property({ type: String, reflect: true }) src?: string;
+ @property({ type: String, reflect: true }) alt = '';
+ @property({ type: Number, reflect: true }) width?: number;
+ @property({ type: Number, reflect: true }) height?: number;
+ @property({ type: String, reflect: true }) caption?: string;
+ @property({ type: String, attribute: 'object-fit', reflect: true }) objectFit: ImageObjectFit = 'cover';
+ @property({ attribute: false }) styles: CssValues = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ @state() private _loading = true;
+ @state() private _failed = false;
+
+ protected willUpdate(changed: Map) {
+ if (changed.has('src')) {
+ this._loading = true;
+ this._failed = false;
+ }
+ }
+
+ private _loaded() {
+ this._loading = false;
+ this._failed = false;
+ this.dispatchEvent(new CustomEvent('gd-load', { bubbles: true, composed: true }));
+ }
+
+ private _errored() {
+ this._loading = false;
+ this._failed = true;
+ this.dispatchEvent(new CustomEvent('gd-error', { bubbles: true, composed: true }));
+ }
+
+ render() {
+ const tokens = resolveThemeTree(image, this.theme) as unknown as Record;
+ const wrapper = {
+ ...tokens.wrapper,
+ width: this.width ? `${this.width}px` : '100%',
+ height: this.height ? `${this.height}px` : '100%',
+ };
+ const imageStyles = {
+ ...tokens.default,
+ objectFit: this.objectFit,
+ opacity: this._loading ? 0 : 1,
+ ...this.styles,
+ };
+ const showFallback = !this.src || this._failed;
+ return html`
+ ${this._loading && this.src
+ ? html` `
+ : nothing}
+ ${showFallback
+ ? html` `
+ : html` `}
+ ${this.caption
+ ? html`${this.caption} `
+ : nothing}
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-image': GdImage;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-input-file/gd-input-file.spec.ts b/libs/web-components/src/components/atoms/gd-input-file/gd-input-file.spec.ts
new file mode 100644
index 0000000..1e4c75a
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-input-file/gd-input-file.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it, vi } from 'vitest';
+import './gd-input-file';
+
+describe('gd-input-file', () => {
+ it('maps selected files to a bubbling composed gd-change event', async () => {
+ const el = document.createElement('gd-input-file');
+ document.body.append(el);
+ await el.updateComplete;
+ Object.defineProperty(el.input, 'files', { value: [{ name: 'a.txt', size: 4, type: 'text/plain' }] });
+ const listener = vi.fn();
+ el.addEventListener('gd-change', listener);
+ el.input.dispatchEvent(new Event('change'));
+ expect(listener.mock.calls[0][0].detail).toEqual({ files: [{ name: 'a.txt', size: 4, type: 'text/plain' }] });
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-input-file/gd-input-file.ts b/libs/web-components/src/components/atoms/gd-input-file/gd-input-file.ts
new file mode 100644
index 0000000..72b4bc7
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-input-file/gd-input-file.ts
@@ -0,0 +1,64 @@
+import { LitElement, html } from 'lit';
+import { customElement, property, query } from 'lit/decorators.js';
+import { styleMap } from 'lit/directives/style-map.js';
+import { inputfile } from 'gd-design-library/tokens';
+import { resolveThemeTree, type DesignCoreTheme } from 'gd-design-core';
+import '../gd-button/gd-button';
+
+export interface GdInputFileChangeDetail {
+ files: Array<{ name: string; size: number; type: string }>;
+}
+
+@customElement('gd-input-file')
+export class GdInputFile extends LitElement {
+ @property({ type: String, reflect: true }) accept?: string;
+ @property({ type: String, reflect: true }) capture?: 'user' | 'environment';
+ @property({ type: Boolean, reflect: true }) multiple = false;
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ type: Boolean, attribute: 'is-icon', reflect: true }) isIcon = false;
+ @property({ type: String, attribute: 'button-variant' }) buttonVariant = 'outlined';
+ @property({ attribute: false }) styles: Record = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ @query('input') input!: HTMLInputElement;
+
+ private _change() {
+ const files = Array.from(this.input.files ?? []).map(({ name, size, type }) => ({ name, size, type }));
+ this.dispatchEvent(
+ new CustomEvent('gd-change', { detail: { files }, bubbles: true, composed: true })
+ );
+ }
+ render() {
+ const tokens = resolveThemeTree(inputfile, this.theme) as unknown as {
+ default: Record;
+ input: Record;
+ };
+ return html`
+ this.input?.click()}
+ >Browse Files
+
+
`;
+ }
+ open() {
+ if (!this.disabled) this.input.click();
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-input-file': GdInputFile;
+ }
+}
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`
${this.label} `
+ ? html`
${this.label} `
: nothing}
{
+ it('associates its native label with a light-DOM control', async () => {
+ const input = document.createElement('input');
+ input.id = 'field';
+ document.body.append(input);
+ const el = document.createElement('gd-label');
+ el.htmlFor = 'field';
+ el.textContent = 'Name';
+ document.body.append(el);
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelector('label')!.htmlFor).toBe('field');
+ el.remove();
+ input.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-label/gd-label.ts b/libs/web-components/src/components/atoms/gd-label/gd-label.ts
new file mode 100644
index 0000000..18441d0
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-label/gd-label.ts
@@ -0,0 +1,23 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { styleMap } from 'lit/directives/style-map.js';
+import { label } from 'gd-design-library/tokens';
+import { resolveThemeTree, type DesignCoreTheme } from 'gd-design-core';
+
+@customElement('gd-label')
+export class GdLabel extends LitElement {
+ @property({ type: String, attribute: 'for', reflect: true }) htmlFor?: string;
+ @property({ attribute: false }) styles: Record
= {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ render() {
+ const tokens = resolveThemeTree(label, this.theme) as { default: Record };
+ return html` `;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-label': GdLabel;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-link/gd-link.spec.ts b/libs/web-components/src/components/atoms/gd-link/gd-link.spec.ts
new file mode 100644
index 0000000..306c873
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-link/gd-link.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import './gd-link';
+
+describe('gd-link', () => {
+ it('matches the React default by omitting target until one is provided', async () => {
+ const el = document.createElement('gd-link');
+ el.href = '/target';
+ document.body.append(el);
+ await el.updateComplete;
+ const link = el.shadowRoot!.querySelector('a')!;
+ expect(link.hasAttribute('target')).toBe(false);
+ el.target = '_blank';
+ await el.updateComplete;
+ expect(link.getAttribute('target')).toBe('_blank');
+ el.remove();
+ });
+
+ it('removes href and exposes disabled semantics when disabled', async () => {
+ const el = document.createElement('gd-link');
+ el.href = '/target';
+ el.disabled = true;
+ document.body.append(el);
+ await el.updateComplete;
+ const link = el.shadowRoot!.querySelector('a')!;
+ expect(link.hasAttribute('href')).toBe(false);
+ expect(link.getAttribute('aria-disabled')).toBe('true');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-link/gd-link.ts b/libs/web-components/src/components/atoms/gd-link/gd-link.ts
new file mode 100644
index 0000000..3abcf1d
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-link/gd-link.ts
@@ -0,0 +1,66 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { link } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, get, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+type LinkVariant = 'primary' | 'secondary' | 'inverted' | 'inherit';
+type LinkSize = 'sm' | 'md' | 'lg';
+type LinkUnderline = 'default' | 'highlight' | 'none';
+type CssBlock = ButtonCssBlock;
+
+@customElement('gd-link')
+export class GdLink extends LitElement {
+ @property({ type: String, reflect: true }) variant: LinkVariant = 'primary';
+ @property({ type: String, reflect: true }) size?: LinkSize;
+ @property({ type: String, reflect: true }) underline: LinkUnderline = 'default';
+ @property({ type: String, reflect: true }) color?: string;
+ @property({ type: String, reflect: true }) cursor = 'pointer';
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ type: String, reflect: true }) target?: string;
+ @property({ type: String, reflect: true }) href?: string;
+ @property({ type: String, reflect: true }) rel?: string;
+ @property({ attribute: false }) styles: CssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ render() {
+ const tokens = resolveThemeTree(link, this.theme) as unknown as Record & {
+ sizeMap: Record;
+ underline: Record;
+ };
+ const color = this.color ? get(this.theme, `colors.${this.color}`, this.color) : undefined;
+ const cssText =
+ [
+ tokens.default,
+ tokens[this.variant],
+ this.size ? tokens.sizeMap[this.size] : {},
+ this.underline !== 'default' ? tokens.underline[this.underline] : {},
+ this.styles,
+ color ? { color } : {},
+ ]
+ .map((block) => buttonCssBlockToText('a', block))
+ .join('\n') + buttonCssBlockToText('a', { cursor: this.disabled ? 'default' : this.cursor });
+ const root = this.shadowRoot;
+ if (root && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ root.adoptedStyleSheets = [sheet];
+ }
+ return html` {
+ if (this.disabled) event.preventDefault();
+ }}
+ > `;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-link': GdLink;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-loader/gd-loader.spec.ts b/libs/web-components/src/components/atoms/gd-loader/gd-loader.spec.ts
new file mode 100644
index 0000000..03238ae
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-loader/gd-loader.spec.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import './gd-loader';
+
+describe('gd-loader', () => {
+ it('renders one circle or exactly three dots with status semantics', async () => {
+ const el = document.createElement('gd-loader');
+ document.body.append(el);
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelector('[part="wrapper"]')!.hasAttribute('popover')).toBe(false);
+ expect(el.shadowRoot!.querySelectorAll('.dot')).toHaveLength(0);
+ el.name = 'dots';
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelectorAll('.dot')).toHaveLength(3);
+ expect(el.shadowRoot!.querySelector('[role="status"]')).not.toBeNull();
+ el.variant = 'fullPage';
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelector('[part="wrapper"]')!.getAttribute('popover')).toBe('manual');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-loader/gd-loader.ts b/libs/web-components/src/components/atoms/gd-loader/gd-loader.ts
new file mode 100644
index 0000000..fd7aa63
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-loader/gd-loader.ts
@@ -0,0 +1,76 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { loader, animations } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, get, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+type LoaderName = 'circle' | 'dots';
+type LoaderVariant = 'inline' | 'section' | 'fullPage';
+type LoaderSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+type CssBlock = ButtonCssBlock;
+
+@customElement('gd-loader')
+export class GdLoader extends LitElement {
+ @property({ type: String, reflect: true }) name: LoaderName = 'circle';
+ @property({ type: String, reflect: true }) variant: LoaderVariant = 'inline';
+ @property({ type: String, reflect: true }) size: LoaderSize = 'md';
+ @property({ type: String, reflect: true }) rounded = 'none';
+ @property({ type: String, attribute: 'animation-props' }) animationProps = '1000ms ease-in-out infinite';
+ @property({ type: Boolean, attribute: 'with-wrapper' }) withWrapper = true;
+ @property({ attribute: false }) styles: CssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ protected updated() {
+ const surface = this.shadowRoot?.querySelector('[part="wrapper"]');
+ if (!surface || this.variant !== 'fullPage' || surface.matches(':popover-open')) return;
+ surface.showPopover?.();
+ }
+
+ render() {
+ const tokens = resolveThemeTree(loader, this.theme) as unknown as Record & {
+ circle: Record;
+ dots: Record;
+ };
+ const resolvedAnimations = resolveThemeTree(animations, this.theme) as unknown as Record;
+ const animationName = this.name === 'circle' ? 'spinKeyframes' : 'bounceKeyframes';
+ const frames = resolvedAnimations[animationName];
+ const radius = get(this.theme, `radius.${this.rounded}`, '0px');
+ const cssText = [
+ buttonCssBlockToText('.loader', tokens.default),
+ buttonCssBlockToText('.loader', tokens[this.variant] ?? {}),
+ buttonCssBlockToText('.loader', tokens[this.name].default),
+ buttonCssBlockToText('.loader', tokens[this.name][this.size]),
+ this.name === 'circle'
+ ? buttonCssBlockToText('.loader', { animation: `gd-loader-spin ${this.animationProps}` })
+ : '',
+ this.name === 'dots'
+ ? buttonCssBlockToText('.dot', { borderRadius: radius, animation: `gd-loader-bounce ${this.animationProps}` })
+ : '',
+ buttonCssBlockToText('.loader', this.styles),
+ buttonCssBlockToText(`@keyframes gd-loader-${this.name === 'circle' ? 'spin' : 'bounce'}`, frames),
+ ].join('\n');
+ const root = this.shadowRoot;
+ if (root && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ root.adoptedStyleSheets = [sheet];
+ }
+ const content = html`${this.name === 'dots'
+ ? html` `
+ : ''} `;
+ return this.withWrapper
+ ? html`${content} `
+ : content;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-loader': GdLoader;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-select/gd-select.spec.ts b/libs/web-components/src/components/atoms/gd-select/gd-select.spec.ts
new file mode 100644
index 0000000..91fb0ad
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-select/gd-select.spec.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-select';
+import type { GdSelect } from './gd-select';
+
+const items = [
+ { name: 'Alpha', value: 'a' },
+ { name: 'Beta', value: 'b' },
+ { name: 'Gamma', value: 'c' },
+];
+
+describe('gd-select React parity', () => {
+ 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 =
+ 'Start End Initiator Nothing ';
+ 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` `}
+
+
- ${this.items.length
- ? this.items.map(
- (item) => html`
+ ${this.searchable
+ ? html` `
+ : null}
+ ${visibleItems.length
+ ? visibleItems.map((item, index) => {
+ const isSelected = selectedItems.some((current) =>
+ this.itemIdentifier ? this.itemIdentifier(current, item) : current.value === item.value
+ );
+ const custom = this.renderOption?.({ item, index, isActiveItem: isSelected, className: 'option' });
+ return html`
this._select(item)}
>
- ${item.name}
+ ${custom ?? this.itemStringifier(item)}
- `
- )
+ `;
+ })
: html` `}
diff --git a/libs/web-components/src/components/atoms/gd-separator/gd-separator.spec.ts b/libs/web-components/src/components/atoms/gd-separator/gd-separator.spec.ts
new file mode 100644
index 0000000..900ba76
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-separator/gd-separator.spec.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-separator';
+
+describe('gd-separator', () => {
+ it('renders the source default and centered-label line inventory', async () => {
+ const el = document.createElement('gd-separator');
+ el.theme = defaultTheme;
+ document.body.append(el);
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelectorAll('[part="line"]')).toHaveLength(1);
+ expect(el.shadowRoot!.querySelector('[part="separator"]')!.tagName).toBe('DIV');
+ el.label = 'OR';
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelectorAll('[part="line"]')).toHaveLength(2);
+ expect(el.shadowRoot!.querySelector('[part="label"]')!.textContent).toContain('OR');
+ el.remove();
+ });
+
+ it('preserves vertical length, variants, and span polymorphism', async () => {
+ const el = document.createElement('gd-separator');
+ Object.assign(el, {
+ theme: defaultTheme,
+ orientation: 'vertical',
+ length: '40px',
+ variant: 'dashed',
+ as: 'span',
+ label: 'OR',
+ });
+ document.body.append(el);
+ await el.updateComplete;
+ const root = el.shadowRoot!.querySelector('[part="separator"]')!;
+ const lines = el.shadowRoot!.querySelectorAll('[part="line"]');
+ expect(root.tagName).toBe('SPAN');
+ expect(lines).toHaveLength(2);
+ expect([...lines].every((line) => line.tagName === 'SPAN')).toBe(true);
+ expect(getComputedStyle(root).height).toBe('40px');
+ expect(getComputedStyle(lines[0]).borderLeftStyle).toBe('dashed');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-separator/gd-separator.ts b/libs/web-components/src/components/atoms/gd-separator/gd-separator.ts
new file mode 100644
index 0000000..a87de4f
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-separator/gd-separator.ts
@@ -0,0 +1,102 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { html as staticHtml, unsafeStatic } from 'lit/static-html.js';
+import { separator, resolveThemeColor } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, get, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+import '../gd-typography/gd-typography';
+
+type SeparatorOrientation = 'horizontal' | 'vertical';
+type SeparatorVariant = 'solid' | 'dashed' | 'dotted';
+type SeparatorSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
+type SeparatorLabelPosition = 'start' | 'center' | 'end';
+type SeparatorTag = 'div' | 'hr' | 'span';
+type CssBlock = ButtonCssBlock;
+
+const labelTypography: Record = {
+ xs: { variant: 'caption', as: 'span' },
+ sm: { variant: 'body2', as: 'span' },
+ md: { variant: 'body1', as: 'span' },
+ lg: { variant: 'h6', as: 'h6' },
+ xl: { variant: 'h5', as: 'h5' },
+ xxl: { variant: 'h4', as: 'h4' },
+};
+
+@customElement('gd-separator')
+export class GdSeparator extends LitElement {
+ @property({ type: String, reflect: true }) orientation: SeparatorOrientation = 'horizontal';
+ @property({ type: String, reflect: true }) length?: string;
+ @property({ type: String, reflect: true }) color?: string;
+ @property({ type: String, reflect: true }) size: SeparatorSize = 'sm';
+ @property({ type: String, reflect: true }) variant: SeparatorVariant = 'solid';
+ @property({ type: String, reflect: true }) as: SeparatorTag = 'div';
+ @property({ type: String, reflect: true }) label?: string;
+ @property({ type: String, attribute: 'label-position', reflect: true }) labelPosition: SeparatorLabelPosition =
+ 'center';
+ @property({ type: String, attribute: 'label-color', reflect: true }) labelColor?: string;
+ @property({ attribute: false }) styles: CssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ private line() {
+ const tag = unsafeStatic(this.as === 'hr' ? 'hr' : this.as);
+ return staticHtml`<${tag} class="line" part="line">${tag}>`;
+ }
+
+ private labelNode() {
+ if (!this.label) return null;
+ const typography = labelTypography[this.size];
+ return html`${this.label} `;
+ }
+
+ render() {
+ const tokens = resolveThemeTree(separator, this.theme) as unknown as {
+ default: CssBlock;
+ horizontal: CssBlock;
+ vertical: CssBlock;
+ line: { default: CssBlock; horizontal: CssBlock; vertical: CssBlock };
+ label: { default: CssBlock };
+ };
+ const lineColor = resolveThemeColor(this.theme.colors as never, this.color ?? 'border.default');
+ const thickness = get(this.theme, `values.separator.thickness.${this.size}`, '1px');
+ const borderSide = this.orientation === 'horizontal' ? 'borderTop' : 'borderLeft';
+ const lengthStyle = this.length ? { [this.orientation === 'horizontal' ? 'width' : 'height']: this.length } : {};
+ const cssText = [
+ buttonCssBlockToText('.separator', tokens.default),
+ buttonCssBlockToText('.separator', tokens[this.orientation]),
+ buttonCssBlockToText('.separator', lengthStyle),
+ buttonCssBlockToText('.separator', this.styles),
+ buttonCssBlockToText('.line', tokens.line.default),
+ buttonCssBlockToText('.line', tokens.line[this.orientation]),
+ buttonCssBlockToText('.line', { [borderSide]: `${thickness} ${this.variant} ${lineColor}` }),
+ buttonCssBlockToText('gd-typography', tokens.label.default),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+
+ const hasLabel = Boolean(this.label);
+ const wrapperTag = unsafeStatic(this.as === 'hr' && hasLabel ? 'div' : this.as);
+ const first = this.line();
+ const label = this.labelNode();
+ return staticHtml`<${wrapperTag} class="separator" part="separator">
+ ${hasLabel && this.labelPosition === 'start' ? label : null}
+ ${first}
+ ${hasLabel && this.labelPosition === 'center' ? html`${label}${this.line()}` : null}
+ ${hasLabel && this.labelPosition === 'end' ? label : null}
+ ${wrapperTag}>`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-separator': GdSeparator;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.spec.ts b/libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.spec.ts
new file mode 100644
index 0000000..550a075
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-skeleton';
+
+describe('gd-skeleton', () => {
+ it('renders the rounded animated source default and slotted children', async () => {
+ const el = document.createElement('gd-skeleton');
+ el.theme = defaultTheme;
+ el.textContent = 'Loading Content...';
+ document.body.append(el);
+ await el.updateComplete;
+ const skeleton = el.shadowRoot!.querySelector('[part="skeleton"]')!;
+ expect(skeleton).not.toBeNull();
+ expect(getComputedStyle(skeleton).animationName).toBe('gd-skeleton-animation');
+ expect(el.textContent).toBe('Loading Content...');
+ el.remove();
+ });
+
+ it('supports rectangular dimensions, theme colors, and disabled animation', async () => {
+ const el = document.createElement('gd-skeleton');
+ Object.assign(el, {
+ theme: defaultTheme,
+ variant: 'rectangular',
+ width: '120px',
+ height: '40px',
+ backgroundColor: 'theme.palette.success.main',
+ animationName: null,
+ });
+ document.body.append(el);
+ await el.updateComplete;
+ const skeleton = el.shadowRoot!.querySelector('[part="skeleton"]')!;
+ const style = getComputedStyle(skeleton);
+ expect(style.width).toBe('120px');
+ expect(style.height).toBe('40px');
+ expect(style.borderRadius).toBe('0px');
+ expect(style.animationName).toBe('none');
+ expect(style.backgroundColor).not.toBe('');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.ts b/libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.ts
new file mode 100644
index 0000000..f9ef01c
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-skeleton/gd-skeleton.ts
@@ -0,0 +1,57 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { animations, resolveThemeColor, skeleton } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+type SkeletonVariant = 'rounded' | 'rectangular' | 'circular';
+type CssBlock = ButtonCssBlock;
+
+@customElement('gd-skeleton')
+export class GdSkeleton extends LitElement {
+ @property({ type: String, reflect: true }) variant: SkeletonVariant = 'rounded';
+ @property({ type: String, reflect: true }) width?: string;
+ @property({ type: String, reflect: true }) height?: string;
+ @property({ type: String, attribute: 'background-color', reflect: true }) backgroundColor?: string;
+ @property({ type: String, attribute: 'animation-name' }) animationName: string | null = 'blinkKeyframes';
+ @property({ type: String, attribute: 'animation-props' }) animationProps = '2000ms ease-in-out 500ms infinite';
+ @property({ attribute: false }) styles: CssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ render() {
+ const tokens = resolveThemeTree(skeleton, this.theme) as unknown as Record;
+ const resolvedAnimations = resolveThemeTree(animations, this.theme) as unknown as Record;
+ const themeFrames = this.animationName ? resolvedAnimations[this.animationName] : undefined;
+ const animationCssName = themeFrames ? 'gd-skeleton-animation' : this.animationName;
+ const dimensions: CssBlock = {
+ ...(this.width ? { width: this.width } : {}),
+ ...(this.height ? { height: this.height } : {}),
+ };
+ const resolvedBackground = this.backgroundColor
+ ? resolveThemeColor(this.theme.colors as never, this.backgroundColor)
+ : undefined;
+ const background: CssBlock = resolvedBackground ? { backgroundColor: resolvedBackground } : {};
+ const cssText = [
+ buttonCssBlockToText('.skeleton', tokens.default),
+ buttonCssBlockToText('.skeleton', tokens[this.variant]),
+ buttonCssBlockToText('.skeleton', dimensions),
+ buttonCssBlockToText('.skeleton', background),
+ animationCssName
+ ? buttonCssBlockToText('.skeleton', { animation: `${animationCssName} ${this.animationProps}` })
+ : '',
+ buttonCssBlockToText('.skeleton', this.styles),
+ themeFrames ? buttonCssBlockToText('@keyframes gd-skeleton-animation', themeFrames) : '',
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+ return html` `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-skeleton': GdSkeleton;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.spec.ts b/libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.spec.ts
new file mode 100644
index 0000000..835e340
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-slider-dots';
+
+describe('gd-slider-dots', () => {
+ it('renders accessible dots and reports selection', async () => {
+ const el = document.createElement('gd-slider-dots');
+ Object.assign(el, { theme: defaultTheme, count: 5 });
+ const fn = vi.fn();
+ el.addEventListener('gd-change', fn);
+ document.body.append(el);
+ await el.updateComplete;
+ const dots = el.shadowRoot!.querySelectorAll('[role=tab]');
+ expect(dots).toHaveLength(5);
+ dots[2].click();
+ await el.updateComplete;
+ expect(el.activeIndex).toBe(2);
+ expect(fn).toHaveBeenCalledWith(expect.objectContaining({ detail: { index: 2 } }));
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.ts b/libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.ts
new file mode 100644
index 0000000..d86d821
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-slider-dots/gd-slider-dots.ts
@@ -0,0 +1,57 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { repeat } from 'lit/directives/repeat.js';
+import { sliderDots } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+@customElement('gd-slider-dots')
+export class GdSliderDots extends LitElement {
+ @property({ type: Number, reflect: true }) count = 0;
+ @property({ type: Number, attribute: 'active-index', reflect: true }) activeIndex = 0;
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ private select(index: number) {
+ this.activeIndex = index;
+ this.dispatchEvent(new CustomEvent('gd-change', { detail: { index }, bubbles: true, composed: true }));
+ }
+
+ render() {
+ const tokens = resolveThemeTree(sliderDots, this.theme) as unknown as {
+ container: { default: ButtonCssBlock };
+ dot: { default: ButtonCssBlock; active: ButtonCssBlock };
+ };
+ const cssText = [
+ buttonCssBlockToText('.dots', tokens.container.default),
+ buttonCssBlockToText('.dots', this.styles),
+ buttonCssBlockToText('.dot', tokens.dot.default),
+ buttonCssBlockToText('.dot[aria-selected="true"]', tokens.dot.active),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+ return html`
+ ${repeat(
+ Array.from({ length: Math.max(0, this.count) }, (_, index) => index),
+ (index) => index,
+ (index) =>
+ html` this.select(index)}
+ > `
+ )}
+
`;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-slider-dots': GdSliderDots;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-slider/gd-slider.spec.ts b/libs/web-components/src/components/atoms/gd-slider/gd-slider.spec.ts
new file mode 100644
index 0000000..229963a
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-slider/gd-slider.spec.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-slider';
+
+describe('gd-slider', () => {
+ it('renders range semantics and emits clamped values', async () => {
+ const el = document.createElement('gd-slider');
+ Object.assign(el, { theme: defaultTheme, min: 10, max: 20, value: 15 });
+ const listener = vi.fn();
+ el.addEventListener('gd-change', listener);
+ document.body.append(el);
+ await el.updateComplete;
+ const input = el.shadowRoot!.querySelector('input')!;
+ expect(input.value).toBe('15');
+ expect(input.getAttribute('aria-valuenow')).toBe('15');
+ input.value = '20';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await el.updateComplete;
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: 20 } }));
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-slider/gd-slider.ts b/libs/web-components/src/components/atoms/gd-slider/gd-slider.ts
new file mode 100644
index 0000000..3029a50
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-slider/gd-slider.ts
@@ -0,0 +1,64 @@
+import { LitElement, html } from 'lit';
+import { customElement, property, state } from 'lit/decorators.js';
+import { slider } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+@customElement('gd-slider')
+export class GdSlider extends LitElement {
+ @property({ type: Number, reflect: true }) min = 0;
+ @property({ type: Number, reflect: true }) max = 100;
+ @property({ type: Number }) value?: number;
+ @property({ type: Number, reflect: true }) step = 1;
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ @state() private currentValue = 0;
+
+ protected willUpdate(changed: Map) {
+ if (changed.has('value') || changed.has('min')) this.currentValue = this.value ?? this.min;
+ }
+
+ private onInput(event: Event) {
+ const value = Math.min(this.max, Math.max(this.min, Number((event.target as HTMLInputElement).value)));
+ this.currentValue = value;
+ this.value = value;
+ this.dispatchEvent(new CustomEvent('gd-change', { detail: { value }, bubbles: true, composed: true }));
+ }
+
+ render() {
+ const tokens = resolveThemeTree(slider, this.theme) as unknown as { default: ButtonCssBlock };
+ const fillRatio = this.max > this.min ? (this.currentValue - this.min) / (this.max - this.min) : this.min;
+ const cssText = [
+ buttonCssBlockToText('input', tokens.default),
+ buttonCssBlockToText('input', { '--gd-slider-fill-ratio': fillRatio }),
+ buttonCssBlockToText('input', this.styles),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+ return html` `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-slider': GdSlider;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-switch/gd-switch.spec.ts b/libs/web-components/src/components/atoms/gd-switch/gd-switch.spec.ts
new file mode 100644
index 0000000..bf3134e
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-switch/gd-switch.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-switch';
+
+describe('gd-switch', () => {
+ it('toggles and emits checked detail', async () => {
+ const el = document.createElement('gd-switch');
+ Object.assign(el, { theme: defaultTheme });
+ el.textContent = 'Notifications';
+ const fn = vi.fn();
+ el.addEventListener('gd-change', fn);
+ document.body.append(el);
+ await el.updateComplete;
+ el.shadowRoot!.querySelector('input')!.click();
+ await el.updateComplete;
+ expect(el.checked).toBe(true);
+ expect(fn).toHaveBeenCalledWith(expect.objectContaining({ detail: { checked: true } }));
+ el.remove();
+ });
+ it('disables interaction and composes loader while loading', async () => {
+ const el = document.createElement('gd-switch');
+ Object.assign(el, { theme: defaultTheme, isLoading: true });
+ document.body.append(el);
+ await el.updateComplete;
+ expect(el.shadowRoot!.querySelector('input')!.disabled).toBe(true);
+ expect(el.shadowRoot!.querySelector('gd-loader')).not.toBeNull();
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-switch/gd-switch.ts b/libs/web-components/src/components/atoms/gd-switch/gd-switch.ts
new file mode 100644
index 0000000..dc421c4
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-switch/gd-switch.ts
@@ -0,0 +1,74 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { switchToken } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+import '../gd-loader/gd-loader';
+
+@customElement('gd-switch')
+export class GdSwitch extends LitElement {
+ @property({ type: String, reflect: true }) label: 'left' | 'right' = 'right';
+ @property({ type: String, reflect: true }) name = 'switch';
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ type: Boolean, attribute: 'is-loading', reflect: true }) isLoading = false;
+ @property({ type: Boolean, reflect: true }) checked = false;
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+
+ private change(event: Event) {
+ if (this.disabled || this.isLoading) return;
+ this.checked = (event.target as HTMLInputElement).checked;
+ this.dispatchEvent(
+ new CustomEvent('gd-change', { detail: { checked: this.checked }, bubbles: true, composed: true })
+ );
+ }
+
+ render() {
+ const t = resolveThemeTree(switchToken, this.theme) as unknown as Record & {
+ wrapper: Record;
+ label: Record;
+ slider: Record;
+ };
+ const off = this.disabled || this.isLoading;
+ const cssText = [
+ buttonCssBlockToText('.wrapper', t.wrapper.default),
+ off ? buttonCssBlockToText('.wrapper', t.wrapper.disabled) : '',
+ buttonCssBlockToText('.wrapper', this.styles),
+ buttonCssBlockToText('.control', t.default),
+ this.checked ? buttonCssBlockToText('.control', t.checked) : '',
+ buttonCssBlockToText('.label', t.label.default ?? {}),
+ buttonCssBlockToText('.label.right', t.label.right ?? {}),
+ buttonCssBlockToText('.slider', t.slider.default),
+ this.checked ? buttonCssBlockToText('.slider', t.slider.checked) : '',
+ off ? buttonCssBlockToText('.slider', t.slider.disabled) : '',
+ buttonCssBlockToText('input', t.checkbox),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+ return html`
+ ${this.label === 'left' ? html` ` : nothing}
+
+ ${this.isLoading
+ ? html` `
+ : nothing}
+
+
+
+ ${this.label === 'right' ? html` ` : nothing}
+ `;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-switch': GdSwitch;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-textarea/gd-textarea.spec.ts b/libs/web-components/src/components/atoms/gd-textarea/gd-textarea.spec.ts
new file mode 100644
index 0000000..ff67ce5
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-textarea/gd-textarea.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-textarea';
+
+describe('gd-textarea', () => {
+ it('emits values and updates the character counter', async () => {
+ const el = document.createElement('gd-textarea');
+ Object.assign(el, { theme: defaultTheme, maxCharacters: 10 });
+ const fn = vi.fn();
+ el.addEventListener('gd-input', fn);
+ document.body.append(el);
+ await el.updateComplete;
+ const input = el.shadowRoot!.querySelector('textarea')!;
+ input.value = 'Hello';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await el.updateComplete;
+ expect(fn).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: 'Hello' } }));
+ expect(el.shadowRoot!.querySelector('[part=counter]')!.textContent).toBe('5/10');
+ el.remove();
+ });
+ it('forwards native disabled, readonly, dimensions, and resize behavior', async () => {
+ const el = document.createElement('gd-textarea');
+ Object.assign(el, {
+ theme: defaultTheme,
+ disabled: true,
+ readOnly: true,
+ rows: 3,
+ minHeight: '80px',
+ resize: 'both',
+ });
+ document.body.append(el);
+ await el.updateComplete;
+ const input = el.shadowRoot!.querySelector('textarea')!;
+ expect(input.disabled).toBe(true);
+ expect(input.readOnly).toBe(true);
+ expect(input.rows).toBe(3);
+ expect(getComputedStyle(input).resize).toBe('both');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-textarea/gd-textarea.ts b/libs/web-components/src/components/atoms/gd-textarea/gd-textarea.ts
new file mode 100644
index 0000000..34a556c
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-textarea/gd-textarea.ts
@@ -0,0 +1,108 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property, query, state } from 'lit/decorators.js';
+import { textarea } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+@customElement('gd-textarea')
+export class GdTextarea extends LitElement {
+ @property({ type: String }) value?: string;
+ @property({ type: String, attribute: 'default-value' }) defaultValue = '';
+ @property({ type: String, reflect: true }) name = 'textarea';
+ @property({ type: String, reflect: true }) placeholder = '';
+ @property({ type: String, reflect: true }) resize: 'none' | 'both' | 'horizontal' | 'vertical' = 'none';
+ @property({ type: String, reflect: true }) variant: 'default' | 'inline' = 'default';
+ @property({ type: String, reflect: true }) color: 'primary' | 'success' | 'warning' | 'error' = 'primary';
+ @property({ type: String, attribute: 'aria-described-by' }) ariaDescribedBy?: string;
+ @property({ type: Boolean, attribute: 'dynamic-height-adjustment' }) dynamicHeightAdjustment = false;
+ @property({ type: Boolean, attribute: 'auto-focus' }) autoFocus = false;
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ type: Boolean, attribute: 'read-only', reflect: true }) readOnly = false;
+ @property({ type: Number, reflect: true }) rows?: number;
+ @property({ type: Number, attribute: 'max-length' }) maxLength?: number;
+ @property({ type: Number, attribute: 'max-characters' }) maxCharacters?: number;
+ @property({ type: String, attribute: 'min-height' }) minHeight?: string;
+ @property({ type: String, attribute: 'max-height' }) maxHeight?: string;
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ @state() private internalValue = '';
+ @query('textarea') private control!: HTMLTextAreaElement;
+
+ protected firstUpdated() {
+ this.internalValue = this.value ?? this.defaultValue;
+ if (this.autoFocus) this.control.focus();
+ this.adjustHeight();
+ }
+ protected updated(changed: Map) {
+ if (changed.has('value') && this.value !== undefined) this.internalValue = this.value;
+ }
+ focus(options?: FocusOptions) {
+ this.control?.focus(options);
+ }
+ private adjustHeight() {
+ if (!this.dynamicHeightAdjustment || !this.control) return;
+ this.control.style.height = 'auto';
+ this.control.style.height = `${this.control.scrollHeight}px`;
+ }
+ private input(event: Event) {
+ this.internalValue = (event.target as HTMLTextAreaElement).value;
+ this.adjustHeight();
+ this.dispatchEvent(
+ new CustomEvent('gd-input', { detail: { value: this.internalValue }, bubbles: true, composed: true })
+ );
+ }
+ private change() {
+ this.dispatchEvent(
+ new CustomEvent('gd-change', { detail: { value: this.internalValue }, bubbles: true, composed: true })
+ );
+ }
+
+ render() {
+ const t = resolveThemeTree(textarea, this.theme) as unknown as Record & {
+ charCount: Record;
+ };
+ const exceeded = this.maxCharacters !== undefined && this.internalValue.length > this.maxCharacters;
+ const cssText = [
+ buttonCssBlockToText('textarea', t[this.variant]),
+ buttonCssBlockToText('textarea', t[exceeded ? 'error' : this.color]),
+ buttonCssBlockToText('textarea', {
+ resize: this.resize,
+ ...(this.minHeight ? { minHeight: this.minHeight } : {}),
+ ...(this.maxHeight ? { maxHeight: this.maxHeight } : {}),
+ }),
+ buttonCssBlockToText('textarea', this.styles),
+ buttonCssBlockToText('.counter', t.charCount.default),
+ exceeded ? buttonCssBlockToText('.counter', t.charCount.exceeded) : '',
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const sheet = new CSSStyleSheet();
+ sheet.replaceSync(cssText);
+ this.shadowRoot.adoptedStyleSheets = [sheet];
+ }
+ return html`
+ ${this.maxCharacters === undefined
+ ? nothing
+ : html`${this.internalValue.length}/${this.maxCharacters} `}
+
`;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-textarea': GdTextarea;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-toggle/gd-toggle.spec.ts b/libs/web-components/src/components/atoms/gd-toggle/gd-toggle.spec.ts
new file mode 100644
index 0000000..9f7da9f
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-toggle/gd-toggle.spec.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it, vi } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-toggle';
+
+describe('gd-toggle', () => {
+ it('renders every item, selects values, and emits gd-change', async () => {
+ const el = document.createElement('gd-toggle');
+ Object.assign(el, { theme: defaultTheme, items: ['One', 'Two', 'Three'], value: 'One' });
+ const fn = vi.fn();
+ el.addEventListener('gd-change', fn);
+ document.body.append(el);
+ await el.updateComplete;
+ const items = el.shadowRoot!.querySelectorAll('gd-button');
+ expect(items).toHaveLength(3);
+ (items[1] as HTMLElement).click();
+ await el.updateComplete;
+ expect(el.value).toBe('Two');
+ expect(fn).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: 'Two' } }));
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-toggle/gd-toggle.ts b/libs/web-components/src/components/atoms/gd-toggle/gd-toggle.ts
new file mode 100644
index 0000000..6713636
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-toggle/gd-toggle.ts
@@ -0,0 +1,64 @@
+import { LitElement, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { repeat } from 'lit/directives/repeat.js';
+import { switchToggle } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+import '../gd-button/gd-button';
+
+export interface GdToggleItem {
+ label?: unknown;
+ value: unknown;
+}
+@customElement('gd-toggle')
+export class GdToggle extends LitElement {
+ @property({ attribute: false }) items: Array = [];
+ @property({ attribute: false }) value?: unknown;
+ @property({ type: Boolean, reflect: true }) disabled = false;
+ @property({ attribute: false }) renderItemContent?: (item: string | GdToggleItem, index: number) => unknown;
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ private normalized(item: string | GdToggleItem): GdToggleItem {
+ return typeof item === 'object' ? item : { label: item, value: item };
+ }
+ private select(value: unknown) {
+ if (this.disabled) return;
+ this.value = value;
+ this.dispatchEvent(new CustomEvent('gd-change', { detail: { value }, bubbles: true, composed: true }));
+ }
+ render() {
+ const t = resolveThemeTree(switchToggle, this.theme) as unknown as Record;
+ const css = [
+ buttonCssBlockToText('.toggle', t.default),
+ this.disabled ? buttonCssBlockToText('.toggle', t.disabled) : '',
+ buttonCssBlockToText('.toggle', this.styles),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const s = new CSSStyleSheet();
+ s.replaceSync(css);
+ this.shadowRoot.adoptedStyleSheets = [s];
+ }
+ return html`
+ ${repeat(
+ this.items,
+ (_, i) => i,
+ (item, index) => {
+ const x = this.normalized(item);
+ return html` this.select(x.value)}
+ >${this.renderItemContent?.(item, index) ?? x.label ?? ''} `;
+ }
+ )}
+
`;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-toggle': GdToggle;
+ }
+}
diff --git a/libs/web-components/src/components/atoms/gd-truncate/gd-truncate.spec.ts b/libs/web-components/src/components/atoms/gd-truncate/gd-truncate.spec.ts
new file mode 100644
index 0000000..a47996c
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-truncate/gd-truncate.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-truncate';
+
+describe('gd-truncate', () => {
+ it('renders slotted content with the requested line clamp', async () => {
+ const el = document.createElement('gd-truncate');
+ Object.assign(el, { theme: defaultTheme, lines: 2 });
+ el.textContent = 'A long line of content';
+ document.body.append(el);
+ await el.updateComplete;
+ expect(getComputedStyle(el.shadowRoot!.querySelector('[part=content]')!).webkitLineClamp).toBe('2');
+ expect(el.textContent).toContain('A long line');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-truncate/gd-truncate.ts b/libs/web-components/src/components/atoms/gd-truncate/gd-truncate.ts
new file mode 100644
index 0000000..509c34c
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-truncate/gd-truncate.ts
@@ -0,0 +1,48 @@
+import { LitElement, html } from 'lit';
+import { customElement, property, query, state } from 'lit/decorators.js';
+import { truncate } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+@customElement('gd-truncate')
+export class GdTruncate extends LitElement {
+ @property({ type: Number, reflect: true }) lines = 1;
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ @state() isTruncated = false;
+ @query('[part="content"]') private content!: HTMLElement;
+ private observer?: ResizeObserver;
+ protected firstUpdated() {
+ this.measure();
+ this.observer = new ResizeObserver(() => this.measure());
+ this.observer.observe(this.content);
+ }
+ disconnectedCallback() {
+ this.observer?.disconnect();
+ super.disconnectedCallback();
+ }
+ measure() {
+ requestAnimationFrame(() => {
+ if (this.content) this.isTruncated = this.content.scrollHeight > this.content.clientHeight;
+ });
+ return this.isTruncated;
+ }
+ render() {
+ const t = resolveThemeTree(truncate, this.theme) as unknown as { default: ButtonCssBlock };
+ const css = [
+ buttonCssBlockToText('.content', t.default),
+ buttonCssBlockToText('.content', { lineClamp: this.lines, WebkitLineClamp: this.lines }),
+ buttonCssBlockToText('.content', this.styles),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const s = new CSSStyleSheet();
+ s.replaceSync(css);
+ this.shadowRoot.adoptedStyleSheets = [s];
+ }
+ return html` `;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-truncate': GdTruncate;
+ }
+}
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)}> ${tag}>`;
diff --git a/libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.spec.ts b/libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.spec.ts
new file mode 100644
index 0000000..235059d
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+import { defaultTheme } from 'gd-design-library/tokens';
+import './gd-wrapper';
+
+describe('gd-wrapper', () => {
+ it('renders variants, custom semantic tags, and slotted content', async () => {
+ const el = document.createElement('gd-wrapper');
+ Object.assign(el, { theme: defaultTheme, variant: 'section', as: 'section' });
+ el.textContent = 'Hello World';
+ document.body.append(el);
+ await el.updateComplete;
+ const root = el.shadowRoot!.querySelector('[part=wrapper]')!;
+ expect(root.tagName).toBe('SECTION');
+ expect(getComputedStyle(root).position).toBe('absolute');
+ expect(el.textContent).toBe('Hello World');
+ el.remove();
+ });
+});
diff --git a/libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.ts b/libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.ts
new file mode 100644
index 0000000..552f8dc
--- /dev/null
+++ b/libs/web-components/src/components/atoms/gd-wrapper/gd-wrapper.ts
@@ -0,0 +1,34 @@
+import { LitElement } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { html, unsafeStatic } from 'lit/static-html.js';
+import { wrapper } from 'gd-design-library/tokens';
+import { buttonCssBlockToText, resolveThemeTree, type ButtonCssBlock, type DesignCoreTheme } from 'gd-design-core';
+
+type WrapperVariant = 'inline' | 'section' | 'fullPage';
+@customElement('gd-wrapper')
+export class GdWrapper extends LitElement {
+ @property({ type: String, reflect: true }) variant: WrapperVariant = 'inline';
+ @property({ type: String, reflect: true }) as: keyof HTMLElementTagNameMap = 'div';
+ @property({ attribute: false }) styles: ButtonCssBlock = {};
+ @property({ attribute: false }) theme: DesignCoreTheme = {};
+ render() {
+ const t = resolveThemeTree(wrapper, this.theme) as unknown as Record;
+ const css = [
+ buttonCssBlockToText('.wrapper', t.default),
+ buttonCssBlockToText('.wrapper', t[this.variant]),
+ buttonCssBlockToText('.wrapper', this.styles),
+ ].join('\n');
+ if (this.shadowRoot && typeof CSSStyleSheet !== 'undefined') {
+ const s = new CSSStyleSheet();
+ s.replaceSync(css);
+ this.shadowRoot.adoptedStyleSheets = [s];
+ }
+ const tag = unsafeStatic(this.as);
+ return html`<${tag} class="wrapper" part="wrapper"> ${tag}>`;
+ }
+}
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gd-wrapper': GdWrapper;
+ }
+}
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/src/index.ts b/libs/web-components/src/index.ts
index d1d5359..5532a15 100644
--- a/libs/web-components/src/index.ts
+++ b/libs/web-components/src/index.ts
@@ -4,5 +4,22 @@ export { GdTypography } from './components/atoms/gd-typography/gd-typography';
export { GdInput } from './components/atoms/gd-input/gd-input';
export { GdSelect } from './components/atoms/gd-select/gd-select';
export { GdAvatar } from './components/atoms/gd-avatar/gd-avatar';
+export { GdBox } from './components/atoms/gd-box/gd-box';
+export { GdBadge } from './components/atoms/gd-badge/gd-badge';
+export { GdImage } from './components/atoms/gd-image/gd-image';
+export { GdIcon } from './components/atoms/gd-icon/gd-icon';
+export { GdInputFile } from './components/atoms/gd-input-file/gd-input-file';
+export { GdLabel } from './components/atoms/gd-label/gd-label';
+export { GdLink } from './components/atoms/gd-link/gd-link';
+export { GdLoader } from './components/atoms/gd-loader/gd-loader';
+export { GdSeparator } from './components/atoms/gd-separator/gd-separator';
+export { GdSkeleton } from './components/atoms/gd-skeleton/gd-skeleton';
+export { GdSlider } from './components/atoms/gd-slider/gd-slider';
+export { GdSliderDots } from './components/atoms/gd-slider-dots/gd-slider-dots';
+export { GdSwitch } from './components/atoms/gd-switch/gd-switch';
+export { GdTextarea } from './components/atoms/gd-textarea/gd-textarea';
+export { GdToggle } from './components/atoms/gd-toggle/gd-toggle';
+export { GdTruncate } from './components/atoms/gd-truncate/gd-truncate';
+export { GdWrapper } from './components/atoms/gd-wrapper/gd-wrapper';
export { GdMenu } from './components/molecules/gd-menu/gd-menu';
export { GdCounter } from './components/molecules/gd-counter/gd-counter';
diff --git a/libs/web-components/stories/Avatar.stories.ts b/libs/web-components/stories/Avatar.stories.ts
new file mode 100644
index 0000000..ab7fee1
--- /dev/null
+++ b/libs/web-components/stories/Avatar.stories.ts
@@ -0,0 +1,68 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdAvatar } from '../src';
+import { defaultTokenViewer, element, portrait } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Avatar',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Avatar matching the React image, fallback initials or icon, badge, color, size, and accessibility states.',
+ },
+ },
+ },
+ 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;
+ },
+};
+export const WithBadge: Story = { args: { withBadge: true } };
+export const WithCustomBadgeColor: Story = { args: { withBadge: true, badgeColor: '#22c55e' } };
+export const WithInitials: Story = { args: { src: '', fallback: 'AL' } };
+export const WithCustomBackgroundColor: Story = { args: { src: '', fallback: 'AL', backgroundColor: '#6b46c1' } };
+export const WithIcon: Story = SlottedFallback;
+export const WithImageAndFallback: Story = { args: { src: portrait, fallback: 'AL' } };
+export const WithDifferentSize: Story = AllSizes;
+export const WithAccessibility: Story = { args: { alt: 'Ada Lovelace profile photo' } };
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('avatar') };
diff --git a/libs/web-components/stories/Badge.stories.ts b/libs/web-components/stories/Badge.stories.ts
new file mode 100644
index 0000000..a97549d
--- /dev/null
+++ b/libs/web-components/stories/Badge.stories.ts
@@ -0,0 +1,63 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdBadge } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Badge',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Status badge matching React Badge variants, appearances, sizes, disabled state, icon positions, and Box style overrides.',
+ },
+ },
+ },
+ args: { variant: 'primary', appearance: 'filled', size: 'md', disabled: false },
+ argTypes: {
+ variant: {
+ description: 'Visual style variant of the badge',
+ control: 'select',
+ options: ['primary', 'secondary', 'tertiary', 'quaternary', 'quinary'],
+ },
+ appearance: {
+ description: 'Filled, light-filled, outline, or light-filled outline appearance',
+ control: 'select',
+ options: ['filled', 'filledLight', 'outline', 'outlineFilledLight'],
+ },
+ size: { description: 'Badge size', control: 'select', options: ['xs', 'sm', 'md', 'lg'] },
+ disabled: { description: 'Whether the badge uses disabled styling', control: 'boolean' },
+ },
+ render: (args: Args) => element('gd-badge', args, 'Badge'),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const Sizes: Story = { args: { size: 'lg' } };
+export const Variants: Story = { args: { variant: 'tertiary' } };
+export const WithIcons: Story = {
+ render: (args) => {
+ const badge = element('gd-badge', args, 'Status');
+ const icon = document.createElement('span');
+ icon.slot = 'icon-start';
+ icon.textContent = '●';
+ badge.prepend(icon);
+ return badge;
+ },
+};
+export const Disabled: Story = { args: { disabled: true } };
+export const WithBoxStyles: Story = {
+ render: (args) => element('gd-badge', { ...args, styles: { margin: '12px', minWidth: '140px' } }, 'Custom styles'),
+};
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const badge = element('gd-badge', args, '3 unread messages');
+ badge.setAttribute('role', 'status');
+ badge.setAttribute('aria-label', '3 unread messages');
+ return badge;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('badge'),
+};
diff --git a/libs/web-components/stories/Box.stories.ts b/libs/web-components/stories/Box.stories.ts
new file mode 100644
index 0000000..fa1d80c
--- /dev/null
+++ b/libs/web-components/stories/Box.stories.ts
@@ -0,0 +1,64 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdBox } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Box',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Layout container matching React Box orientation, border, hover highlight, shadow, accessibility, and custom style behavior.',
+ },
+ },
+ },
+ args: { variant: 'vertical', isBordered: false, isHighlighted: false, withShadowHover: false },
+ argTypes: {
+ variant: { description: 'Box orientation variant', control: 'select', options: ['vertical', 'horizontal'] },
+ isBordered: { description: 'Adds a border to the box', control: 'boolean' },
+ isHighlighted: { description: 'Adds the React hover outline treatment', control: 'boolean' },
+ withShadowHover: { description: 'Adds the React elevation shadow on hover', control: 'boolean' },
+ },
+ render: (args: Args) => element('gd-box', args, 'Box content'),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const Bordered: Story = { args: { isBordered: true } };
+export const Highlighted: Story = { args: { isBordered: true, isHighlighted: true } };
+export const WithShadowHover: Story = { args: { withShadowHover: true } };
+export const VerticalLayout: Story = {};
+export const HorizontalLayout: Story = { args: { variant: 'horizontal' } };
+export const CenteredContent: Story = {
+ render: (args) =>
+ element(
+ 'gd-box',
+ { ...args, styles: { width: '240px', height: '120px', alignItems: 'center', justifyContent: 'center' } },
+ 'Centered content'
+ ),
+};
+export const Examples: Story = {
+ render: () => {
+ const root = document.createElement('div');
+ root.style.cssText = 'display:grid;gap:16px';
+ root.append(
+ element('gd-box', { isBordered: true }, 'Bordered'),
+ element('gd-box', { variant: 'horizontal', withShadowHover: true }, 'Horizontal hover')
+ );
+ return root;
+ },
+};
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const box = element('gd-box', args, 'Keyboard-focusable box');
+ box.tabIndex = 0;
+ box.setAttribute('role', 'region');
+ box.setAttribute('aria-label', 'Example box');
+ return box;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('box'),
+};
diff --git a/libs/web-components/stories/Button.stories.ts b/libs/web-components/stories/Button.stories.ts
new file mode 100644
index 0000000..9f94f2b
--- /dev/null
+++ b/libs/web-components/stories/Button.stories.ts
@@ -0,0 +1,99 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdButton } from '../src';
+import { defaultTokenViewer, 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'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Button matching React variants, icons, loading, width, rounding, disabled, custom styling, states, and accessibility.',
+ },
+ },
+ },
+ 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;
+ },
+};
+export const WithIcons: Story = IconSlots;
+export const FullWidthButton: Story = FullWidth;
+export const RoundedButton: Story = Rounded;
+export const DisabledButton: Story = Disabled;
+export const ButtonStatesUsingClass: Story = { args: { ariaPressed: 'true', label: 'Pressed state' } };
+export const CustomStyledButton: Story = {
+ render: (args) => element('gd-button', { ...args, styles: { letterSpacing: '0.08em' } }, 'Custom styled'),
+};
+export const RealWorldExamples: Story = AllVariants;
+export const IsLoading: Story = Loading;
+export const WithAccessibility: Story = { args: { ariaLabel: 'Save changes', label: 'Save' } };
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('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..dfd56cb
--- /dev/null
+++ b/libs/web-components/stories/Checkbox.stories.ts
@@ -0,0 +1,43 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdCheckbox } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Checkbox',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Checkbox matching React controlled, indeterminate, disabled, size, native form, and change-event behavior.',
+ },
+ },
+ },
+ 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;
+ },
+};
+export const Controlled: Story = Checked;
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('checkbox') };
diff --git a/libs/web-components/stories/Counter.stories.ts b/libs/web-components/stories/Counter.stories.ts
new file mode 100644
index 0000000..77dd3f1
--- /dev/null
+++ b/libs/web-components/stories/Counter.stories.ts
@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdCounter } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Molecules/Counter',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Counter matching React minimum, maximum, initial value, external change handling, disabled state, and accessible native controls.',
+ },
+ },
+ },
+ 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 } };
+export const AdjustedMaxValue5: Story = { args: { max: 5, initial: 5 } };
+export const AdjustedMinValue3: Story = { args: { min: 3, initial: 3 } };
+export const AdjustedMin2MaxValue10: Story = CustomRange;
+export const WithExternalCounterChangeHandler: Story = Default;
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('counter') };
diff --git a/libs/web-components/stories/Icon.stories.ts b/libs/web-components/stories/Icon.stories.ts
new file mode 100644
index 0000000..5a68ea6
--- /dev/null
+++ b/libs/web-components/stories/Icon.stories.ts
@@ -0,0 +1,69 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import { iconCatalog, type GridKitIconName } from 'gd-design-core';
+import type { GdIcon } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick;
+const names = Object.keys(iconCatalog) as GridKitIconName[];
+const meta = {
+ title: 'Atoms/Icon',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'The same built-in SVG icon catalog used by React Icon. Supports theme sizes, exact dimensions, path and SVG fills, native click events, and accessible names.',
+ },
+ },
+ },
+ args: { name: 'star', size: 'md', fill: 'currentColor' },
+ argTypes: {
+ name: { description: 'Icon name from the shared React/Web Components catalog', control: 'select', options: names },
+ size: {
+ description: 'Theme size applied to width and height',
+ control: 'select',
+ options: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'],
+ },
+ fill: { description: 'Path fill or theme color token', control: 'color' },
+ },
+ render: (args: Args) => element('gd-icon', args),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const WithDefinedSize: Story = { args: { size: 'xl' } };
+export const AllIcons: Story = {
+ name: 'Icons Library',
+ render: () => {
+ const grid = document.createElement('div');
+ grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:16px';
+ for (const name of names) {
+ const item = document.createElement('label');
+ item.append(element('gd-icon', { name, size: 'lg' }), document.createTextNode(name));
+ grid.append(item);
+ }
+ return grid;
+ },
+};
+export const RegisteringCustomIcons: Story = {
+ name: 'Registering a Custom Icon',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Built-in icons use the shared catalog. Application-specific SVGs should be registered in that catalog generator so React and Web Components retain one source.',
+ },
+ },
+ },
+ args: { name: 'star' },
+};
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const icon = element('gd-icon', args);
+ icon.setAttribute('aria-label', 'Favorite');
+ return icon;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('icon'),
+};
diff --git a/libs/web-components/stories/Image.stories.ts b/libs/web-components/stories/Image.stories.ts
new file mode 100644
index 0000000..08fef8f
--- /dev/null
+++ b/libs/web-components/stories/Image.stories.ts
@@ -0,0 +1,88 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdImage } from '../src';
+import { defaultTokenViewer, element, portrait } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Image',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Responsive image matching React Image loading, placeholder, fallback, caption, object-fit, click, load/error events, and accessibility behavior. Slots replace React node props; native figure/figcaption semantics replace polymorphic wrapper props.',
+ },
+ },
+ },
+ args: { src: portrait, alt: 'Portrait', width: 160, height: 160, caption: '', objectFit: 'cover' },
+ argTypes: {
+ src: { description: 'Image source URL', control: 'text' },
+ alt: { description: 'Accessible image alternative text', control: 'text' },
+ width: { description: 'Rendered image width in pixels', control: 'number' },
+ height: { description: 'Rendered image height in pixels', control: 'number' },
+ caption: { description: 'Optional visible image caption', control: 'text' },
+ objectFit: {
+ description: 'Native object-fit behavior',
+ control: 'select',
+ options: ['cover', 'contain', 'fill', 'none', 'scale-down'],
+ },
+ },
+ render: (args: Args) => element('gd-image', args),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const WithCaption: Story = { args: { caption: 'Ada Lovelace' } };
+export const WithPlaceholder: Story = {
+ render: (args) => {
+ const image = element('gd-image', { ...args, src: 'data:image/png;base64,' });
+ const placeholder = document.createElement('span');
+ placeholder.slot = 'placeholder';
+ placeholder.textContent = 'Loading…';
+ image.append(placeholder);
+ return image;
+ },
+};
+export const FallbackImage: Story = {
+ render: (args) => {
+ const image = element('gd-image', { ...args, src: '' });
+ const fallback = document.createElement('span');
+ fallback.slot = 'fallback';
+ fallback.textContent = 'Image unavailable';
+ image.append(fallback);
+ return image;
+ },
+};
+export const Clickable: Story = {
+ render: (args) => {
+ const image = element('gd-image', args);
+ image.style.cursor = 'pointer';
+ image.tabIndex = 0;
+ image.setAttribute('role', 'button');
+ return image;
+ },
+};
+export const WithAsProp: Story = {
+ ...Default,
+ parameters: {
+ docs: {
+ description: {
+ story: 'Custom Elements keep a stable gd-image host; the internal wrapper uses the semantic figure element.',
+ },
+ },
+ },
+};
+export const WithCaptionAsProp: Story = {
+ args: { caption: 'Semantic figcaption' },
+ parameters: {
+ docs: {
+ description: {
+ story: 'The native port always uses figcaption, the semantic default of the React captionAs prop.',
+ },
+ },
+ },
+};
+export const WithAccessibility: Story = { args: { alt: 'Portrait of Ada Lovelace', caption: 'Ada Lovelace' } };
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('image'),
+};
diff --git a/libs/web-components/stories/Input.stories.ts b/libs/web-components/stories/Input.stories.ts
new file mode 100644
index 0000000..600694b
--- /dev/null
+++ b/libs/web-components/stories/Input.stories.ts
@@ -0,0 +1,139 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdInput } from '../src';
+import { defaultTokenViewer, 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'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Input matching React labels, helper text, validation colors, disabled/read-only states, adornments, styles, checkbox/radio modes, accessibility, and native events.',
+ },
+ },
+ },
+ 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;
+ },
+};
+export const PrimaryDefaultWithLabelAndHelperText: Story = Default;
+export const SuccessWithLabelAndHelperText: Story = { args: { color: 'success' } };
+export const WarningWithLabelAndHelperText: Story = { args: { color: 'warning' } };
+export const ErrorWithLabelAndHelperText: Story = { args: { color: 'error' } };
+export const WrapperAsSpan: Story = {
+ ...Default,
+ parameters: {
+ docs: {
+ description: { story: 'The Custom Element host is stable; internal layout provides the React wrapper behavior.' },
+ },
+ },
+};
+export const WithStartAdornment: Story = WithAdornments;
+export const WithEndAdornmentAsIcon: Story = WithAdornments;
+export const DefaultWithTailwind: Story = {
+ render: (args) => element('gd-input', { ...args, styles: { fontWeight: 700 } }),
+};
+export const CustomStyles: Story = {
+ render: (args) => element('gd-input', { ...args, styles: { letterSpacing: '0.05em' } }),
+};
+export const CheckboxWithLabel: Story = { args: { variant: 'checkbox', value: '', label: 'Accept terms' } };
+export const RadioControlledWithLabel: Story = { args: { variant: 'radio', value: 'one', label: 'Option one' } };
+export const RadioGroupWithLabel: Story = InputTypes;
+export const WithAccessibility: Story = {
+ args: { required: true, label: 'Email address', helperText: 'Required field' },
+};
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('input') };
diff --git a/libs/web-components/stories/InputFile.stories.ts b/libs/web-components/stories/InputFile.stories.ts
new file mode 100644
index 0000000..12bd498
--- /dev/null
+++ b/libs/web-components/stories/InputFile.stories.ts
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdInputFile } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/InputFile',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'File picker matching React InputFile accept, capture, multiple, disabled, icon-label, button variant, custom styles, and accessibility behavior. Emits gd-change with serializable file metadata.',
+ },
+ },
+ },
+ args: { accept: '', capture: undefined, multiple: false, disabled: false, isIcon: false, buttonVariant: 'outlined' },
+ argTypes: {
+ accept: { description: 'Accepted file MIME types or extensions', control: 'text' },
+ capture: {
+ description: 'Preferred camera capture source',
+ control: 'select',
+ options: [undefined, 'user', 'environment'],
+ },
+ multiple: { description: 'Allows selection of multiple files', control: 'boolean' },
+ disabled: { description: 'Disables the file picker', control: 'boolean' },
+ isIcon: { description: 'Uses icon-only button sizing', control: 'boolean' },
+ buttonVariant: {
+ description: 'Visual variant passed to the internal button',
+ control: 'select',
+ options: ['primary', 'secondary', 'outlined'],
+ },
+ },
+ render: (args: Args) => observed(element('gd-input-file', args), 'gd-change', { files: [] }),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const Disabled: Story = { args: { disabled: true } };
+export const Multiple: Story = { args: { multiple: true } };
+export const WithAccept: Story = { args: { accept: 'image/*' } };
+export const CustomLabel: Story = { render: (args) => element('gd-input-file', args, 'Choose documents') };
+export const IconLabel: Story = {
+ render: (args) => {
+ const input = element('gd-input-file', { ...args, isIcon: true });
+ input.append(element('gd-icon', { name: 'upload' }));
+ return input;
+ },
+};
+export const WithCustomStyles: Story = {
+ render: (args) => element('gd-input-file', { ...args, styles: { borderRadius: '8px' } }, 'Styled file picker'),
+};
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const input = element('gd-input-file', args, 'Upload résumé');
+ input.setAttribute('aria-label', 'Upload résumé');
+ return input;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('inputfile'),
+};
diff --git a/libs/web-components/stories/Label.stories.ts b/libs/web-components/stories/Label.stories.ts
new file mode 100644
index 0000000..bef2f96
--- /dev/null
+++ b/libs/web-components/stories/Label.stories.ts
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdLabel } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Label',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Native label matching React Label content, child icons, custom styles, control association, and accessibility semantics.',
+ },
+ },
+ },
+ args: { htmlFor: 'example' },
+ argTypes: {
+ htmlFor: { description: 'ID of the associated form control', control: 'text' },
+ styles: { description: 'Custom inline style overrides', control: 'object' },
+ },
+ render: (args: Args) => element('gd-label', args, 'Field label'),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const LabelDefault: Story = {};
+export const LabelWithChildIcon: Story = {
+ render: (args) => {
+ const label = element('gd-label', args, 'Label ');
+ label.append(element('gd-icon', { name: 'info' }));
+ return label;
+ },
+};
+export const CustomStyles: Story = { args: { styles: { color: '#0069b4', fontWeight: 700, gap: '8px' } } };
+export const DefaultWithTailwind: Story = {
+ args: { styles: { fontSize: '18px', color: '#1f843a' } },
+ parameters: {
+ docs: { description: { story: 'Framework-neutral style properties reproduce the React utility-class example.' } },
+ },
+};
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const root = document.createElement('div');
+ const label = element('gd-label', { ...args, htmlFor: 'label-story-input' }, 'Email address');
+ const input = document.createElement('input');
+ input.id = 'label-story-input';
+ root.append(label, input);
+ return root;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('label'),
+};
diff --git a/libs/web-components/stories/Link.stories.ts b/libs/web-components/stories/Link.stories.ts
new file mode 100644
index 0000000..127bde0
--- /dev/null
+++ b/libs/web-components/stories/Link.stories.ts
@@ -0,0 +1,92 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdLink } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick<
+ GdLink,
+ 'variant' | 'size' | 'underline' | 'color' | 'cursor' | 'disabled' | 'href' | 'target' | 'rel' | 'styles'
+>;
+const meta = {
+ title: 'Atoms/Link',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Native anchor matching React Link variants, sizes, underline modes, colors, cursor, disabled behavior, targets, relationships, custom styles, accessibility, and child composition.',
+ },
+ },
+ },
+ args: {
+ variant: 'primary',
+ size: 'md',
+ underline: 'default',
+ disabled: false,
+ href: 'https://example.com',
+ target: undefined,
+ rel: '',
+ },
+ argTypes: {
+ variant: {
+ description: 'Design variant',
+ control: 'select',
+ options: ['primary', 'secondary', 'inverted', 'inherit'],
+ },
+ size: { description: 'Link typography size', control: 'select', options: ['sm', 'md', 'lg'] },
+ underline: { description: 'Underline presentation', control: 'select', options: ['default', 'highlight', 'none'] },
+ color: { description: 'CSS color or theme color token', control: 'text' },
+ cursor: { description: 'CSS cursor', control: 'text' },
+ disabled: { description: 'Prevents navigation and applies disabled styling', control: 'boolean' },
+ href: { description: 'Navigation URL', control: 'text' },
+ target: { description: 'Browsing context target', control: 'text' },
+ rel: { description: 'Anchor relationship attribute', control: 'text' },
+ styles: { description: 'Custom inline style overrides', control: 'object' },
+ },
+ render: (args: Args) => element('gd-link', args, 'GridKit link'),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Variants: Story = {};
+export const Disabled: Story = { args: { disabled: true } };
+export const WithUnderline: Story = { args: { underline: 'highlight' } };
+export const WithSizes: Story = { args: { size: 'lg' } };
+export const TargetBlankVisited: Story = {
+ args: {
+ variant: 'inherit',
+ href: 'https://storybook.cto-rnd-system-design.griddynamics.net/',
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+};
+export const InheritWithButtonAsChild: Story = {
+ render: (args) => {
+ const link = element('gd-link', { ...args, variant: 'inherit' });
+ link.append(element('gd-button', { variant: 'primary' }, 'Button child'));
+ return link;
+ },
+};
+export const Inverted: Story = { args: { variant: 'inverted' } };
+export const InheritWithTypographyAsChild: Story = {
+ render: (args) => {
+ const link = element('gd-link', { ...args, variant: 'inherit' });
+ link.append(element('gd-typography', { variant: 'p' }, 'Typography child'));
+ return link;
+ },
+};
+export const InheritWithTailwind: Story = {
+ args: { variant: 'inherit', styles: { fontWeight: 700, textTransform: 'uppercase' } },
+ parameters: {
+ docs: { description: { story: 'Framework-neutral style properties reproduce the React utility-class example.' } },
+ },
+};
+export const CustomStyles: Story = { args: { styles: { color: '#7b2cbf', letterSpacing: '0.08em' } } };
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const link = element('gd-link', args, 'Read the accessibility guide');
+ link.setAttribute('aria-label', 'Read the accessibility guide');
+ return link;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('link'),
+};
diff --git a/libs/web-components/stories/Loader.stories.ts b/libs/web-components/stories/Loader.stories.ts
new file mode 100644
index 0000000..683b4b2
--- /dev/null
+++ b/libs/web-components/stories/Loader.stories.ts
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdLoader } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Loader',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Loading indicator matching React Loader circle/dots animations, five sizes, rounding, inline/section/full-page wrappers, custom animation timing, custom content, styles, and status accessibility.',
+ },
+ },
+ },
+ args: { name: 'circle', variant: 'inline', size: 'md', rounded: 'none', withWrapper: true },
+ argTypes: {
+ name: { control: 'select', options: ['circle', 'dots'] },
+ variant: { control: 'select', options: ['inline', 'section', 'fullPage'] },
+ size: { control: 'select', options: ['xs', 'sm', 'md', 'lg', 'xl'] },
+ rounded: {
+ description: 'Radius token used by dot loaders',
+ control: 'select',
+ options: ['none', 'default', 'round', 'xs', 'sm', 'md', 'lg', 'xl'],
+ },
+ withWrapper: { description: 'Whether the positioning wrapper is rendered', control: 'boolean' },
+ animationProps: { description: 'CSS animation timing, easing, and iteration values', control: 'text' },
+ styles: { description: 'Custom loader style overrides', control: 'object' },
+ },
+ render: (args: Args) => element('gd-loader', args),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const LoaderNames: Story = { args: { name: 'dots' } };
+export const LoaderSizes: Story = { args: { size: 'xl' } };
+export const LoaderRounded: Story = { args: { name: 'dots', rounded: 'round' } };
+export const LoaderSectionVariant: Story = { args: { variant: 'section' } };
+export const LoaderVariantsWithWrapperViewAsHeaderTag: Story = {
+ args: { variant: 'inline' },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Custom Elements retain a stable gd-loader host; withWrapper controls the internal native span wrapper.',
+ },
+ },
+ },
+};
+export const CustomLoaderTailwindClassBounceAnimation: Story = {
+ args: { name: 'dots', animationProps: '600ms ease-in-out infinite', styles: { color: '#7b2cbf' } },
+};
+export const SectionLoaderButtonVariant: Story = {
+ render: (args) => {
+ const button = element('gd-button', { variant: 'primary' }, 'Loading');
+ button.append(element('gd-loader', { ...args, variant: 'section', size: 'sm' }));
+ return button;
+ },
+};
+export const InlineLoaderButtonVariant: Story = {
+ render: (args) => {
+ const button = element('gd-button', { variant: 'primary' }, 'Save ');
+ button.append(element('gd-loader', { ...args, variant: 'inline', size: 'sm', withWrapper: false }));
+ return button;
+ },
+};
+export const WithAccessibility: Story = {
+ render: (args) => {
+ const loader = element('gd-loader', args);
+ loader.setAttribute('aria-label', 'Saving changes');
+ return loader;
+ },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('loader'),
+};
diff --git a/libs/web-components/stories/Menu.stories.ts b/libs/web-components/stories/Menu.stories.ts
new file mode 100644
index 0000000..7d6b6cc
--- /dev/null
+++ b/libs/web-components/stories/Menu.stories.ts
@@ -0,0 +1,81 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdMenu } from '../src';
+import { defaultTokenViewer, 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'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Menu matching React close-on-select, placement, imperative controls, offsets, height constraints, positioning, modal actions, and native popover behavior.',
+ },
+ },
+ },
+ 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 } };
+export const CloseOnSelectFalse: Story = KeepOpenOnSelect;
+export const WithRefControl: Story = Open;
+export const WithPositioningOptions: Story = Placement;
+export const WithEditAndDeleteModals: Story = Default;
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('menu') };
diff --git a/libs/web-components/stories/Overview.stories.ts b/libs/web-components/stories/Overview.stories.ts
index df96d5d..0478c7f 100644
--- a/libs/web-components/stories/Overview.stories.ts
+++ b/libs/web-components/stories/Overview.stories.ts
@@ -5,17 +5,162 @@ const meta = {
parameters: { controls: { disable: true } },
render: () => {
const page = document.createElement('main');
- page.style.cssText = 'max-width: 760px; padding: 24px; font-family: "Fira Sans", sans-serif; line-height: 1.6';
+ page.style.cssText =
+ 'max-width: 900px; padding: 24px; font-family: "Fira Sans", sans-serif; line-height: 1.6; color: #262626';
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:
-
- 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.
+
+
+
+ Using Web Components
+
+ GridKit Web Components are framework-independent custom HTML elements built with Lit. They expose the
+ same design tokens, variants, states, and interaction patterns as the React components while using
+ standard browser APIs. Use them in plain HTML or inside Angular, Vue, React, and other web frameworks
+ without a framework-specific GridKit adapter.
+
+
+ Component names start with gd-, for example <gd-button>,
+ <gd-input>, and <gd-select>. Browse the Atoms and
+ Molecules sections to inspect each component's properties, variants, events, slots,
+ accessibility behavior, and token defaults.
+
+
+ Install and register
+ Install the component package and the GridKit design tokens in your application:
+ npm install web-components gd-design-library
+
+ Import the package once in your application entry point. The import registers every
+ gd-* custom element with the browser.
+
+ import 'web-components';
+import { defaultTheme } from 'gd-design-library/tokens';
+
+ How values and events work
+
+ Pass text, numbers, and booleans through HTML attributes or framework bindings.
+ Assign objects, arrays, functions, controlled values, and the theme as JavaScript properties.
+ Listen for native events such as gd-input and gd-change.
+ Read component data from event.detail.
+ Use default and named slots for labels, icons, custom initiators, and other composed content.
+
+
+ The theme is an object, so it must be assigned as a property. Writing
+ theme="defaultTheme" in HTML passes only a string and does not apply the theme.
+
+
+ Plain JavaScript example
+ const input = document.querySelector('gd-input');
+
+input.theme = defaultTheme;
+input.value = 'hello@example.com';
+input.addEventListener('gd-input', (event) => {
+ console.log(event.detail.value);
+});
+
+ Angular
+
+ Import the package in main.ts, enable custom elements with
+ CUSTOM_ELEMENTS_SCHEMA, and use Angular property and event bindings. Property bindings are
+ required for themes, arrays, objects, and controlled values.
+
+ // main.ts
+import 'web-components';
+
+// app.component.ts
+import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { defaultTheme } from 'gd-design-library/tokens';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ template: \`
+ <gd-input
+ [theme]="theme"
+ label="Email"
+ [value]="email"
+ (gd-input)="email = $any($event).detail.value"
+ ></gd-input>
+
+ <gd-checkbox
+ [theme]="theme"
+ [checked]="accepted"
+ (gd-change)="accepted = $any($event).detail.checked"
+ >
+ Accept terms
+ </gd-checkbox>
+
+ <gd-button [theme]="theme" variant="primary" (click)="save()">
+ Save
+ </gd-button>
+ \`,
+})
+export class AppComponent {
+ readonly theme = defaultTheme;
+ email = '';
+ accepted = false;
+
+ save() {}
+}
+
+ Vue
+
+ Import the package in the application entry point. In Vue templates, use .prop when a
+ value must be assigned to the custom element as a JavaScript property, and use Vue's normal
+ @event syntax for native custom events.
+
+ // main.ts
+import { createApp } from 'vue';
+import 'web-components';
+import App from './App.vue';
+
+createApp(App).mount('#app');
+ <script setup lang="ts">
+import { ref } from 'vue';
+import { defaultTheme } from 'gd-design-library/tokens';
+
+const email = ref('');
+const accepted = ref(false);
+</script>
+
+<template>
+ <gd-input
+ :theme.prop="defaultTheme"
+ label="Email"
+ :value.prop="email"
+ @gd-input="email = $event.detail.value"
+ />
+
+ <gd-checkbox
+ :theme.prop="defaultTheme"
+ :checked.prop="accepted"
+ @gd-change="accepted = $event.detail.checked"
+ >
+ Accept terms
+ </gd-checkbox>
+
+ <gd-button :theme.prop="defaultTheme" variant="primary">
+ Save
+ </gd-button>
+</template>
+
+ Explore component APIs
+
+ Open a component story and use Controls to change its public properties. Interactive
+ stories display emitted event details next to the component. Each documentation page also includes
+ accessibility examples and the default design tokens used by that component.
+
+
`;
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..d3c3e1f
--- /dev/null
+++ b/libs/web-components/stories/Select.stories.ts
@@ -0,0 +1,114 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdSelect } from '../src';
+import { defaultTokenViewer, 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'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Select matching React single/multiple selection, adornments, custom initiator, refs, custom identifiers, validation colors, search, disabled/empty states, and accessibility.',
+ },
+ },
+ },
+ 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;
+ },
+};
+export const MultipleSelect: Story = Multiple;
+export const WithAdornmentsAndResetOptionStory: Story = WithAdornments;
+export const WithCustomInitiatorAndSelectedOutputValue: Story = CustomInitiator;
+export const UsingRefComponentWithCustomPlaceholder: Story = { args: { placeholder: 'Choose through component API' } };
+export const WithFileInputAndCustomDropdown: Story = {
+ ...Default,
+ parameters: {
+ docs: { description: { story: 'Native slots and item rendering provide the custom-dropdown composition point.' } },
+ },
+};
+export const CustomItemIdentifierComponent: Story = { args: { value: items[0] } };
+export const WithAccessibility: Story = { args: { placeholder: 'Choose an accessible option' } };
+export const EmptyItems: Story = Empty;
+export const ColorPrimary: Story = { args: { color: 'primary' } };
+export const ColorSuccess: Story = { args: { color: 'success' } };
+export const ColorWarning: Story = { args: { color: 'warning' } };
+export const ColorError: Story = { args: { color: 'error' } };
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('select') };
diff --git a/libs/web-components/stories/Separator.stories.ts b/libs/web-components/stories/Separator.stories.ts
new file mode 100644
index 0000000..cf62b70
--- /dev/null
+++ b/libs/web-components/stories/Separator.stories.ts
@@ -0,0 +1,114 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdSeparator } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick<
+ GdSeparator,
+ 'orientation' | 'length' | 'color' | 'size' | 'variant' | 'as' | 'label' | 'labelPosition' | 'labelColor' | 'styles'
+>;
+const meta = {
+ title: 'Atoms/Separator',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A token-backed visual divider with horizontal and vertical layouts, solid/dashed/dotted lines, optional positioned labels, semantic internal elements, custom lengths and theme-aware colors.',
+ },
+ },
+ },
+ args: { orientation: 'horizontal', size: 'sm', variant: 'solid', as: 'div', labelPosition: 'center' },
+ argTypes: {
+ orientation: {
+ description: 'The orientation of the separator.',
+ control: 'select',
+ options: ['horizontal', 'vertical'],
+ },
+ styles: { description: 'Custom styles for the separator.', control: 'object' },
+ length: { description: 'The length of the separator, such as 100px or 50%.', control: 'text' },
+ color: { description: 'A CSS color or GridKit theme color path for the line.', control: 'text' },
+ size: {
+ description: 'The thickness and label size.',
+ control: 'select',
+ options: ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'],
+ },
+ variant: { description: 'The line style.', control: 'select', options: ['solid', 'dashed', 'dotted'] },
+ as: {
+ description: 'The internal semantic element used for the wrapper and line segments.',
+ control: 'select',
+ options: ['div', 'hr', 'span'],
+ },
+ label: { description: 'Text displayed within the separator.', control: 'text' },
+ labelPosition: {
+ description: 'The label position when present.',
+ control: 'select',
+ options: ['start', 'center', 'end'],
+ },
+ labelColor: { description: 'A CSS color or GridKit theme color path for the label.', control: 'text' },
+ },
+ render: (args: Args) => {
+ const wrapper = document.createElement('div');
+ wrapper.style.width = '300px';
+ wrapper.append(element('gd-separator', args));
+ return wrapper;
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const WithCustomColor: Story = { args: { color: 'brand.500' } };
+export const WithDifferentVariants: Story = {
+ render: () => {
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'width:400px;display:grid;gap:20px';
+ for (const variant of ['solid', 'dashed', 'dotted'] as const)
+ wrapper.append(element('gd-separator', { size: 'md', variant }));
+ return wrapper;
+ },
+};
+export const WithLabel: Story = {
+ render: () => {
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'width:400px;display:grid;gap:20px';
+ wrapper.append(
+ element('gd-separator', { size: 'md', label: 'Label', labelPosition: 'start' }),
+ element('gd-separator', { size: 'md', label: 'Label', labelColor: 'brand.500' }),
+ element('gd-separator', { size: 'md', label: 'Label', labelPosition: 'end' })
+ );
+ return wrapper;
+ },
+};
+export const Vertical: Story = {
+ render: () => {
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'display:flex;gap:20px;align-items:center';
+ wrapper.append(
+ 'Title 1 — Description 1',
+ element('gd-separator', { size: 'md', length: '200px', variant: 'solid', orientation: 'vertical' }),
+ 'Title 2 — Description 2'
+ );
+ return wrapper;
+ },
+};
+export const VerticalWithLabel: Story = {
+ render: () => {
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'display:flex;gap:20px;align-items:center';
+ wrapper.append(
+ 'Title 1 — Description 1',
+ element('gd-separator', { size: 'md', length: '200px', variant: 'solid', orientation: 'vertical', label: 'Or' }),
+ 'Title 2 — Description 2'
+ );
+ return wrapper;
+ },
+};
+export const WithAccessibility: Story = {
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('separator'),
+ parameters: { layout: 'padded' },
+};
diff --git a/libs/web-components/stories/Skeleton.stories.ts b/libs/web-components/stories/Skeleton.stories.ts
new file mode 100644
index 0000000..c56ad0c
--- /dev/null
+++ b/libs/web-components/stories/Skeleton.stories.ts
@@ -0,0 +1,93 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdSkeleton } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick<
+ GdSkeleton,
+ 'variant' | 'width' | 'height' | 'backgroundColor' | 'animationName' | 'animationProps' | 'styles'
+>;
+const meta = {
+ title: 'Atoms/Skeleton',
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A theme-aware animated loading placeholder with rounded, rectangular, and circular shapes, flexible dimensions, custom colors and animations, and slotted child content.',
+ },
+ },
+ },
+ args: { width: '250px', height: '15px', variant: 'rounded', animationName: 'blinkKeyframes' },
+ argTypes: {
+ variant: {
+ description: 'Controls the shape of the skeleton.',
+ control: 'select',
+ options: ['rounded', 'rectangular', 'circular'],
+ },
+ width: { description: 'Sets the width using any valid CSS width.', control: 'text' },
+ height: { description: 'Sets the height using any valid CSS height.', control: 'text' },
+ backgroundColor: { description: 'A CSS color or GridKit theme/palette color path.', control: 'text' },
+ animationName: {
+ description: 'A theme animation token, raw CSS animation name, or null to disable animation.',
+ control: 'text',
+ },
+ animationProps: { description: 'Animation duration, easing, delay, and iteration string.', control: 'text' },
+ styles: { description: 'Custom CSS properties applied to the skeleton.', control: 'object' },
+ },
+ render: (args: Args) => element('gd-skeleton', args),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ parameters: { docs: { description: { story: 'The standard rounded skeleton for text-line placeholders.' } } },
+};
+export const Circular: Story = {
+ args: { width: '80px', height: '80px', variant: 'circular' },
+ parameters: { docs: { description: { story: 'A circular placeholder for avatars and icons.' } } },
+};
+export const Rectangular: Story = {
+ args: { width: '250px', height: '125px', variant: 'rectangular' },
+ parameters: { docs: { description: { story: 'A rectangular placeholder for images, cards, and video.' } } },
+};
+export const WithThemeColor: Story = {
+ args: { width: '250px', height: '40px', variant: 'rectangular', backgroundColor: 'theme.palette.success.main' },
+ parameters: { docs: { description: { story: 'Uses the top-level theme-aware backgroundColor property.' } } },
+};
+export const WithCustomAnimation: Story = {
+ name: 'Animation with Tailwind Class',
+ args: { width: '250px', height: '15px', animationName: null },
+ parameters: {
+ docs: { description: { story: 'Disables the built-in animation so a consumer class can supply one.' } },
+ },
+};
+export const WithChildren: Story = {
+ name: 'Skeleton with Child Content',
+ render: (args) => element('gd-skeleton', { ...args, width: '250px', height: '50px' }, 'Loading Content...'),
+ parameters: {
+ docs: { description: { story: 'The default slot preserves child content inside the animated wrapper.' } },
+ },
+};
+export const ComposedLayout: Story = {
+ name: 'Example: Article Placeholder',
+ render: () => {
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'display:grid;grid-template-columns:100px 1fr;gap:10px;max-width:480px';
+ wrapper.append(element('gd-skeleton', { variant: 'circular', width: '100px', height: '100px' }));
+ const lines = document.createElement('div');
+ lines.style.cssText = 'display:grid;gap:10px';
+ lines.append(element('gd-skeleton'), element('gd-skeleton'), element('gd-skeleton'));
+ wrapper.append(lines);
+ return wrapper;
+ },
+ parameters: { docs: { description: { story: 'Composes multiple skeletons as an article or profile placeholder.' } } },
+};
+export const WithAccessibility: Story = {
+ args: { 'aria-label': 'Loading content' } as never,
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('skeleton'),
+ parameters: { layout: 'padded' },
+};
diff --git a/libs/web-components/stories/Slider.stories.ts b/libs/web-components/stories/Slider.stories.ts
new file mode 100644
index 0000000..2d20590
--- /dev/null
+++ b/libs/web-components/stories/Slider.stories.ts
@@ -0,0 +1,54 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdSlider } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Slider',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A token-backed native range control with controlled values, keyboard accessibility, visual track fill, disabled state, and gd-change value events.',
+ },
+ },
+ },
+ args: { min: 1, max: 100, value: 1, step: 1, disabled: false },
+ argTypes: {
+ min: { description: 'Minimum value of the slider range', control: 'number' },
+ max: { description: 'Maximum value of the slider range', control: 'number' },
+ value: { description: 'Current slider value', control: 'number' },
+ step: { description: 'Increment between permitted values', control: 'number' },
+ disabled: { description: 'Whether the slider is disabled', control: 'boolean' },
+ styles: { description: 'Custom slider style overrides', control: 'object' },
+ },
+ render: (args: Args) => observed(element('gd-slider', args), 'gd-change', { value: args.value }),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const WithInitialValue: Story = { args: { value: 50 } };
+export const Disabled: Story = { args: { disabled: true, value: 30 } };
+export const CustomStyles: Story = {
+ args: { value: 70, styles: { backgroundColor: 'lightyellow', borderRadius: '8px' } },
+};
+export const Controlled: Story = {
+ render: (args) => {
+ const slider = element('gd-slider', { ...args, value: 25 });
+ slider.addEventListener('gd-change', (e) => (slider.value = (e as CustomEvent<{ value: number }>).detail.value));
+ return observed(slider, 'gd-change', { value: 25 });
+ },
+ parameters: {
+ docs: { description: { story: 'The value property is updated externally from each gd-change event.' } },
+ },
+};
+export const WithAccessibility: Story = {
+ args: { 'aria-label': 'Select volume level' } as never,
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('slider'),
+};
diff --git a/libs/web-components/stories/SliderDots.stories.ts b/libs/web-components/stories/SliderDots.stories.ts
new file mode 100644
index 0000000..9fbcdb4
--- /dev/null
+++ b/libs/web-components/stories/SliderDots.stories.ts
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdSliderDots } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/SliderDots',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'Accessible carousel navigation dots with a controlled active index, theme-backed active/hover states, and gd-change index events.',
+ },
+ },
+ },
+ args: { count: 5, activeIndex: 0 },
+ argTypes: {
+ count: { description: 'Total number of dots to render', control: 'number' },
+ activeIndex: { description: 'Zero-based index of the active dot', control: 'number' },
+ styles: { description: 'Custom container styles', control: 'object' },
+ },
+ render: (args: Args) => observed(element('gd-slider-dots', args), 'gd-change', { index: args.activeIndex }),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {
+ parameters: { docs: { description: { story: 'Five dots with the first dot active.' } } },
+};
+export const Interactive: Story = {
+ render: () => {
+ const dots = element('gd-slider-dots', { count: 5 });
+ const output = document.createElement('output');
+ output.textContent = 'Active slide: 1 of 5';
+ dots.addEventListener(
+ 'gd-change',
+ (e) => (output.textContent = `Active slide: ${(e as CustomEvent<{ index: number }>).detail.index + 1} of 5`)
+ );
+ const section = document.createElement('section');
+ section.append(output, dots);
+ return section;
+ },
+ parameters: { docs: { description: { story: 'Clicking a dot updates the active slide output.' } } },
+};
+export const ManyDots: Story = {
+ args: { count: 10, activeIndex: 4 },
+ parameters: { docs: { description: { story: 'Ten dots with the fifth active.' } } },
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('sliderDots'),
+};
diff --git a/libs/web-components/stories/Switch.stories.ts b/libs/web-components/stories/Switch.stories.ts
new file mode 100644
index 0000000..100d545
--- /dev/null
+++ b/libs/web-components/stories/Switch.stories.ts
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdSwitch } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const meta = {
+ title: 'Atoms/Switch',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'An accessible token-backed on/off control with label placement, checked, disabled and loading states, slotted label content, and gd-change events.',
+ },
+ },
+ },
+ args: { name: 'switch', checked: false, disabled: false, isLoading: false, label: 'right' },
+ argTypes: {
+ checked: { description: 'Checked state', control: 'boolean' },
+ disabled: { description: 'Disables interaction', control: 'boolean' },
+ isLoading: { description: 'Shows a loader and disables interaction', control: 'boolean' },
+ label: { description: 'Position of the slotted label', control: 'select', options: ['left', 'right'] },
+ name: { description: 'Native checkbox name', control: 'text' },
+ styles: { description: 'Custom wrapper styles', control: 'object' },
+ },
+ render: (args: Args) => observed(element('gd-switch', args, 'Label'), '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 Disabled: Story = { args: { disabled: true } };
+export const LabelLeft: Story = {
+ args: { label: 'left' },
+ render: (args) => element('gd-switch', args, 'Label on the left'),
+};
+export const Controlled: Story = {
+ render: () => {
+ const sw = element('gd-switch', { checked: false }, 'Controlled Switch, with 1.5 seconds delay');
+ sw.addEventListener('gd-change', (e) =>
+ setTimeout(() => (sw.checked = (e as CustomEvent<{ checked: boolean }>).detail.checked), 1500)
+ );
+ return observed(sw, 'gd-change', { checked: false });
+ },
+ parameters: { docs: { description: { story: 'Controlled state is written back externally after a delay.' } } },
+};
+export const Uncontrolled: Story = {
+ render: () => observed(element('gd-switch', {}, 'Uncontrolled Switch'), 'gd-change', { checked: false }),
+ parameters: { docs: { description: { story: 'The element manages its checked state and emits each change.' } } },
+};
+export const WithLoading: Story = {
+ args: { isLoading: true },
+ render: (args) => element('gd-switch', args, 'Switch with Loading State (3 seconds)'),
+ parameters: { docs: { description: { story: 'Loading composes gd-loader and disables the native checkbox.' } } },
+};
+export const WithAccessibility: Story = {
+ args: { name: 'notifications' },
+ render: (args) => element('gd-switch', args, 'Enable notifications'),
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('switch', 'switchToken'),
+};
diff --git a/libs/web-components/stories/Textarea.stories.ts b/libs/web-components/stories/Textarea.stories.ts
new file mode 100644
index 0000000..e5e41d8
--- /dev/null
+++ b/libs/web-components/stories/Textarea.stories.ts
@@ -0,0 +1,155 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdTextarea } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick<
+ GdTextarea,
+ | 'name'
+ | 'placeholder'
+ | 'value'
+ | 'defaultValue'
+ | 'minHeight'
+ | 'maxHeight'
+ | 'disabled'
+ | 'readOnly'
+ | 'autoFocus'
+ | 'resize'
+ | 'rows'
+ | 'dynamicHeightAdjustment'
+ | 'variant'
+ | 'color'
+ | 'maxLength'
+ | 'maxCharacters'
+ | 'styles'
+>;
+const meta = {
+ title: 'Atoms/Textarea',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'An accessible multi-line text input with controlled and uncontrolled values, resize modes, dynamic height, validation colors, character limits, focus management, and native gd-input/gd-change events.',
+ },
+ },
+ },
+ args: {
+ name: 'textarea',
+ resize: 'none',
+ variant: 'default',
+ color: 'primary',
+ disabled: false,
+ readOnly: false,
+ dynamicHeightAdjustment: false,
+ },
+ argTypes: {
+ name: { description: 'Native field name', control: 'text' },
+ placeholder: { description: 'Placeholder text', control: 'text' },
+ value: { description: 'Controlled value', control: 'text' },
+ defaultValue: { description: 'Initial uncontrolled value', control: 'text' },
+ minHeight: { description: 'Minimum height', control: 'text' },
+ maxHeight: { description: 'Maximum height', control: 'text' },
+ disabled: { description: 'Disables the field', control: 'boolean' },
+ readOnly: { description: 'Makes the field read-only', control: 'boolean' },
+ autoFocus: { description: 'Focuses after first render', control: 'boolean' },
+ resize: {
+ description: 'Native resize behavior',
+ control: 'select',
+ options: ['none', 'both', 'horizontal', 'vertical'],
+ },
+ rows: { description: 'Visible row count', control: 'number' },
+ dynamicHeightAdjustment: { description: 'Grow height with content', control: 'boolean' },
+ variant: { description: 'Visual variant', control: 'select', options: ['default', 'inline'] },
+ color: { description: 'Validation color', control: 'select', options: ['primary', 'success', 'warning', 'error'] },
+ maxLength: { description: 'Native hard character limit', control: 'number' },
+ maxCharacters: { description: 'Visual character counter threshold', control: 'number' },
+ styles: { description: 'Custom textarea styles', control: 'object' },
+ },
+ render: (args: Args) =>
+ observed(element('gd-textarea', args), 'gd-input', { value: args.value ?? args.defaultValue ?? '' }),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = { args: { placeholder: 'Type something...', defaultValue: 'This is a default value ' } };
+export const Disabled: Story = {
+ args: { placeholder: 'You cannot edit this...', disabled: true, defaultValue: 'This is a disabled value' },
+};
+export const ReadOnly: Story = { args: { defaultValue: 'This text cannot be changed', readOnly: true } };
+export const VariantInline: Story = { args: { defaultValue: 'Styled text area...', variant: 'inline' } };
+export const ColorPrimary: Story = {
+ args: {
+ placeholder: 'Primary color textarea...',
+ color: 'primary',
+ defaultValue: 'This is a primary color textarea',
+ },
+};
+export const ColorSuccess: Story = {
+ args: {
+ placeholder: 'Success color textarea...',
+ color: 'success',
+ defaultValue: 'This is a success color textarea',
+ },
+};
+export const ColorWarning: Story = {
+ args: {
+ placeholder: 'Warning color textarea...',
+ color: 'warning',
+ defaultValue: 'This is a warning color textarea',
+ },
+};
+export const ColorError: Story = {
+ args: { placeholder: 'Error color textarea...', color: 'error', defaultValue: 'This is an error color textarea' },
+};
+export const AutoFocus: Story = { args: { autoFocus: true, placeholder: 'This will be focused on load' } };
+export const DynamicHeight: Story = {
+ args: { dynamicHeightAdjustment: true, placeholder: 'Start typing to expand... Start typing to expand...' },
+};
+export const WithCharLimit: Story = { args: { maxLength: 50, placeholder: 'Max 50 characters...' } };
+export const Resizable: Story = { args: { placeholder: 'Resizable Textarea placeholder text...', resize: 'both' } };
+export const WithControlledStateEraseOnEnterClick: Story = {
+ render: () => {
+ const area = element('gd-textarea', {
+ name: 'customName',
+ rows: 1,
+ dynamicHeightAdjustment: true,
+ value: 'This is a controlled textarea, initially 1 row',
+ });
+ area.addEventListener('gd-input', (e) => (area.value = (e as CustomEvent<{ value: string }>).detail.value));
+ area.addEventListener('keydown', (e) => {
+ if ((e as KeyboardEvent).key === 'Enter') {
+ e.preventDefault();
+ area.value = '';
+ area.requestUpdate();
+ }
+ });
+ return area;
+ },
+ parameters: { docs: { description: { story: 'Controlled example: pressing Enter clears the textarea value.' } } },
+};
+export const MaxCharacters: Story = {
+ args: { placeholder: 'Type up to 100 characters...', maxCharacters: 100 },
+ parameters: {
+ docs: { description: { story: 'Displays current/max character feedback and an error state when exceeded.' } },
+ },
+};
+export const WithAccessibility: Story = {
+ args: {
+ name: 'comments',
+ placeholder: 'Enter your comments here...',
+ ariaDescribedBy: 'textarea-helper',
+ 'aria-label': 'Comments',
+ } as never,
+ render: (args) => {
+ const label = document.createElement('label');
+ label.textContent = 'Comments';
+ const area = element('gd-textarea', args);
+ label.append(area);
+ return label;
+ },
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('textarea'),
+};
diff --git a/libs/web-components/stories/Toggle.stories.ts b/libs/web-components/stories/Toggle.stories.ts
new file mode 100644
index 0000000..bdda506
--- /dev/null
+++ b/libs/web-components/stories/Toggle.stories.ts
@@ -0,0 +1,58 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GridKitIconName } from 'gd-design-core';
+import type { GdToggle } from '../src';
+import { defaultTokenViewer, element, observed } from './helpers';
+
+type Args = Pick;
+const items = ['Option 1', 'Option 2', 'Option 3'];
+const meta = {
+ title: 'Atoms/Toggle',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A token-backed option group that composes GridKit buttons, supports string and object items, controlled selection, custom item rendering, disabled state, and gd-change events.',
+ },
+ },
+ },
+ args: { items, value: 'Option 1', disabled: false },
+ argTypes: {
+ items: { description: 'String or label/value items', control: 'object' },
+ value: { description: 'Selected item value', control: 'text' },
+ disabled: { description: 'Disables all options', control: 'boolean' },
+ renderItemContent: { description: 'Optional item render function', control: false },
+ styles: { description: 'Custom group styles', control: 'object' },
+ },
+ render: (args: Args) => observed(element('gd-toggle', args), 'gd-change', { value: args.value }),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const Disabled: Story = { args: { disabled: true } };
+export const WithCustomRender: Story = {
+ args: {
+ items: [
+ { label: 'Fire', value: 'home' },
+ { label: 'Water', value: 'accountCircle' },
+ { label: 'Earth', value: 'success' },
+ ],
+ value: 'home',
+ renderItemContent: (item) =>
+ typeof item === 'string'
+ ? item
+ : element('gd-icon', {
+ name: (item as { value: GridKitIconName }).value,
+ }),
+ },
+ parameters: { docs: { description: { story: 'Object items can supply values and consumer-rendered content.' } } },
+};
+export const WithAccessibility: Story = {
+ args: { 'aria-label': 'Toggle options' } as never,
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('switchToggle'),
+};
diff --git a/libs/web-components/stories/Truncate.stories.ts b/libs/web-components/stories/Truncate.stories.ts
new file mode 100644
index 0000000..70c14d7
--- /dev/null
+++ b/libs/web-components/stories/Truncate.stories.ts
@@ -0,0 +1,72 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdTruncate } from '../src';
+import { element } from './helpers';
+
+type Args = Pick & { text: string };
+const long =
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
+const meta = {
+ title: 'Atoms/Truncate',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A token-backed line-clamp wrapper with slotted content, configurable line count, custom styles, responsive overflow measurement, and an isTruncated API.',
+ },
+ },
+ },
+ args: { lines: 1, text: long },
+ argTypes: {
+ text: { description: 'Text or slotted content to display', control: 'text' },
+ lines: { description: 'Maximum visible line count', control: 'number' },
+ styles: { description: 'Custom content styles', control: 'object' },
+ },
+ render: ({ text, ...args }: Args) => {
+ const box = document.createElement('div');
+ box.style.width = '240px';
+ box.append(element('gd-truncate', args, text));
+ return box;
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {
+ parameters: { docs: { description: { story: 'One-line truncation within a constrained container.' } } },
+};
+export const LineTruncation: Story = {
+ args: { lines: 2, text: 'This is a very long text that will wrap to multiple lines' },
+ parameters: { docs: { description: { story: 'CSS line-clamp limits content to two lines.' } } },
+};
+export const WithCustomStyling: Story = {
+ args: {
+ styles: { color: '#dc2626', fontWeight: 600, fontSize: '18px' },
+ text: 'This text has custom styling applied via the styles property.',
+ },
+};
+export const RefAPIWithTooltipOnOverflow: Story = {
+ render: () => {
+ const section = document.createElement('section');
+ section.style.width = '220px';
+ const output = document.createElement('output');
+ const value = element(
+ 'gd-truncate',
+ { lines: 1 },
+ '(Resize window): Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
+ );
+ const update = () => (output.textContent = `isTruncated: ${value.isTruncated}`);
+ new ResizeObserver(update).observe(value);
+ setTimeout(update);
+ section.append(value, output);
+ return section;
+ },
+ parameters: {
+ docs: { description: { story: 'The isTruncated property exposes measured overflow for tooltip decisions.' } },
+ },
+};
+export const WithAccessibility: Story = {
+ args: { 'aria-label': 'Truncated summary' } as never,
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
diff --git a/libs/web-components/stories/Typography.stories.ts b/libs/web-components/stories/Typography.stories.ts
new file mode 100644
index 0000000..087c20f
--- /dev/null
+++ b/libs/web-components/stories/Typography.stories.ts
@@ -0,0 +1,136 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdTypography } from '../src';
+import { defaultTokenViewer, 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'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Typography matching React semantic overrides, combined style variants, disclaimer, display, heading, body, color, alignment, and accessibility states.',
+ },
+ },
+ },
+ 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;
+ },
+};
+export const AsCustomComponent: Story = SemanticOverride;
+export const AsCustomHtmlTag: Story = SemanticOverride;
+export const CombinedStyleVariant: Story = StyleVariants;
+export const Disclaimers: Story = CaptionAndHeader;
+export const Display: Story = DisplaySizes;
+export const Heading: Story = AllVariants;
+export const Body: Story = { args: { variant: 'p', as: 'p', text: 'Body paragraph text' } };
+export const WithAccessibility: Story = { args: { variant: 'h2', as: 'h2', text: 'Accessible section heading' } };
+export const DefaultTokens: Story = { render: () => defaultTokenViewer('typography') };
diff --git a/libs/web-components/stories/Wrapper.stories.ts b/libs/web-components/stories/Wrapper.stories.ts
new file mode 100644
index 0000000..7633fa0
--- /dev/null
+++ b/libs/web-components/stories/Wrapper.stories.ts
@@ -0,0 +1,75 @@
+import type { Meta, StoryObj } from '@storybook/web-components-vite';
+import type { GdWrapper } from '../src';
+import { defaultTokenViewer, element } from './helpers';
+
+type Args = Pick & { text: string };
+const meta = {
+ title: 'Atoms/Wrapper',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A minimal token-backed layout wrapper with inline, section and full-page variants, semantic internal tag selection, slotted content, and style overrides.',
+ },
+ },
+ },
+ args: { variant: 'inline', as: 'div', text: 'This is a basic wrapper.' },
+ argTypes: {
+ variant: { description: 'Layout context', control: 'select', options: ['inline', 'section', 'fullPage'] },
+ as: { description: 'Internal semantic HTML tag', control: 'text' },
+ text: { description: 'Slotted content', control: 'text' },
+ styles: { description: 'Custom wrapper styles', control: 'object' },
+ },
+ render: ({ text, ...args }: Args) => element('gd-wrapper', args, text),
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+export const InteractiveExample: Story = {
+ name: 'Interactive Example',
+ args: { styles: { padding: '20px', border: '2px dashed #007bff', borderRadius: '8px', backgroundColor: '#f0f8ff' } },
+};
+export const SectionWrapper: Story = {
+ name: 'Variant: Section',
+ args: {
+ variant: 'section',
+ text: 'This content is semantically grouped within a section element.',
+ styles: { padding: '20px', width: '400px', border: '1px solid #ccc', backgroundColor: '#f9f9f9' },
+ },
+};
+export const InlineWrapper: Story = {
+ name: 'Variant: Inline (Span)',
+ args: {
+ variant: 'inline',
+ text: 'this part is wrapped in a span',
+ styles: {
+ padding: '4px 8px',
+ borderRadius: '4px',
+ backgroundColor: '#fffbe6',
+ color: '#856404',
+ fontWeight: 'bold',
+ },
+ },
+};
+export const FullPageWrapper: Story = {
+ name: 'Variant: Full Page',
+ args: {
+ variant: 'fullPage',
+ text: 'This wrapper covers the entire viewport.',
+ styles: { display: 'flex', alignItems: 'center', justifyContent: 'center' },
+ },
+ parameters: { layout: 'fullscreen' },
+};
+export const CustomTagWrapper: Story = {
+ name: 'Custom tag Wrapper',
+ args: { as: 'section', text: 'Custom tag Wrapper' },
+};
+export const WithAccessibility: Story = {
+ args: { 'aria-label': 'Content wrapper' } as never,
+ parameters: { a11y: { test: 'error' }, docs: { disable: true } },
+ tags: ['a11y'],
+};
+export const DefaultTokens: Story = {
+ render: () => defaultTokenViewer('wrapper'),
+};
diff --git a/libs/web-components/stories/helpers.ts b/libs/web-components/stories/helpers.ts
new file mode 100644
index 0000000..2b42951
--- /dev/null
+++ b/libs/web-components/stories/helpers.ts
@@ -0,0 +1,254 @@
+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-badge': ['variant', 'appearance', 'size'],
+ 'gd-box': ['variant', 'isBordered'],
+ 'gd-button': ['variant', 'rounded'],
+ 'gd-checkbox': ['size'],
+ 'gd-input': ['variant', 'color'],
+ 'gd-image': ['src', 'alt', 'objectFit'],
+ 'gd-icon': ['name', 'size'],
+ 'gd-input-file': ['accept', 'multiple'],
+ 'gd-label': ['htmlFor'],
+ 'gd-link': ['variant', 'href'],
+ 'gd-loader': ['name', 'variant', 'size'],
+ 'gd-separator': ['orientation', 'variant', 'size', 'labelPosition'],
+ 'gd-skeleton': ['variant', 'width', 'height'],
+ 'gd-slider': ['min', 'max', 'value'],
+ 'gd-slider-dots': ['count', 'activeIndex'],
+ 'gd-switch': ['checked', 'disabled', 'isLoading'],
+ 'gd-textarea': ['variant', 'color', 'value'],
+ 'gd-toggle': ['items', 'value'],
+ 'gd-truncate': ['lines'],
+ 'gd-wrapper': ['variant', 'as'],
+ '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'],
+ 'gd-image': ['gd-load', 'gd-error'],
+ 'gd-input-file': ['gd-change'],
+ 'gd-slider': ['gd-change'],
+ 'gd-slider-dots': ['gd-change'],
+ 'gd-switch': ['gd-change'],
+ 'gd-textarea': ['gd-input', 'gd-change'],
+ 'gd-toggle': ['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}${tag}>`);
+
+ 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;
+}
+
+const tokenViewerStyles = `
+ font-family: monospace;
+ font-size: 14px;
+ background: #f9f9f9;
+ padding: 12px;
+ border-radius: 6px;
+ white-space: pre-wrap;
+ overflow-x: auto;
+ color: #333;
+ max-height: 800px;
+`;
+
+function tokenNode(value: unknown, indent = 0): HTMLElement {
+ if (typeof value === 'function') return tokenNode(value(), indent);
+
+ if (typeof value !== 'object' || value === null) {
+ const text = document.createElement('span');
+ text.style.color = '#545454';
+ text.textContent = JSON.stringify(value);
+ return text;
+ }
+
+ const node = document.createElement('div');
+ node.style.margin = '2px 0';
+ for (const [key, childValue] of Object.entries(value)) {
+ const line = document.createElement('div');
+ line.style.cssText = `display:block;margin:2px 0;padding-left:${(indent + 1) * 12}px`;
+ const keyElement = document.createElement('span');
+ keyElement.style.cssText = 'color:#0d52a5;margin-right:6px';
+ keyElement.textContent = `"${key}" :`;
+ line.append(keyElement);
+
+ if (typeof childValue === 'object' && childValue !== null) {
+ keyElement.style.cursor = 'pointer';
+ keyElement.tabIndex = 0;
+ keyElement.setAttribute('role', 'button');
+ keyElement.setAttribute('aria-expanded', 'true');
+ const brace = document.createElement('span');
+ brace.style.cssText = 'color:#545454;user-select:none';
+ brace.textContent = '{';
+ const content = tokenNode(childValue, indent + 1);
+ const closingBrace = document.createElement('span');
+ closingBrace.style.cssText = 'color:#545454;user-select:none';
+ closingBrace.textContent = ' } ';
+ const toggle = () => {
+ const expanded = keyElement.getAttribute('aria-expanded') === 'true';
+ keyElement.setAttribute('aria-expanded', String(!expanded));
+ brace.textContent = expanded ? '{...}' : '{';
+ content.hidden = expanded;
+ closingBrace.hidden = expanded;
+ };
+ keyElement.addEventListener('click', toggle);
+ keyElement.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ toggle();
+ }
+ });
+ line.append(brace, content, closingBrace);
+ } else {
+ line.append(tokenNode(childValue, indent + 1));
+ }
+ node.append(line);
+ }
+ return node;
+}
+
+/** Render the same default-theme branch shown by the React Storybook TokenViewer. */
+export function defaultTokenViewer(
+ label: string,
+ themeToken: keyof typeof defaultTheme = label as keyof typeof defaultTheme
+) {
+ const viewer = document.createElement('div');
+ viewer.style.cssText = tokenViewerStyles;
+ viewer.tabIndex = 0;
+ viewer.setAttribute('role', 'region');
+ viewer.setAttribute('aria-label', 'Token viewer');
+ viewer.append(tokenNode({ [label]: defaultTheme[themeToken] }));
+ return viewer;
+}
+
+/** 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');
+ });
+});
diff --git a/libs/web-components/vitest.config.ts b/libs/web-components/vitest.config.ts
index 644327d..a70640b 100644
--- a/libs/web-components/vitest.config.ts
+++ b/libs/web-components/vitest.config.ts
@@ -40,6 +40,9 @@ export default defineConfig({
'@assets': path.join(uiSrc, 'assets'),
},
},
+ optimizeDeps: {
+ include: ['lit/directives/repeat.js'],
+ },
test: {
name: 'web-components',
include: ['src/**/*.spec.ts', 'src/**/*.spec.tsx', 'test/**/*.spec.ts', 'test/**/*.spec.tsx'],
diff --git a/package-lock.json b/package-lock.json
index 12c0d41..bf6da97 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2870,6 +2870,7 @@
"@visx/tooltip": "^3.12.0",
"@visx/xychart": "^3.12.0",
"embla-carousel-react": "^8.6.0",
+ "gd-design-core": "*",
"uuid": "^14.0.0"
},
"devDependencies": {