`. When the table is wider
+than its container, that region scrolls — but it has no `tabindex`, so a keyboard user cannot scroll
+it and cannot reach the columns on the right.
+
+*Fix:* `tabIndex={0}` on the container, plus a `role="region"` and a label.
+
+### An empty `TagMultiSelect` breaks its own popup
+
+With no options loaded the component renders a bare `
` where the `ComboboxList` would go. The
+popup keeps its listbox semantics without the children they require, and axe reports a missing
+required ARIA attribute.
+
+*Fix:* render the empty message through `ComboboxEmpty`, which exists for this and is already
+exported.
+
+## Not a defect, but worth knowing
+
+- **`SidebarHeader` links to `/oe-logo-landscape-dark.svg`.** The path is absolute and app-served;
+ the library does not ship the asset. In the showcase the image is simply broken.
+- **`LanguageSwitch` only works on a dark ground.** Its inactive state is `text-oe-white/70`, so on a
+ light surface it is invisible. There is a story that shows this deliberately.
+- **`TagMultiSelect` derives its chips from the loaded options**, so a field with `selectedIds`
+ already set renders empty until `loadTags` resolves. On a slow connection a detail page shows no
+ tags for as long as that takes.
+- **`DialogFooter`'s built-in close button** renders the Radix primitive directly rather than the
+ exported `DialogClose`, so unlike every other close control it carries no `data-slot="dialog-close"`.
diff --git a/package.json b/package.json
index 8f987a4..81932f7 100644
--- a/package.json
+++ b/package.json
@@ -93,6 +93,7 @@
"react-dom": "^19.0.0",
"storybook": "^10.5.9",
"tailwindcss": "^4.3.3",
+ "tw-animate-css": "^1.4.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^7.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 198a19e..c722497 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,7 +59,7 @@ importers:
version: 10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(rollup@4.60.2)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@storybook/react-vite':
specifier: ^10.5.9
- version: 10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.2)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
+ version: 10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.2)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@tailwindcss/typography':
specifier: ^0.5.20
version: 0.5.20(tailwindcss@4.3.3)
@@ -80,7 +80,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.0.0
- version: 4.7.0(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
+ version: 4.7.0(supports-color@7.2.0)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -92,10 +92,10 @@ importers:
version: 1.6.1(ajv@8.20.0)
eslint:
specifier: ^9.0.0
- version: 9.39.4(jiti@2.7.0)
+ version: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
jsdom:
specifier: ^26.0.0
- version: 26.1.0
+ version: 26.1.0(supports-color@7.2.0)
lucide-react:
specifier: ^0.500.0
version: 0.500.0(react@19.2.5)
@@ -117,18 +117,21 @@ importers:
tailwindcss:
specifier: ^4.3.3
version: 4.3.3
+ tw-animate-css:
+ specifier: ^1.4.0
+ version: 1.4.0
typescript:
specifier: ^5.0.0
version: 5.9.3
typescript-eslint:
specifier: ^8.0.0
- version: 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ version: 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
vite:
specifier: ^7.3.2
version: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
vitest:
specifier: ^3.0.0
- version: 3.2.4(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)
+ version: 3.2.4(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(supports-color@7.2.0)
packages:
@@ -3329,6 +3332,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tw-animate-css@1.4.0:
+ resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
+
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3565,20 +3571,20 @@ snapshots:
'@babel/compat-data@7.29.0': {}
- '@babel/core@7.29.0':
+ '@babel/core@7.29.0(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6
- '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.2
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@@ -3603,19 +3609,19 @@ snapshots:
'@babel/helper-globals@7.28.0': {}
- '@babel/helper-module-imports@7.28.6':
+ '@babel/helper-module-imports@7.28.6(supports-color@7.2.0)':
dependencies:
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0(supports-color@7.2.0))(supports-color@7.2.0)':
dependencies:
- '@babel/core': 7.29.0
- '@babel/helper-module-imports': 7.28.6
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/helper-module-imports': 7.28.6(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3636,14 +3642,14 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
- '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
- '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0(supports-color@7.2.0))':
dependencies:
- '@babel/core': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-plugin-utils': 7.28.6
'@babel/runtime@7.29.2': {}
@@ -3654,7 +3660,7 @@ snapshots:
'@babel/parser': 7.29.2
'@babel/types': 7.29.0
- '@babel/traverse@7.29.0':
+ '@babel/traverse@7.29.0(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
@@ -3662,7 +3668,7 @@ snapshots:
'@babel/parser': 7.29.2
'@babel/template': 7.28.6
'@babel/types': 7.29.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -3834,17 +3840,17 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
- '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))':
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))':
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
- '@eslint/config-array@0.21.2':
+ '@eslint/config-array@0.21.2(supports-color@7.2.0)':
dependencies:
'@eslint/object-schema': 2.1.7
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -3857,10 +3863,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
- '@eslint/eslintrc@3.3.5':
+ '@eslint/eslintrc@3.3.5(supports-color@7.2.0)':
dependencies:
ajv: 6.14.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
@@ -4979,16 +4985,16 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
- '@storybook/react-vite@10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.2)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
+ '@storybook/react-vite@10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(esbuild@0.27.7)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(rollup@4.60.2)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(supports-color@7.2.0)(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
'@rollup/pluginutils': 5.4.0(rollup@4.60.2)
'@storybook/builder-vite': 10.5.9(esbuild@0.27.7)(rollup@4.60.2)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))
- '@storybook/react': 10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(typescript@5.9.3)
+ '@storybook/react': 10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(supports-color@7.2.0)(typescript@5.9.3)
empathic: 2.0.1
magic-string: 0.30.21
react: 19.2.5
- react-docgen: 8.0.3
+ react-docgen: 8.0.3(supports-color@7.2.0)
react-dom: 19.2.5(react@19.2.5)
resolve: 1.22.12
storybook: 10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5)
@@ -5004,12 +5010,12 @@ snapshots:
- supports-color
- webpack
- '@storybook/react@10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(typescript@5.9.3)':
+ '@storybook/react@10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@storybook/global': 5.0.0
'@storybook/react-dom-shim': 10.5.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(storybook@10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5))
react: 19.2.5
- react-docgen: 8.0.3
+ react-docgen: 8.0.3(supports-color@7.2.0)
react-docgen-typescript: 2.4.0(typescript@5.9.3)
react-dom: 19.2.5(react@19.2.5)
storybook: 10.5.9(@types/react@19.2.14)(prettier@3.8.3)(react@19.2.5)
@@ -5377,15 +5383,15 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
- '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.59.0
- '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -5393,23 +5399,23 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.59.0
'@typescript-eslint/types': 8.59.0
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.59.0(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.0
- debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ debug: 4.4.3(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)':
+ '@typescript-eslint/project-service@8.59.0(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
'@typescript-eslint/types': 8.59.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5423,13 +5429,13 @@ snapshots:
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.0
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/typescript-estree': 8.59.0(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ debug: 4.4.3(supports-color@7.2.0)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -5437,13 +5443,13 @@ snapshots:
'@typescript-eslint/types@8.59.0': {}
- '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.59.0(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3)
+ '@typescript-eslint/project-service': 8.59.0(supports-color@7.2.0)(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3)
'@typescript-eslint/types': 8.59.0
'@typescript-eslint/visitor-keys': 8.59.0
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.16
@@ -5452,13 +5458,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))
'@typescript-eslint/scope-manager': 8.59.0
'@typescript-eslint/types': 8.59.0
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/typescript-estree': 8.59.0(supports-color@7.2.0)(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -5468,11 +5474,11 @@ snapshots:
'@typescript-eslint/types': 8.59.0
eslint-visitor-keys: 5.0.1
- '@vitejs/plugin-react@4.7.0(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
+ '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@7.3.2(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
- '@babel/core': 7.29.0
- '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
- '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0))
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0(supports-color@7.2.0))
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
@@ -5678,9 +5684,11 @@ snapshots:
date-fns@4.1.0: {}
- debug@4.4.3:
+ debug@4.4.3(supports-color@7.2.0):
dependencies:
ms: 2.1.3
+ optionalDependencies:
+ supports-color: 7.2.0
decimal.js@10.6.0: {}
@@ -5774,14 +5782,14 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
- eslint@9.39.4(jiti@2.7.0):
+ eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0):
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))
'@eslint-community/regexpp': 4.12.2
- '@eslint/config-array': 0.21.2
+ '@eslint/config-array': 0.21.2(supports-color@7.2.0)
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
- '@eslint/eslintrc': 3.3.5
+ '@eslint/eslintrc': 3.3.5(supports-color@7.2.0)
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.8
@@ -5791,7 +5799,7 @@ snapshots:
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -5908,17 +5916,17 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
- http-proxy-agent@7.0.2:
+ http-proxy-agent@7.0.2(supports-color@7.2.0):
dependencies:
agent-base: 7.1.4
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
- https-proxy-agent@7.0.6:
+ https-proxy-agent@7.0.6(supports-color@7.2.0):
dependencies:
agent-base: 7.1.4
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -5973,14 +5981,14 @@ snapshots:
dependencies:
argparse: 2.0.1
- jsdom@26.1.0:
+ jsdom@26.1.0(supports-color@7.2.0):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
- http-proxy-agent: 7.0.2
- https-proxy-agent: 7.0.6
+ http-proxy-agent: 7.0.2(supports-color@7.2.0)
+ https-proxy-agent: 7.0.6(supports-color@7.2.0)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
@@ -6432,10 +6440,10 @@ snapshots:
dependencies:
typescript: 5.9.3
- react-docgen@8.0.3:
+ react-docgen@8.0.3(supports-color@7.2.0):
dependencies:
- '@babel/core': 7.29.0
- '@babel/traverse': 7.29.0
+ '@babel/core': 7.29.0(supports-color@7.2.0)
+ '@babel/traverse': 7.29.0(supports-color@7.2.0)
'@babel/types': 7.29.0
'@types/babel__core': 7.20.5
'@types/babel__traverse': 7.28.0
@@ -6694,17 +6702,19 @@ snapshots:
tslib@2.8.1: {}
+ tw-animate-css@1.4.0: {}
+
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
- typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3):
+ typescript-eslint@8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/typescript-estree': 8.59.0(supports-color@7.2.0)(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.59.0(eslint@9.39.4(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
+ eslint: 9.39.4(jiti@2.7.0)(supports-color@7.2.0)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -6751,10 +6761,10 @@ snapshots:
util-deprecate@1.0.2: {}
- vite-node@3.2.4(jiti@2.7.0)(lightningcss@1.32.0):
+ vite-node@3.2.4(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0):
dependencies:
cac: 6.7.14
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
@@ -6785,7 +6795,7 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
- vitest@3.2.4(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0):
+ vitest@3.2.4(jiti@2.7.0)(jsdom@26.1.0(supports-color@7.2.0))(lightningcss@1.32.0)(supports-color@7.2.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -6796,7 +6806,7 @@ snapshots:
'@vitest/spy': 3.2.4
'@vitest/utils': 3.2.4
chai: 5.3.3
- debug: 4.4.3
+ debug: 4.4.3(supports-color@7.2.0)
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
@@ -6808,10 +6818,10 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.3.2(jiti@2.7.0)(lightningcss@1.32.0)
- vite-node: 3.2.4(jiti@2.7.0)(lightningcss@1.32.0)
+ vite-node: 3.2.4(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
- jsdom: 26.1.0
+ jsdom: 26.1.0(supports-color@7.2.0)
transitivePeerDependencies:
- jiti
- less
diff --git a/stories/actions/action-icon-button.stories.tsx b/stories/actions/action-icon-button.stories.tsx
new file mode 100644
index 0000000..7866711
--- /dev/null
+++ b/stories/actions/action-icon-button.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Archive, Pin, Star } from "lucide-react";
+import { expect, fn, userEvent, within } from "storybook/test";
+import { ActionIconButton } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Actions/ActionIconButton",
+ component: ActionIconButton,
+ args: { title: "Archive", tone: "default", onClick: fn() },
+ argTypes: {
+ tone: { control: "inline-radio", options: ["default", "success"] },
+ title: { control: "text", description: "Becomes both the tooltip and the accessible name." },
+ children: { table: { disable: true } },
+ },
+ render: (args) => (
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A bare icon button for inline row actions. It stops click propagation, so putting one " +
+ "inside a clickable table row does not also trigger the row.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement, args }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Archive" });
+ await userEvent.click(button);
+ await expect(args.onClick).toHaveBeenCalledOnce();
+ },
+};
+
+export const Tones: Story = {
+ render: (args) => (
+
+ ),
+ parameters: {
+ controls: { exclude: ["tone", "title"] },
+ docs: {
+ description: {
+ story:
+ "`success` is how `CopyToClipboardButton` marks a completed action — the tone is the " +
+ "component's whole state vocabulary.",
+ },
+ },
+ },
+};
+
+export const InsideAClickableRow: Story = {
+ render: (args) => (
+ // A table row, not a `role="button"` wrapper: a widget role containing a
+ // focusable control is a nested-interactive violation, and a clickable row
+ // in a real table is where this component actually lives.
+
+
+
+ Open Elements GmbH
+
+
+
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Clicking the icon archives the row without opening it. That is the reason the component " +
+ "calls `stopPropagation` for you.",
+ },
+ },
+ },
+ play: async ({ canvasElement, args }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Archive" }));
+ await expect(args.onClick).toHaveBeenCalledOnce();
+ },
+};
diff --git a/stories/actions/copy-to-clipboard-button.stories.tsx b/stories/actions/copy-to-clipboard-button.stories.tsx
new file mode 100644
index 0000000..875650a
--- /dev/null
+++ b/stories/actions/copy-to-clipboard-button.stories.tsx
@@ -0,0 +1,75 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, userEvent, waitFor, within } from "storybook/test";
+import { CopyToClipboardButton } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Actions/CopyToClipboardButton",
+ component: CopyToClipboardButton,
+ args: { value: "anna.weber@example.com", title: "Copy email address" },
+ argTypes: {
+ value: { control: "text", description: "What lands on the clipboard." },
+ title: { control: "text" },
+ },
+ beforeEach: async () => {
+ // Writing to the real clipboard needs a permission the story runner does not
+ // hold, and the component does not await the write. Stubbing keeps the story
+ // deterministic and lets the play function assert what was copied.
+ const writeText = fn();
+ const original = navigator.clipboard;
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+ return () => {
+ Object.defineProperty(navigator, "clipboard", { value: original, configurable: true });
+ };
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Copies a string and confirms it by swapping the icon for a tick for two seconds. The " +
+ "confirmation is the `success` tone of `ActionIconButton`.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Copy email address" });
+ await userEvent.click(button);
+
+ await expect(navigator.clipboard.writeText).toHaveBeenCalledWith("anna.weber@example.com");
+ // The icon swap is the only feedback, so assert on it rather than on the call alone.
+ await waitFor(() => expect(button.querySelector("svg")).toHaveClass("lucide-check"));
+ },
+};
+
+export const ResetsAfterTwoSeconds: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: "The tick is temporary — after two seconds the button is ready to be pressed again.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Copy email address" });
+ await userEvent.click(button);
+ await waitFor(() => expect(button.querySelector("svg")).toHaveClass("lucide-check"));
+ await waitFor(() => expect(button.querySelector("svg")).toHaveClass("lucide-copy"), {
+ timeout: 4000,
+ });
+ },
+};
+
+export const NextToAValue: Story = {
+ render: (args) => (
+
+ {args.value}
+
+
+ ),
+};
diff --git a/stories/actions/external-link-button.stories.tsx b/stories/actions/external-link-button.stories.tsx
new file mode 100644
index 0000000..a717c45
--- /dev/null
+++ b/stories/actions/external-link-button.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, userEvent, within } from "storybook/test";
+import { ExternalLinkButton } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Actions/ExternalLinkButton",
+ component: ExternalLinkButton,
+ args: { href: "https://open-elements.com", title: "Open website" },
+ argTypes: {
+ href: { control: "text" },
+ title: { control: "text", description: "Tooltip and accessible name." },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Opens a URL in a new tab with `noopener,noreferrer`. It is a button rather than an " +
+ "anchor because it is meant to sit inside a clickable row, where a nested link would " +
+ "fight the row's own navigation.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ beforeEach: async () => {
+ // Stubbed so the play function does not actually spawn a tab.
+ const original = window.open;
+ window.open = fn() as typeof window.open;
+ return () => {
+ window.open = original;
+ };
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Open website" }));
+ await expect(window.open).toHaveBeenCalledWith(
+ "https://open-elements.com",
+ "_blank",
+ "noopener,noreferrer",
+ );
+ },
+};
+
+export const NextToAValue: Story = {
+ render: (args) => (
+
+ open-elements.com
+
+
+ ),
+};
diff --git a/stories/actions/mailto-button.stories.tsx b/stories/actions/mailto-button.stories.tsx
new file mode 100644
index 0000000..4a65c87
--- /dev/null
+++ b/stories/actions/mailto-button.stories.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { MailtoButton } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Actions/MailtoButton",
+ component: MailtoButton,
+ args: { email: "anna.weber@example.com", title: "Send an email" },
+ argTypes: {
+ email: { control: "text" },
+ title: { control: "text", description: "Tooltip and accessible name." },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Hands the address to the operating system by assigning `window.location.href`. There is " +
+ "no play function that clicks it: the assignment navigates the story frame, and unlike " +
+ "`window.open` it cannot be stubbed. Press it yourself to see your mail client open.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ // Render-only: the button is reachable by its accessible name.
+ await expect(
+ within(canvasElement).getByRole("button", { name: "Send an email" }),
+ ).toBeVisible();
+ },
+};
+
+export const NextToAValue: Story = {
+ render: (args) => (
+
+ {args.email}
+
+
+ ),
+};
diff --git a/stories/actions/tooltip-icon-button.stories.tsx b/stories/actions/tooltip-icon-button.stories.tsx
new file mode 100644
index 0000000..a33b46f
--- /dev/null
+++ b/stories/actions/tooltip-icon-button.stories.tsx
@@ -0,0 +1,122 @@
+import type { MouseEvent, ReactNode } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Pencil, Trash2 } from "lucide-react";
+import { expect, fn, screen, userEvent, within } from "storybook/test";
+import { TooltipIconButton } from "../../src/index.ts";
+
+/**
+ * `TooltipIconButtonProps` is a union discriminated by `asChild`, which
+ * collapses to `never` when Storybook derives args from it. The catalogue
+ * declares the button-shaped half explicitly; the `asChild` half gets its own
+ * story that builds the element directly.
+ */
+interface TooltipIconButtonArgs {
+ readonly tooltip: string;
+ readonly tone?: "default" | "destructive";
+ readonly disabled?: boolean;
+ readonly icon?: ReactNode;
+ readonly onClick: (event: MouseEvent) => void;
+}
+
+const meta: Meta = {
+ title: "Actions/TooltipIconButton",
+ args: { tooltip: "Edit", tone: "default", onClick: fn(), icon: },
+ argTypes: {
+ tone: { control: "inline-radio", options: ["default", "destructive"] },
+ tooltip: {
+ control: "text",
+ description: "Used as both the tooltip text and the button's accessible name.",
+ },
+ disabled: { control: "boolean" },
+ icon: { table: { disable: true } },
+ onClick: { table: { disable: true } },
+ },
+ render: (args) => ,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A ghost icon button that always carries a tooltip. Two shapes: pass an `icon` and an " +
+ "`onClick`, or pass `asChild` with your own element — a link, typically.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement, args }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Edit" });
+
+ // Hover first: Radix dismisses the tooltip on click and keeps it closed
+ // until the pointer leaves and comes back.
+ await userEvent.hover(button);
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent("Edit");
+
+ await userEvent.click(button);
+ await expect(args.onClick).toHaveBeenCalledOnce();
+ },
+};
+
+export const Destructive: Story = {
+ args: { tone: "destructive", tooltip: "Delete", icon: },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "A disabled button swallows pointer events, which would normally kill the tooltip too — " +
+ "so the component wraps the trigger in a span to keep the hint reachable.",
+ },
+ },
+ },
+ play: async ({ canvasElement, args }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Edit" });
+ await expect(button).toBeDisabled();
+
+ // The tooltip still opens, because the wrapper span is what the trigger listens on.
+ await userEvent.hover(button.parentElement!);
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent("Edit");
+ await expect(args.onClick).not.toHaveBeenCalled();
+ },
+};
+
+export const AsLink: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+ parameters: {
+ controls: { disable: true },
+ docs: {
+ description: {
+ story:
+ "`asChild` keeps link semantics — it is still an anchor with an `href`, just wearing the " +
+ "ghost icon button's styling and its tooltip.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const link = within(canvasElement).getByRole("link", { name: "Open in a new tab" });
+ await expect(link).toHaveAttribute("href", "https://open-elements.com");
+ },
+};
+
+export const Row: Story = {
+ render: () => (
+
+ } onClick={fn()} />
+ } onClick={fn()} />
+
+ ),
+ parameters: { controls: { disable: true } },
+};
diff --git a/stories/actions/translate-button.stories.tsx b/stories/actions/translate-button.stories.tsx
new file mode 100644
index 0000000..dbb3c34
--- /dev/null
+++ b/stories/actions/translate-button.stories.tsx
@@ -0,0 +1,117 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, within } from "storybook/test";
+import { TranslateButton } from "../../src/index.ts";
+import type { TranslateResult } from "../../src/index.ts";
+import { TRANSLATE_BUTTON_TRANSLATIONS, delay } from "../support/fixtures.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const GERMAN_TEXT =
+ "Die Rechnung ist seit vierzehn Tagen überfällig; eine Erinnerung wurde bereits versendet.";
+
+async function onTranslate(_text: string, targetLanguage: string): Promise {
+ await delay(600);
+ return {
+ translatedText: `[${targetLanguage}] The invoice has been overdue for fourteen days; a reminder has already been sent.`,
+ };
+}
+
+const meta: Meta = {
+ title: "Actions/TranslateButton",
+ component: TranslateButton,
+ args: {
+ text: GERMAN_TEXT,
+ configured: true,
+ size: "md",
+ onTranslate,
+ translations: TRANSLATE_BUTTON_TRANSLATIONS,
+ },
+ argTypes: {
+ text: { control: "text", description: "Empty or whitespace hides the button entirely." },
+ configured: {
+ control: "select",
+ options: [true, false, null],
+ description:
+ "Whether the backend feature is switched on. `null` means the probe is still in flight — " +
+ "the button stays hidden so it cannot flash in and out.",
+ },
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ onTranslate: { table: { disable: true } },
+ translations: { table: { disable: true } },
+ },
+ render: (args) => (
+
+ {args.text}
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A translate affordance that sits beside a piece of text and opens a `TranslateDialog`. " +
+ "It renders nothing at all in three cases: no text, a probe still in flight, or the " +
+ "feature not configured.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const button = within(canvasElement).getByTestId("translate-button");
+ await expect(button).toHaveAccessibleName(TRANSLATE_BUTTON_TRANSLATIONS.button);
+
+ await userEvent.click(button);
+ const dialog = await screen.findByRole("dialog");
+ await expectVisible(within(dialog).getByTestId("translate-dialog-loading"));
+ await expect(
+ await within(dialog).findByTestId("translate-dialog-result", undefined, { timeout: 5000 }),
+ ).toHaveTextContent("The invoice has been overdue");
+ },
+};
+
+export const Small: Story = {
+ args: { size: "sm" },
+};
+
+export const NotConfigured: Story = {
+ args: { configured: false },
+ parameters: {
+ docs: {
+ description: { story: "The backend has no translation provider, so nothing renders." },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).queryByTestId("translate-button")).toBeNull();
+ },
+};
+
+export const ProbeInFlight: Story = {
+ args: { configured: null },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`configured === null` is the app saying it does not know yet. Hiding rather than " +
+ "guessing is what stops the button appearing and then vanishing.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).queryByTestId("translate-button")).toBeNull();
+ },
+};
+
+export const NoText: Story = {
+ args: { text: " " },
+ parameters: {
+ docs: { description: { story: "Whitespace counts as nothing to translate." } },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).queryByTestId("translate-button")).toBeNull();
+ },
+};
diff --git a/stories/data/capability-status.stories.tsx b/stories/data/capability-status.stories.tsx
new file mode 100644
index 0000000..f9b09d6
--- /dev/null
+++ b/stories/data/capability-status.stories.tsx
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, within } from "storybook/test";
+import { CapabilityStatus } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Data/CapabilityStatus",
+ component: CapabilityStatus,
+ args: {
+ available: true,
+ label: "Automatic translation",
+ availableText: "Configured and reachable",
+ unavailableText: "No API key configured",
+ },
+ argTypes: {
+ available: { control: "boolean" },
+ hint: {
+ control: "text",
+ description:
+ "Optional explanation. Supplying it wraps the row in a tooltip and makes it focusable, so " +
+ "keyboard users can reach the explanation too.",
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Reports whether an optional backend feature is switched on. The icon changes shape as " +
+ "well as colour — a tick versus a warning triangle — so the state survives without colour.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Available: Story = {
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText("Configured and reachable")).toBeVisible();
+ },
+};
+
+export const Unavailable: Story = {
+ args: { available: false },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText("No API key configured")).toBeVisible();
+ },
+};
+
+export const WithHint: Story = {
+ args: {
+ available: false,
+ hint: "Set TRANSLATION_API_KEY in the backend environment and restart the service.",
+ },
+ play: async ({ canvasElement }) => {
+ const note = within(canvasElement).getByRole("note");
+ // The row announces the label, the state and the hint in one accessible name.
+ await expect(note).toHaveAccessibleName(/Automatic translation: No API key configured\./);
+
+ await userEvent.hover(note);
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent("TRANSLATION_API_KEY");
+ },
+};
+
+export const Grid: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+ parameters: { controls: { exclude: ["available", "label", "availableText", "unavailableText"] } },
+};
diff --git a/stories/data/detail-field.stories.tsx b/stories/data/detail-field.stories.tsx
new file mode 100644
index 0000000..39a579f
--- /dev/null
+++ b/stories/data/detail-field.stories.tsx
@@ -0,0 +1,169 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+import { Badge, DetailField } from "../../src/index.ts";
+import { DETAIL_FIELD_TRANSLATIONS } from "../support/fixtures.ts";
+
+const meta: Meta = {
+ title: "Data/DetailField",
+ component: DetailField,
+ args: {
+ label: "Email",
+ value: "anna.weber@example.com",
+ translations: DETAIL_FIELD_TRANSLATIONS,
+ },
+ argTypes: {
+ copyable: { control: "boolean" },
+ linkable: { control: "boolean" },
+ mailable: { control: "boolean" },
+ callable: { control: "boolean" },
+ multiline: { control: "boolean" },
+ translations: { table: { disable: true } },
+ children: { table: { disable: true } },
+ },
+ // The component renders a ``/` ` pair, which is only valid inside a
+ // description list — so every story supplies one.
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A label/value pair for a detail page, with optional inline actions. The action icons " +
+ "only appear when there is a value to act on, so an empty field stays quiet.\n\n" +
+ "It renders a bare ` `/` ` pair, so it has to be placed inside a `` — every " +
+ "story here wraps it in one.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Copyable: Story = {
+ args: { copyable: true },
+ beforeEach: async () => {
+ // The real clipboard needs a permission the story runner does not have, and
+ // the component does not await the write, so stub it to keep the story
+ // deterministic — and to be able to assert what was copied.
+ const writeText = fn();
+ const original = navigator.clipboard;
+ Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
+ return () => {
+ Object.defineProperty(navigator, "clipboard", { value: original, configurable: true });
+ };
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const button = canvas.getAllByRole("button")[0];
+
+ // The tooltip says "Copy" before anything happens.
+ await userEvent.hover(button);
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ DETAIL_FIELD_TRANSLATIONS.copy,
+ );
+
+ await userEvent.click(button);
+ await expect(navigator.clipboard.writeText).toHaveBeenCalledWith("anna.weber@example.com");
+
+ // Confirmation is the icon swapping to a tick for two seconds.
+ await waitFor(() => expect(button.querySelector("svg")).toHaveClass("lucide-check"));
+ await waitFor(() => expect(button.querySelector("svg")).toHaveClass("lucide-copy"), {
+ timeout: 4000,
+ });
+ },
+};
+
+export const AllActions: Story = {
+ args: {
+ label: "Contact",
+ value: "anna.weber@example.com",
+ copyable: true,
+ linkable: true,
+ mailable: true,
+ callable: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "All four actions at once. Only the copy action is exercised by a play function — the " +
+ "other three navigate the frame or open a tab, which a test cannot undo.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getAllByRole("button")).toHaveLength(4);
+ },
+};
+
+export const Empty: Story = {
+ args: { value: null, copyable: true, linkable: true },
+ parameters: {
+ docs: { description: { story: "No value: an em dash, and no actions to press." } },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByText("—")).toBeVisible();
+ await expect(canvas.queryAllByRole("button")).toHaveLength(0);
+ },
+};
+
+export const Multiline: Story = {
+ args: {
+ label: "Address",
+ value: "Open Elements GmbH\nHauptstraße 12\n10827 Berlin\nGermany",
+ multiline: true,
+ copyable: true,
+ },
+ play: async ({ canvasElement }) => {
+ const value = within(canvasElement).getByText(/Hauptstraße/);
+ await expect(getComputedStyle(value).whiteSpace).toBe("pre-line");
+ },
+};
+
+export const WithCustomContent: Story = {
+ args: { label: "Status", value: "Active", children: undefined },
+ render: (args) => (
+
+ Active since March 2024
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`children` replaces the rendered value but not the actions — the actions still work off " +
+ "`value`, so both are supplied.",
+ },
+ },
+ },
+};
+
+export const Group: Story = {
+ render: (args) => (
+ <>
+
+
+
+
+
+ >
+ ),
+ parameters: {
+ controls: { exclude: ["label", "value", "copyable", "linkable", "mailable", "callable"] },
+ docs: {
+ description: {
+ story: "How the component is actually used: a `` of fields on a detail page.",
+ },
+ },
+ },
+};
diff --git a/stories/data/health-status.stories.tsx b/stories/data/health-status.stories.tsx
new file mode 100644
index 0000000..a8c69c5
--- /dev/null
+++ b/stories/data/health-status.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { HealthStatus } from "../../src/index.ts";
+import { HEALTH_STATUS_TRANSLATIONS } from "../support/fixtures.ts";
+
+const meta: Meta = {
+ title: "Data/HealthStatus",
+ component: HealthStatus,
+ args: { healthy: true, translations: HEALTH_STATUS_TRANSLATIONS },
+ argTypes: {
+ healthy: { control: "boolean" },
+ translations: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A card reporting whether the backend answers. The dot is decorative on its own, so it " +
+ "carries the status text as its label rather than relying on colour alone.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Healthy: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByText(HEALTH_STATUS_TRANSLATIONS.statusUp)).toBeVisible();
+ // Colour is not the only carrier of the state.
+ await expect(canvas.getByLabelText(HEALTH_STATUS_TRANSLATIONS.statusUp)).toBeVisible();
+ },
+};
+
+export const Unhealthy: Story = {
+ args: { healthy: false },
+ play: async ({ canvasElement }) => {
+ await expect(
+ within(canvasElement).getByText(HEALTH_STATUS_TRANSLATIONS.statusDown),
+ ).toBeVisible();
+ },
+};
+
+export const BothStates: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+ parameters: { controls: { exclude: ["healthy"] } },
+};
diff --git a/stories/data/table-pagination.stories.tsx b/stories/data/table-pagination.stories.tsx
new file mode 100644
index 0000000..05cd972
--- /dev/null
+++ b/stories/data/table-pagination.stories.tsx
@@ -0,0 +1,157 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TablePagination,
+ TableRow,
+} from "../../src/index.ts";
+import { PAGINATION_TRANSLATIONS } from "../support/fixtures.ts";
+
+const PAGE_SIZE_OPTIONS = [10, 25, 50] as const;
+
+interface PaginationStoryProps {
+ readonly totalElements: number;
+}
+
+function PaginationHarness({ totalElements }: PaginationStoryProps) {
+ const [page, setPage] = useState(0);
+ const [pageSize, setPageSize] = useState(10);
+ const totalPages = Math.max(1, Math.ceil(totalElements / pageSize));
+
+ const rows = Array.from(
+ { length: Math.min(pageSize, totalElements - page * pageSize) },
+ (_, i) => ({
+ index: page * pageSize + i + 1,
+ }),
+ );
+
+ return (
+
+
+
+
+ #
+ Company
+
+
+
+ {rows.map((row) => (
+
+ {row.index}
+ Company {row.index}
+
+ ))}
+
+
+
+
+ page {page} · size {pageSize}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Data/TablePagination",
+ render: (args) => ,
+ args: { totalElements: 42 },
+ argTypes: {
+ totalElements: {
+ control: { type: "number", min: 0, max: 500 },
+ description:
+ "Drives the total label and, with the page size, how many pages there are. The previous/" +
+ "next controls hide entirely when there is only one page.",
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Page-size select plus previous/next, sitting under a table. It persists the chosen page " +
+ "size to `localStorage` under `storageKey`, and resets to the first page when the size " +
+ "changes.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByText("· 42 entries")).toBeVisible();
+
+ await userEvent.click(canvas.getByRole("button", { name: PAGINATION_TRANSLATIONS.next }));
+ await waitFor(() => expect(canvas.getByTestId("state")).toHaveTextContent("page 1"));
+
+ // First page reached again, so Previous goes dead.
+ await userEvent.click(canvas.getByRole("button", { name: PAGINATION_TRANSLATIONS.previous }));
+ await waitFor(() =>
+ expect(canvas.getByRole("button", { name: PAGINATION_TRANSLATIONS.previous })).toBeDisabled(),
+ );
+ },
+};
+
+export const ChangingPageSizeResetsToFirstPage: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await userEvent.click(canvas.getByRole("button", { name: PAGINATION_TRANSLATIONS.next }));
+ await waitFor(() => expect(canvas.getByTestId("state")).toHaveTextContent("page 1"));
+
+ await userEvent.click(canvas.getByRole("combobox", { name: PAGINATION_TRANSLATIONS.perPage }));
+ await userEvent.click(await screen.findByRole("option", { name: "25" }));
+
+ await waitFor(() => expect(canvas.getByTestId("state")).toHaveTextContent("page 0 · size 25"));
+ // And the choice is remembered for the next visit.
+ await expect(localStorage.getItem("showcase.pageSize")).toBe("25");
+ },
+};
+
+export const SinglePage: Story = {
+ args: { totalElements: 7 },
+ parameters: {
+ docs: {
+ description: {
+ story: "Fewer entries than one page: the previous/next pair is not rendered at all.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.queryByRole("button", { name: PAGINATION_TRANSLATIONS.next })).toBeNull();
+ await expect(canvas.getByText("· 7 entries")).toBeVisible();
+ },
+};
+
+export const SingleEntry: Story = {
+ args: { totalElements: 1 },
+ parameters: {
+ docs: {
+ description: {
+ story: "The singular label is a separate translation key rather than a formatting rule.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText("· 1 entry")).toBeVisible();
+ },
+};
diff --git a/stories/data/table.stories.tsx b/stories/data/table.stories.tsx
new file mode 100644
index 0000000..da97ab9
--- /dev/null
+++ b/stories/data/table.stories.tsx
@@ -0,0 +1,185 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import {
+ Badge,
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../src/index.ts";
+
+const ROWS = [
+ { id: "INV-1024", company: "Open Elements GmbH", status: "Paid", amount: "€ 4,200.00" },
+ { id: "INV-1025", company: "Nordwind AG", status: "Open", amount: "€ 1,850.00" },
+ { id: "INV-1026", company: "Baltic Systems", status: "Overdue", amount: "€ 990.00" },
+ { id: "INV-1027", company: "Helios Energie", status: "Paid", amount: "€ 12,400.00" },
+];
+
+const STATUS_VARIANT = {
+ Paid: "secondary",
+ Open: "outline",
+ Overdue: "destructive",
+} as const;
+
+const meta: Meta = {
+ title: "Data/Table",
+ component: Table,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A plain table with brand styling. `Table` wraps itself in an `overflow-x-auto` " +
+ "container, so a wide table scrolls inside its column instead of widening the page.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ Invoice
+ Company
+ Status
+ Amount
+
+
+
+ {ROWS.map((row) => (
+
+ {row.id}
+ {row.company}
+
+
+ {row.status}
+
+
+ {row.amount}
+
+ ))}
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const table = within(canvasElement).getByRole("table");
+ await expect(within(table).getAllByRole("row")).toHaveLength(ROWS.length + 1);
+ await expect(within(table).getAllByRole("columnheader")).toHaveLength(4);
+ },
+};
+
+export const WithCaptionAndFooter: Story = {
+ render: (args) => (
+
+ Invoices issued in May 2026.
+
+
+ Invoice
+ Company
+ Amount
+
+
+
+ {ROWS.map((row) => (
+
+ {row.id}
+ {row.company}
+ {row.amount}
+
+ ))}
+
+
+
+ Total
+ € 19,440.00
+
+
+
+ ),
+};
+
+export const SelectedRow: Story = {
+ render: (args) => (
+
+
+
+ Invoice
+ Company
+
+
+
+ {ROWS.map((row, index) => (
+
+ {row.id}
+ {row.company}
+
+ ))}
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Selection is expressed with `data-state="selected"` on the row — the component keeps ' +
+ "no selection state of its own.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const selected = canvasElement.querySelector('tr[data-state="selected"]');
+ await expect(selected).not.toBeNull();
+ await expect(getComputedStyle(selected!).backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
+ },
+};
+
+export const Scrolls: Story = {
+ render: (args) => (
+
+
+
+
+ {["Invoice", "Company", "Contact", "Issued", "Due", "Status", "Amount"].map((h) => (
+ {h}
+ ))}
+
+
+
+ {ROWS.map((row) => (
+
+ {row.id}
+ {row.company}
+ anna.weber@example.com
+ 2026-05-04
+ 2026-06-03
+ {row.status}
+ {row.amount}
+
+ ))}
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Seven columns in a 28rem box. The container scrolls; the page does not — which is the " +
+ "reason `Table` renders its own wrapper.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const container = canvasElement.querySelector('[data-slot="table-container"]');
+ await expect(container!.scrollWidth).toBeGreaterThan(container!.clientWidth);
+ },
+};
diff --git a/stories/data/tag-chips.stories.tsx b/stories/data/tag-chips.stories.tsx
new file mode 100644
index 0000000..c684d76
--- /dev/null
+++ b/stories/data/tag-chips.stories.tsx
@@ -0,0 +1,67 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { TagChips } from "../../src/index.ts";
+import { TAGS } from "../support/fixtures.ts";
+
+const meta: Meta = {
+ title: "Data/TagChips",
+ component: TagChips,
+ args: { tags: TAGS, label: "Tags" },
+ argTypes: {
+ label: { control: "text", description: "Optional heading. Omit it for a bare row of chips." },
+ tags: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A read-only row of tag chips. Each chip is painted with the tag's own colour and picks " +
+ "a readable foreground from its luminance; a malformed colour falls back to grey.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ for (const tag of TAGS) {
+ await expect(canvas.getByText(tag.name)).toBeVisible();
+ }
+ },
+};
+
+export const ContrastAcrossColours: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // A light chip flips to dark text instead of staying white on yellow.
+ await expect(canvas.getByText("Newsletter")).toHaveStyle({ color: "#1A1A1A" });
+ // A dark chip keeps white text.
+ await expect(canvas.getByText("Churn risk")).toHaveStyle({ color: "#FFFFFF" });
+ // An invalid hex falls back to a grey chip rather than rendering unstyled.
+ await expect(canvas.getByText("Unclassified")).toHaveStyle({ backgroundColor: "#6B7280" });
+ },
+};
+
+export const WithoutLabel: Story = {
+ args: { label: undefined },
+};
+
+export const Empty: Story = {
+ args: { tags: [] },
+ parameters: {
+ docs: {
+ description: {
+ story: "With no tags the component renders nothing at all — not even its label or spacing.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(canvasElement.querySelector("span")).toBeNull();
+ },
+};
diff --git a/stories/data/user-avatar.stories.tsx b/stories/data/user-avatar.stories.tsx
new file mode 100644
index 0000000..fe2b3f9
--- /dev/null
+++ b/stories/data/user-avatar.stories.tsx
@@ -0,0 +1,98 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { UserAvatar } from "../../src/index.ts";
+import { USERS } from "../support/fixtures.ts";
+
+const meta: Meta = {
+ title: "Data/UserAvatar",
+ component: UserAvatar,
+ args: { user: USERS[0], size: "md" },
+ argTypes: {
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ user: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Initials on a colour derived from the user's id, or the photo when there is one. The " +
+ "colour is a hash, so the same person is always the same colour without storing one.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText("AW")).toBeVisible();
+ },
+};
+
+export const Sizes: Story = {
+ render: (args) => (
+
+ {(["sm", "md"] as const).map((size) => (
+
+
+ {size}
+
+ ))}
+
+ ),
+ parameters: { controls: { exclude: ["size"] } },
+};
+
+export const StableColours: Story = {
+ render: (args) => (
+
+ {USERS.map((user) => (
+
+
+ {user.name}
+
+ ))}
+
+ ),
+ play: async ({ canvasElement }) => {
+ const avatars = Array.from(canvasElement.querySelectorAll("span")).filter((el) =>
+ /^[A-Z]{1,2}$/.test(el.textContent ?? ""),
+ );
+ await expect(avatars).toHaveLength(USERS.length);
+ // The palette is fixed, so different people can collide — but a colour is always assigned.
+ for (const avatar of avatars) {
+ await expect(getComputedStyle(avatar).backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
+ }
+ },
+};
+
+export const WithPhoto: Story = {
+ args: {
+ user: {
+ ...USERS[2],
+ // Inline so the story never depends on the network.
+ avatarUrl:
+ "data:image/svg+xml;utf8," +
+ encodeURIComponent(
+ ' ',
+ ),
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const img = within(canvasElement).getByRole("img");
+ await expect(img).toHaveAccessibleName("Clara Nguyen");
+ },
+};
+
+export const SingleWordName: Story = {
+ args: { user: { id: "u-9", name: "Cher", email: "cher@example.com" } },
+ parameters: {
+ docs: { description: { story: "One word gives one initial rather than a truncated pair." } },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByText("C")).toBeVisible();
+ },
+};
diff --git a/stories/forms/calendar.stories.tsx b/stories/forms/calendar.stories.tsx
new file mode 100644
index 0000000..65dd1b5
--- /dev/null
+++ b/stories/forms/calendar.stories.tsx
@@ -0,0 +1,199 @@
+import type React from "react";
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { DateRange } from "react-day-picker";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import {
+ Button,
+ Calendar,
+ Card,
+ CardContent,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "../../src/index.ts";
+
+// Fixed so the grid is identical every time the story is opened.
+const REFERENCE_MONTH = new Date(2026, 4, 1);
+
+/**
+ * `DayPicker`'s props are a union discriminated by `mode`, which collapses to
+ * `never` when Storybook tries to derive args from it. The catalogue therefore
+ * declares only the presentation props it actually exposes as controls, and
+ * each story sets `mode` itself.
+ */
+interface CalendarArgs {
+ readonly captionLayout?: "label" | "dropdown" | "dropdown-months" | "dropdown-years";
+ readonly showOutsideDays?: boolean;
+ readonly showWeekNumber?: boolean;
+ readonly buttonVariant?: React.ComponentProps["variant"];
+}
+
+const meta: Meta = {
+ title: "Forms/Calendar",
+ argTypes: {
+ captionLayout: {
+ control: "select",
+ options: ["label", "dropdown", "dropdown-months", "dropdown-years"],
+ },
+ showOutsideDays: { control: "boolean" },
+ showWeekNumber: { control: "boolean" },
+ buttonVariant: {
+ control: "select",
+ options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
+ },
+ },
+ args: { showOutsideDays: true, captionLayout: "label", buttonVariant: "ghost" },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "`react-day-picker` restyled onto the brand. Every day cell is a `Button`, so the day " +
+ "grid inherits the same focus ring as the rest of the library.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Single: Story = {
+ render: function Single(args) {
+ const [selected, setSelected] = useState(new Date(2026, 4, 14));
+ return (
+
+
+
+ {selected ? selected.toDateString() : "nothing selected"}
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByTestId("selected")).toHaveTextContent("May 14 2026");
+
+ // The accessible name is a full, locale-formatted date; the visible label is
+ // the day number.
+ const day21 = canvas.getAllByRole("button").find((b) => b.textContent?.trim() === "21");
+ await expect(day21).toBeDefined();
+ await userEvent.click(day21!);
+ await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("May 21 2026"));
+ },
+};
+
+export const Range: Story = {
+ render: function Range(args) {
+ const [range, setRange] = useState({
+ from: new Date(2026, 4, 11),
+ to: new Date(2026, 4, 15),
+ });
+ return (
+
+
+
+ {range?.from?.toDateString() ?? "—"} → {range?.to?.toDateString() ?? "—"}
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ // The ends of the range are marked so they can be rounded independently.
+ await expect(canvasElement.querySelector('[data-range-start="true"]')).not.toBeNull();
+ await expect(canvasElement.querySelector('[data-range-end="true"]')).not.toBeNull();
+ },
+};
+
+export const WithDropdownCaption: Story = {
+ args: { captionLayout: "dropdown" },
+ render: (args) => (
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: "Month and year become selects, which needs a `startMonth`/`endMonth` window.",
+ },
+ },
+ },
+};
+
+export const WithDisabledDays: Story = {
+ render: (args) => (
+
+ ),
+ parameters: {
+ docs: {
+ description: { story: "Weekends refused — `disabled` takes a react-day-picker matcher." },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await expect(canvasElement.querySelectorAll("[disabled]").length).toBeGreaterThan(0);
+ },
+};
+
+export const InAPopover: Story = {
+ render: function InAPopover(args) {
+ const [selected, setSelected] = useState();
+ return (
+
+
+ {selected ? selected.toDateString() : "Pick a date"}
+
+
+
+
+
+ );
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Inside a popover or a card the calendar drops its own background — it detects the " +
+ "surrounding slot rather than taking a prop for it.",
+ },
+ },
+ },
+};
+
+export const InACard: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+};
diff --git a/stories/forms/combobox.stories.tsx b/stories/forms/combobox.stories.tsx
new file mode 100644
index 0000000..631112a
--- /dev/null
+++ b/stories/forms/combobox.stories.tsx
@@ -0,0 +1,148 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import {
+ Combobox,
+ ComboboxChip,
+ ComboboxChips,
+ ComboboxChipsInput,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ Label,
+ useComboboxAnchor,
+} from "../../src/index.ts";
+
+const STAGES = ["Lead", "Qualified", "Proposal sent", "Negotiation", "Won", "Lost"];
+
+const meta: Meta = {
+ title: "Forms/Combobox",
+ component: Combobox,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A searchable picker built on Base UI. Two shapes are in use in this library: a plain " +
+ "input with a dropdown, and the chips variant that `TagMultiSelect` builds on.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Single: Story = {
+ render: function Single() {
+ const [value, setValue] = useState(null);
+ return (
+
+
Deal stage
+
+
+
+ No stage matches.
+
+ {(stage: string) => (
+
+ {stage}
+
+ )}
+
+
+
+
+ value: {value ?? "—"}
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Both the field and its trigger expose role="combobox", so address the
+ // field by its placeholder.
+ const input = canvas.getByPlaceholderText("Search stages…");
+
+ await userEvent.click(input);
+ await userEvent.type(input, "Prop");
+
+ const option = await screen.findByRole("option", { name: "Proposal sent" });
+ await userEvent.click(option);
+
+ await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("Proposal sent"));
+ },
+};
+
+export const Chips: Story = {
+ render: function Chips() {
+ const [value, setValue] = useState(["Qualified"]);
+ const anchorRef = useComboboxAnchor();
+ return (
+
+
Stages of interest
+
+
+ {value.map((stage) => (
+ {stage}
+ ))}
+
+
+
+ No stage matches.
+
+ {(stage: string) => (
+
+ {stage}
+
+ )}
+
+
+
+
+ {value.join(", ") || "—"}
+
+
+ );
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The chips variant anchors the popup to the chip container rather than the input, so the " +
+ "dropdown stays put as chips wrap onto a second line. `useComboboxAnchor()` is the ref " +
+ "for that.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByTestId("selected")).toHaveTextContent("Qualified");
+
+ await userEvent.click(canvas.getByPlaceholderText("Add a stage…"));
+ await userEvent.click(await screen.findByRole("option", { name: "Won" }));
+
+ await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("Qualified, Won"));
+ },
+};
+
+export const NoMatches: Story = {
+ render: Single.render,
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`ComboboxEmpty` only shows itself when the list has nothing left, driven by " +
+ "`group-data-empty` on the popup.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const input = within(canvasElement).getByPlaceholderText("Search stages…");
+ await userEvent.click(input);
+ await userEvent.type(input, "zzzz");
+ await expect(await screen.findByText("No stage matches.")).toBeVisible();
+ },
+};
diff --git a/stories/forms/input-group.stories.tsx b/stories/forms/input-group.stories.tsx
new file mode 100644
index 0000000..912bed9
--- /dev/null
+++ b/stories/forms/input-group.stories.tsx
@@ -0,0 +1,154 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { CreditCard, Search, Send } from "lucide-react";
+import { expect, fn, userEvent, within } from "storybook/test";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+ InputGroupText,
+ InputGroupTextarea,
+} from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Forms/InputGroup",
+ component: InputGroup,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A field with attachments. `InputGroupAddon` takes an `align` of `inline-start`, " +
+ "`inline-end`, `block-start` or `block-end`, and the group reshapes itself around " +
+ "whichever are present — focus and error rings included, via `has-[…]` selectors on the " +
+ "wrapper rather than props.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const input = within(canvasElement).getByRole("textbox");
+ await userEvent.type(input, "Nordwind");
+ await expect(input).toHaveValue("Nordwind");
+ },
+};
+
+export const InlineEndButton: Story = {
+ render: (args) => (
+
+
+
+
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Clicking the addon focuses the field; clicking a button inside it does not.
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
+ await expect(canvas.getByRole("textbox")).not.toHaveFocus();
+ },
+};
+
+export const TextAddons: Story = {
+ render: (args) => (
+
+
+
+ https://
+
+
+
+
+
+
+ EUR
+
+
+
+
+
+
+
+
+ Visa
+
+
+
+ ),
+};
+
+export const BlockAddons: Story = {
+ render: (args) => (
+
+
+ Internal note
+
+
+
+
+ Save note
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Block addons stack the group vertically — the layout switch is triggered by the addon's " +
+ "`data-align`, not by a prop on the group.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const group = canvasElement.querySelector('[data-slot="input-group"]');
+ await expect(getComputedStyle(group!).flexDirection).toBe("column");
+ },
+};
+
+export const Invalid: Story = {
+ render: (args) => (
+
+
+ https://
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The whole group turns destructive when the control inside it is `aria-invalid` — the " +
+ "border lives on the wrapper, so it has to react to the child's state.",
+ },
+ },
+ },
+};
+
+export const Disabled: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ ),
+};
diff --git a/stories/forms/select.stories.tsx b/stories/forms/select.stories.tsx
new file mode 100644
index 0000000..139ade5
--- /dev/null
+++ b/stories/forms/select.stories.tsx
@@ -0,0 +1,189 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import {
+ Label,
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "../../src/index.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const meta: Meta = {
+ title: "Forms/Select",
+ component: Select,
+ argTypes: {
+ disabled: { control: "boolean" },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A single-choice dropdown. The listbox is portalled, so a play function looks for it on " +
+ "the screen rather than inside the story canvas.\n\n" +
+ '`SelectTrigger` renders a ``, and that role does not take its ' +
+ "accessible name from its contents — the selected value on screen does **not** name the " +
+ "control. Every trigger therefore needs a `` or an `aria-label`, which is " +
+ "what `TablePagination` does internally.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+ Deal stage
+
+
+
+
+
+ Lead
+ Qualified
+ Proposal sent
+ Won
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const trigger = within(canvasElement).getByRole("combobox");
+ await expect(trigger).toHaveTextContent("Pick a stage");
+
+ await userEvent.click(trigger);
+ await userEvent.click(await screen.findByRole("option", { name: "Proposal sent" }));
+
+ await waitFor(() => expect(trigger).toHaveTextContent("Proposal sent"));
+ },
+};
+
+export const Grouped: Story = {
+ render: (args) => (
+
+
+
+
+
+
+ Sales
+ DACH
+ Nordics
+
+
+
+ Delivery
+ Platform
+ Support
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("combobox"));
+ const listbox = await screen.findByRole("listbox");
+ // Group labels are not options, so they cannot be selected by keyboard either.
+ await expect(within(listbox).getAllByRole("option")).toHaveLength(4);
+ await expectVisible(within(listbox).getByText("Sales"));
+
+ // Leave the story closed: an open Radix overlay marks the rest of the page
+ // aria-hidden, which is a false positive for anything auditing afterwards.
+ await userEvent.keyboard("{Escape}");
+ },
+};
+
+export const WithDisabledItem: Story = {
+ render: (args) => (
+
+
+
+
+
+ Starter
+ Team
+
+ Enterprise (contact sales)
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("combobox"));
+ const option = await screen.findByRole("option", { name: /Enterprise/ });
+ await expect(option).toHaveAttribute("data-disabled");
+ await userEvent.keyboard("{Escape}");
+ },
+};
+
+export const Controlled: Story = {
+ render: function Controlled(args) {
+ const [value, setValue] = useState("qualified");
+ return (
+
+
+
+
+
+
+ Lead
+ Qualified
+ Won
+
+
+
+ value: {value}
+
+
+ );
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByTestId("selected")).toHaveTextContent("value: qualified");
+
+ await userEvent.click(canvas.getByRole("combobox"));
+ await userEvent.click(await screen.findByRole("option", { name: "Won" }));
+
+ await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("value: won"));
+ },
+};
+
+export const Sizes: Story = {
+ render: (args) => (
+
+ {(["default", "sm"] as const).map((size) => (
+
+
+
+
+
+ Option A
+ Option B
+
+
+ ))}
+
+ ),
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+ render: (args) => (
+
+
+
+
+
+ Lead
+
+
+ ),
+};
diff --git a/stories/forms/tag-form.stories.tsx b/stories/forms/tag-form.stories.tsx
new file mode 100644
index 0000000..f37adc8
--- /dev/null
+++ b/stories/forms/tag-form.stories.tsx
@@ -0,0 +1,143 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn, userEvent, waitFor, within } from "storybook/test";
+import { TagForm } from "../../src/index.ts";
+import type { TagDto } from "../../src/index.ts";
+import { TAGS, TAG_FORM_TRANSLATIONS, delay } from "../support/fixtures.ts";
+
+interface TagFormStoryProps {
+ readonly tag?: TagDto;
+ readonly latencyMs: number;
+ /** Reject the save with `CONFLICT`, which the form turns into a name error. */
+ readonly nameTaken: boolean;
+}
+
+function TagFormHarness({ tag, latencyMs, nameTaken }: TagFormStoryProps) {
+ const [saved, setSaved] = useState(null);
+ return (
+
+
{
+ await delay(latencyMs);
+ if (nameTaken) throw new Error("CONFLICT");
+ setSaved(JSON.stringify(data));
+ }}
+ />
+
+ {saved ?? "not saved yet"}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Forms/TagForm",
+ render: (args) => ,
+ args: { tag: undefined, latencyMs: 400, nameTaken: false },
+ argTypes: {
+ tag: { table: { disable: true } },
+ latencyMs: { control: { type: "range", min: 0, max: 3000, step: 100 } },
+ nameTaken: {
+ control: "boolean",
+ description:
+ "Make `onSave` reject with the literal message `CONFLICT` — the only rejection the form " +
+ "recognises, which it shows as a name error.",
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Create or edit a tag. Validation is local: a name is required, and the colour must be a " +
+ "six-digit hex, reachable either from the swatch palette or by typing.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Create: Story = {};
+
+export const Edit: Story = {
+ args: { tag: TAGS[0] },
+ parameters: {
+ docs: { description: { story: "Seeded from an existing tag; the matching swatch is ticked." } },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByLabelText(/Name/)).toHaveValue("Key account");
+ await expect(canvas.getByDisplayValue("#5CBA9E")).toBeVisible();
+ },
+};
+
+export const ValidationErrors: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Submitting an untouched form reports both required fields at once.
+ await userEvent.click(canvas.getByRole("button", { name: TAG_FORM_TRANSLATIONS.save }));
+
+ await expect(await canvas.findByText(TAG_FORM_TRANSLATIONS.nameRequired)).toBeVisible();
+ await expect(canvas.getByText(TAG_FORM_TRANSLATIONS.colorRequired)).toBeVisible();
+ await expect(canvas.getByTestId("saved")).toHaveTextContent("not saved yet");
+ },
+};
+
+export const InvalidColour: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(canvas.getByLabelText(/Name/), "Partner");
+ await userEvent.type(
+ canvas.getByPlaceholderText(TAG_FORM_TRANSLATIONS.colorPlaceholder),
+ "red",
+ );
+ await userEvent.click(canvas.getByRole("button", { name: TAG_FORM_TRANSLATIONS.save }));
+
+ await expect(await canvas.findByText(TAG_FORM_TRANSLATIONS.colorInvalid)).toBeVisible();
+ },
+};
+
+export const SaveFromThePalette: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(canvas.getByLabelText(/Name/), "Partner");
+
+ // The palette swatches are unlabelled buttons; the third is a warm amber.
+ const swatches = canvas
+ .getAllByRole("button")
+ .filter((b) => b.getAttribute("style")?.includes("background-color"));
+ await userEvent.click(swatches[2]);
+
+ // Choosing a swatch fills the hex field, which is the same state the text input writes to.
+ await waitFor(() => expect(canvas.getByDisplayValue(/^#/)).toBeVisible());
+
+ await userEvent.click(canvas.getByRole("button", { name: TAG_FORM_TRANSLATIONS.save }));
+ await waitFor(() => expect(canvas.getByTestId("saved")).toHaveTextContent('"name":"Partner"'), {
+ timeout: 4000,
+ });
+ },
+};
+
+export const NameConflict: Story = {
+ args: { nameTaken: true },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.type(canvas.getByLabelText(/Name/), "Key account");
+ await userEvent.type(
+ canvas.getByPlaceholderText(TAG_FORM_TRANSLATIONS.colorPlaceholder),
+ "#5CBA9E",
+ );
+ await userEvent.click(canvas.getByRole("button", { name: TAG_FORM_TRANSLATIONS.save }));
+
+ await expect(
+ await canvas.findByText(TAG_FORM_TRANSLATIONS.nameConflict, undefined, { timeout: 4000 }),
+ ).toBeVisible();
+ // The form is usable again, not stuck in its submitting state.
+ await expect(canvas.getByRole("button", { name: TAG_FORM_TRANSLATIONS.save })).toBeEnabled();
+ },
+};
diff --git a/stories/forms/tag-multi-select.stories.tsx b/stories/forms/tag-multi-select.stories.tsx
new file mode 100644
index 0000000..a9e1c41
--- /dev/null
+++ b/stories/forms/tag-multi-select.stories.tsx
@@ -0,0 +1,145 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import { Label, TagMultiSelect } from "../../src/index.ts";
+import type { TagOption } from "../../src/index.ts";
+import { TAG_MULTI_SELECT_TRANSLATIONS, TAG_OPTIONS, delay } from "../support/fixtures.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+interface TagStoryProps {
+ readonly initialSelectedIds: readonly string[];
+ readonly latencyMs: number;
+ readonly options: readonly TagOption[];
+}
+
+function TagMultiSelectHarness({ initialSelectedIds, latencyMs, options }: TagStoryProps) {
+ const [selectedIds, setSelectedIds] = useState(initialSelectedIds);
+
+ async function loadTags(): Promise {
+ await delay(latencyMs);
+ return [...options];
+ }
+
+ return (
+
+
Tags
+
+
+ {selectedIds.length > 0 ? selectedIds.join(", ") : "nothing selected"}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Forms/TagMultiSelect",
+ render: (args) => ,
+ args: { initialSelectedIds: ["t-1"], latencyMs: 250, options: TAG_OPTIONS },
+ argTypes: {
+ initialSelectedIds: { control: "check", options: TAG_OPTIONS.map((o) => o.value) },
+ latencyMs: {
+ control: { type: "range", min: 0, max: 3000, step: 50 },
+ description: "How long `loadTags` takes. The field renders before the options arrive.",
+ },
+ options: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A chips picker over tags loaded asynchronously. Each chip is painted with the tag's own " +
+ "hex colour and an automatically chosen foreground, so a pale tag stays readable. An " +
+ "invalid colour falls back to grey.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ // The pre-selected chip only appears once loadTags resolves.
+ await waitFor(() => expect(canvas.getByText("Key account")).toBeVisible());
+
+ await userEvent.click(canvas.getByRole("combobox"));
+ await userEvent.click(await screen.findByRole("option", { name: /Prospect/ }));
+
+ await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("t-1, t-2"));
+ },
+};
+
+export const ContrastAcrossColours: Story = {
+ args: { initialSelectedIds: TAG_OPTIONS.map((o) => o.value) },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Every fixture tag at once: a dark green, a mid blue, a pink, a very light yellow that " +
+ "must flip to dark text, and one with a malformed colour that falls back to grey.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await waitFor(() => expect(canvas.getByText("Newsletter")).toBeVisible());
+
+ // The light yellow chip gets dark text rather than the default white.
+ await expect(canvas.getByText("Newsletter").closest("[data-slot=combobox-chip]")).toHaveStyle({
+ color: "#1A1A1A",
+ });
+
+ // The malformed colour falls back to grey rather than rendering transparent.
+ const fallback = canvas
+ .getByText("Unclassified")
+ .closest("[data-slot=combobox-chip]") as HTMLElement;
+ await expect(fallback.style.backgroundColor).not.toBe("");
+ },
+};
+
+export const Empty: Story = {
+ args: { options: [], initialSelectedIds: [] },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "No tags exist yet, so the dropdown explains how to get one instead of showing a blank box.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("combobox"));
+ await expectVisible(await screen.findByText(TAG_MULTI_SELECT_TRANSLATIONS.empty));
+ },
+};
+
+export const SlowLoad: Story = {
+ args: { latencyMs: 2500, initialSelectedIds: ["t-1", "t-3"] },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "A deliberately slow `loadTags`. Note that the selected chips are derived from the " +
+ "loaded options, so the field looks empty until the request lands — worth knowing when " +
+ "a consuming app renders this on a detail page.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Selected ids are already set, yet nothing is shown yet.
+ await expect(canvas.getByTestId("selected")).toHaveTextContent("t-1, t-3");
+ await expect(canvas.queryByText("Key account")).toBeNull();
+
+ await waitFor(() => expect(canvas.getByText("Key account")).toBeVisible(), { timeout: 6000 });
+ },
+};
diff --git a/stories/forms/user-multi-select.stories.tsx b/stories/forms/user-multi-select.stories.tsx
new file mode 100644
index 0000000..91df65a
--- /dev/null
+++ b/stories/forms/user-multi-select.stories.tsx
@@ -0,0 +1,124 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import { Label, UserMultiSelect } from "../../src/index.ts";
+import { USERS, USER_MULTI_SELECT_TRANSLATIONS } from "../support/fixtures.ts";
+
+interface UserStoryProps {
+ readonly initialSelectedIds: readonly string[];
+ readonly disabledIds: readonly string[];
+}
+
+function UserMultiSelectHarness({ initialSelectedIds, disabledIds }: UserStoryProps) {
+ const [selectedIds, setSelectedIds] = useState(initialSelectedIds);
+ return (
+
+
Assignees
+
+
+ {selectedIds.length > 0 ? selectedIds.join(", ") : "nobody assigned"}
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Forms/UserMultiSelect",
+ render: (args) => ,
+ args: { initialSelectedIds: [], disabledIds: [] },
+ argTypes: {
+ initialSelectedIds: { control: "check", options: USERS.map((u) => u.id) },
+ disabledIds: {
+ control: "check",
+ options: USERS.map((u) => u.id),
+ description: "People who cannot be added or removed — an owner the app pins, for instance.",
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A popover picker over a fixed list of people, with search over both name and email. " +
+ "Selected people appear as removable chips on the trigger itself.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Empty: Story = {
+ play: async ({ canvasElement }) => {
+ await expect(
+ within(canvasElement).getByText(USER_MULTI_SELECT_TRANSLATIONS.placeholder),
+ ).toBeVisible();
+ },
+};
+
+export const SelectAndRemove: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("combobox"));
+
+ await userEvent.click(await screen.findByRole("button", { name: /Ben Okafor/ }));
+ await waitFor(() => expect(canvas.getByTestId("selected")).toHaveTextContent("u-2"));
+
+ // Close the popover so the chip on the trigger is the only match left.
+ await userEvent.keyboard("{Escape}");
+
+ const chip = canvas.getByText("Ben Okafor").closest("span");
+ await userEvent.click(within(chip!.parentElement!).getByRole("button"));
+ await waitFor(() =>
+ expect(canvas.getByTestId("selected")).toHaveTextContent("nobody assigned"),
+ );
+ },
+};
+
+export const Search: Story = {
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("combobox"));
+
+ const search = await screen.findByPlaceholderText(
+ USER_MULTI_SELECT_TRANSLATIONS.searchPlaceholder,
+ );
+ // Matching is on the email as well as the name.
+ await userEvent.type(search, "clara.nguyen@");
+ await waitFor(async () =>
+ expect(await screen.findAllByRole("button", { name: /Nguyen/ })).toHaveLength(1),
+ );
+
+ await userEvent.clear(search);
+ await userEvent.type(search, "nobody");
+ await expect(await screen.findByText(USER_MULTI_SELECT_TRANSLATIONS.empty)).toBeVisible();
+ },
+};
+
+export const WithPinnedAssignee: Story = {
+ args: { initialSelectedIds: ["u-1"], disabledIds: ["u-1"] },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "A disabled id keeps its chip but loses the remove control, and its row in the list " +
+ "cannot be toggled.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const chip = canvas.getByText("Anna Weber").closest("span");
+ // No remove affordance on a pinned chip.
+ await expect(within(chip!.parentElement!).queryByRole("button")).toBeNull();
+
+ await userEvent.click(canvas.getByRole("combobox"));
+ await expect(await screen.findByRole("button", { name: /Anna Weber/ })).toBeDisabled();
+ },
+};
diff --git a/stories/markdown-editor.stories.tsx b/stories/markdown/markdown-editor.stories.tsx
similarity index 97%
rename from stories/markdown-editor.stories.tsx
rename to stories/markdown/markdown-editor.stories.tsx
index 054e976..0764f74 100644
--- a/stories/markdown-editor.stories.tsx
+++ b/stories/markdown/markdown-editor.stories.tsx
@@ -1,11 +1,11 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
-import { MarkdownEditor } from "../src/index.ts";
-import type { MarkdownEditorProps, MarkdownToolbarAction } from "../src/index.ts";
-import { Panel, SplitLayout } from "./support/panel.tsx";
-import { MOD_SHIFT_9, TASK_ITEM_INPUT_RULE } from "./support/keyboard.ts";
-import { waitForEditor } from "./support/editor.ts";
+import { MarkdownEditor } from "../../src/index.ts";
+import type { MarkdownEditorProps, MarkdownToolbarAction } from "../../src/index.ts";
+import { Panel, SplitLayout } from "../support/panel.tsx";
+import { MOD_SHIFT_9, TASK_ITEM_INPUT_RULE } from "../support/keyboard.ts";
+import { waitForEditor } from "../support/editor.ts";
/** Every action the toolbar can offer, in the order the type declares them. */
const TOOLBAR_ACTIONS: readonly MarkdownToolbarAction[] = [
diff --git a/stories/markdown-view.stories.tsx b/stories/markdown/markdown-view.stories.tsx
similarity index 97%
rename from stories/markdown-view.stories.tsx
rename to stories/markdown/markdown-view.stories.tsx
index 365d52a..0fc2f37 100644
--- a/stories/markdown-view.stories.tsx
+++ b/stories/markdown/markdown-view.stories.tsx
@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
-import { MarkdownView } from "../src/index.ts";
-import { Panel, SplitLayout } from "./support/panel.tsx";
-import { useMockSave } from "./support/use-mock-save.ts";
-import { waitForEditor } from "./support/editor.ts";
+import { MarkdownView } from "../../src/index.ts";
+import { Panel, SplitLayout } from "../support/panel.tsx";
+import { useMockSave } from "../support/use-mock-save.ts";
+import { waitForEditor } from "../support/editor.ts";
const CHECKLIST = [
"Onboarding for the new hire:",
diff --git a/stories/navigation/language-switch.stories.tsx b/stories/navigation/language-switch.stories.tsx
new file mode 100644
index 0000000..d6f04f5
--- /dev/null
+++ b/stories/navigation/language-switch.stories.tsx
@@ -0,0 +1,81 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { LanguageSwitch, useLanguage } from "../../src/index.ts";
+
+function CurrentLanguage() {
+ const { language } = useLanguage();
+ return (
+
+ language: {language}
+
+ );
+}
+
+const meta: Meta = {
+ title: "Navigation/LanguageSwitch",
+ component: LanguageSwitch,
+ render: () => (
+ // The switch is styled for the dark sidebar it lives in, so it needs a dark
+ // ground to be legible at all.
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A DE/EN toggle wired straight into `LanguageProvider` — it takes no props. The active " +
+ "language is written to `localStorage`, so the choice survives a reload. Its colours " +
+ "assume the dark sidebar background it is normally rendered on.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const SwitchingLanguage: Story = {
+ render: () => (
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // The showcase's provider is pinned to English.
+ await expect(canvas.getByTestId("language")).toHaveTextContent("language: en");
+
+ await userEvent.click(canvas.getByRole("button", { name: "DE" }));
+ await waitFor(() => expect(canvas.getByTestId("language")).toHaveTextContent("language: de"));
+ await expect(localStorage.getItem("language")).toBe("de");
+
+ // Put it back so the next story does not inherit German.
+ await userEvent.click(canvas.getByRole("button", { name: "EN" }));
+ await waitFor(() => expect(canvas.getByTestId("language")).toHaveTextContent("language: en"));
+ },
+};
+
+export const OnLight: Story = {
+ render: () => (
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The same component without its dark ground. The inactive option is `text-oe-white/70`, " +
+ "so it all but disappears — worth knowing before dropping this into a light surface.",
+ },
+ },
+ },
+};
diff --git a/stories/navigation/sidebar.stories.tsx b/stories/navigation/sidebar.stories.tsx
new file mode 100644
index 0000000..c21c633
--- /dev/null
+++ b/stories/navigation/sidebar.stories.tsx
@@ -0,0 +1,234 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Building2, Contact, LayoutDashboard, ListTodo, Settings, Tags } from "lucide-react";
+import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test";
+import { CollapsibleGroup, NavItem, Sidebar, SidebarHeader, UserSection } from "../../src/index.ts";
+import { USER_SECTION_TRANSLATIONS } from "../support/fixtures.ts";
+
+const USER = {
+ userName: "Anna Weber",
+ roles: ["Administrator", "Sales"],
+ onLogout: fn(),
+ onAvatarClick: fn(),
+ translations: USER_SECTION_TRANSLATIONS,
+};
+
+function Nav() {
+ return (
+ <>
+ }
+ label="Dashboard"
+ active
+ />
+ } label="Companies" />
+ } label="Contacts" />
+ } label="Administration">
+ } label="Tags" indented />
+ }
+ label="Task types"
+ indented
+ />
+
+ >
+ );
+}
+
+const meta: Meta = {
+ title: "Navigation/Sidebar",
+ component: Sidebar,
+ args: { appTitle: "Open CRM", menuLabel: "Menu" },
+ argTypes: {
+ appTitle: { control: "text" },
+ menuLabel: { control: "text", description: "Accessible name of the mobile hamburger." },
+ developedByText: { control: "text" },
+ header: { table: { disable: true } },
+ user: { table: { disable: true } },
+ children: { table: { disable: true } },
+ bottomChildren: { table: { disable: true } },
+ },
+ parameters: {
+ layout: "fullscreen",
+ docs: {
+ description: {
+ component:
+ "The application shell's navigation. It renders both variants at once and lets CSS " +
+ "choose: a fixed rail from the `md` breakpoint up, a hamburger opening a `Sheet` below " +
+ "it. Which one you see here depends on the width of the preview — narrow the viewport to " +
+ "get the mobile header.\n\n" +
+ "Note that the header links to `/oe-logo-landscape-dark.svg`; the consuming app has to " +
+ "serve that asset, and in this showcase it is simply missing.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getAllByText("Open CRM").length).toBeGreaterThan(0);
+ await expect(canvas.getByRole("link", { name: /Companies/ })).toBeVisible();
+
+ // Both variants are always in the DOM; a media query picks one. At the
+ // preview's default width that is the rail, and the mobile header is off.
+ const rail = canvasElement.querySelector("aside")!;
+ const mobileHeader = canvasElement.querySelector("header")!;
+ await expect(getComputedStyle(rail).display).not.toBe("none");
+ await expect(getComputedStyle(mobileHeader).display).toBe("none");
+ },
+};
+
+export const CollapsibleGroups: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "A group starts closed and keeps its own open state; the chevron rotates rather than " +
+ "swapping icon.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Closed to begin with, so the nested items are not in the DOM at all.
+ await expect(canvas.queryByRole("link", { name: /Task types/ })).toBeNull();
+
+ await userEvent.click(canvas.getAllByRole("button", { name: /Administration/ })[0]);
+ await waitFor(() =>
+ expect(canvas.getAllByRole("link", { name: /Task types/ })[0]).toBeVisible(),
+ );
+ },
+};
+
+export const WithoutUser: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: "`user` is optional — the language switch stays, the account block disappears.",
+ },
+ },
+ },
+};
+
+export const WithBottomItems: Story = {
+ render: (args) => (
+
+ } label="Settings" />
+ }
+ >
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: "`bottomChildren` is pushed to the foot of the nav, above the language switch.",
+ },
+ },
+ },
+};
+
+export const MobileMenu: Story = {
+ globals: { viewport: { value: "mobile1" } },
+ render: (args) => (
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Below `md` the rail is replaced by a header with a hamburger. Opening it mounts the same " +
+ "nav inside a left `Sheet`, and every `NavItem` gets an extra handler that closes the " +
+ "sheet — which is why the sidebar clones its children rather than rendering them twice.\n\n" +
+ "The story sets a phone viewport, but the switch is a CSS media query on the preview " +
+ "frame: at a desktop width the hamburger is `display: none` and therefore not in the " +
+ "accessibility tree at all. That is also why this story carries no `play` function — " +
+ "narrow the preview and open the drawer by hand.",
+ },
+ },
+ },
+};
+
+export const HeaderOnly: Story = {
+ render: (args) => (
+
+
+
+ ),
+ parameters: {
+ layout: "padded",
+ docs: {
+ description: {
+ story:
+ "`SidebarHeader` on its own, for an app that supplies a custom `header`. The " +
+ '"developed by" logo is an app-served asset and is missing here.',
+ },
+ },
+ },
+};
+
+export const UserSectionOnly: Story = {
+ render: () => (
+
+
+
+ ),
+ parameters: { layout: "padded" },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // The roles are only reachable through the tooltip on the name.
+ await userEvent.hover(canvas.getByText("Anna Weber"));
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent("Administrator, Sales");
+ },
+};
+
+export const UserSectionWithoutRoles: Story = {
+ render: () => (
+
+
+
+ ),
+ parameters: { layout: "padded" },
+ play: async ({ canvasElement }) => {
+ await userEvent.hover(within(canvasElement).getByText("Anna Weber"));
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ USER_SECTION_TRANSLATIONS.noRoles,
+ );
+ },
+};
diff --git a/stories/overlays/alert-dialog.stories.tsx b/stories/overlays/alert-dialog.stories.tsx
new file mode 100644
index 0000000..0b8a059
--- /dev/null
+++ b/stories/overlays/alert-dialog.stories.tsx
@@ -0,0 +1,142 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { TriangleAlert } from "lucide-react";
+import { expect, fn, screen, userEvent, waitForElementToBeRemoved, within } from "storybook/test";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+ Button,
+} from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Overlays/AlertDialog",
+ component: AlertDialog,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A dialog that demands a decision: no corner cross, no dismiss on outside click. " +
+ "`AlertDialogAction` and `AlertDialogCancel` are buttons in disguise and take the same " +
+ "`variant` and `size` props.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Delete company
+
+
+
+ Delete Open Elements GmbH?
+
+ Its contacts stay, but the link to them is lost. This cannot be undone.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Delete company" }));
+
+ const dialog = await screen.findByRole("alertdialog");
+ // Unlike Dialog, there is no dismiss affordance beyond the two choices.
+ await expect(within(dialog).getAllByRole("button")).toHaveLength(2);
+
+ await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
+ await waitForElementToBeRemoved(() => screen.queryByRole("alertdialog"));
+ },
+};
+
+export const WithMedia: Story = {
+ render: (args) => (
+
+
+ Discard draft
+
+
+
+
+
+
+ Discard this draft?
+
+ The note has unsaved changes that will be lost.
+
+
+
+ Keep editing
+ Discard
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "An `AlertDialogMedia` slot moves the icon into its own column and re-flows the header " +
+ "grid around it — the header rules key off `has-data-[slot=alert-dialog-media]`.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Discard draft" }));
+ const dialog = await screen.findByRole("alertdialog");
+ await expect(dialog.querySelector('[data-slot="alert-dialog-media"]')).not.toBeNull();
+ },
+};
+
+export const Small: Story = {
+ render: (args) => (
+
+
+ Sign out
+
+
+
+ Sign out?
+ You will need to log in again.
+
+
+ Stay
+ Sign out
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ '`size="sm"` narrows the box and lays the two actions out as an even two-column grid ' +
+ "instead of a right-aligned row.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Sign out" }));
+ const dialog = await screen.findByRole("alertdialog");
+ await expect(dialog).toHaveAttribute("data-size", "sm");
+ },
+};
diff --git a/stories/overlays/delete-confirm-dialog.stories.tsx b/stories/overlays/delete-confirm-dialog.stories.tsx
new file mode 100644
index 0000000..af9642c
--- /dev/null
+++ b/stories/overlays/delete-confirm-dialog.stories.tsx
@@ -0,0 +1,134 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import { Button, DeleteConfirmDialog } from "../../src/index.ts";
+import { delay } from "../support/fixtures.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+interface DeleteStoryProps {
+ readonly latencyMs: number;
+ readonly failsWith: string | null;
+}
+
+/**
+ * The dialog is prop-driven — it owns nothing but its own loading flag — so a
+ * story has to hold `open` and hand it a confirm handler.
+ */
+function DeleteConfirmHarness({ latencyMs, failsWith }: DeleteStoryProps) {
+ const [open, setOpen] = useState(false);
+ const [deleted, setDeleted] = useState(false);
+
+ return (
+
+
setOpen(true)}>
+ Delete company
+
+ {deleted && (
+
+ Deleted.
+
+ )}
+
{
+ await delay(latencyMs);
+ setDeleted(true);
+ setOpen(false);
+ }}
+ />
+
+ );
+}
+
+const meta: Meta = {
+ title: "Overlays/DeleteConfirmDialog",
+ render: (args) => ,
+ args: { latencyMs: 800, failsWith: null },
+ argTypes: {
+ latencyMs: {
+ control: { type: "range", min: 0, max: 4000, step: 100 },
+ description: "How long the delete appears to take.",
+ },
+ failsWith: {
+ control: "text",
+ description:
+ "Set a message to put the dialog into its error shape: the description is replaced, the " +
+ "title falls back to `errorTitle`, and only a dismiss button remains.",
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A confirmation for destructive actions, built on `AlertDialog`. It spins while " +
+ "`onConfirm` is in flight and refuses a second click until that promise settles.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Delete company" }));
+
+ const dialog = await screen.findByRole("alertdialog");
+ await expectVisible(within(dialog).getByText("Delete Open Elements GmbH?"));
+
+ await userEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
+
+ // Disabled for the whole in-flight window, so a double click cannot delete twice.
+ await waitFor(() =>
+ expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled(),
+ );
+
+ await waitFor(() => expect(canvas.getByTestId("outcome")).toBeVisible(), { timeout: 4000 });
+ },
+};
+
+export const Cancelled: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Delete company" }));
+
+ const dialog = await screen.findByRole("alertdialog");
+ await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
+
+ await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
+ await expect(canvas.queryByTestId("outcome")).toBeNull();
+ },
+};
+
+export const Failed: Story = {
+ args: { failsWith: "The company is referenced by three open invoices." },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "With an `error` set the dialog stops offering the destructive action at all — there is " +
+ "one button left, and it only dismisses.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Delete company" }));
+
+ const dialog = await screen.findByRole("alertdialog");
+ await expectVisible(within(dialog).getByText("Could not delete"));
+ await expectVisible(
+ within(dialog).getByText("The company is referenced by three open invoices."),
+ );
+ await expect(within(dialog).getAllByRole("button")).toHaveLength(1);
+ },
+};
diff --git a/stories/overlays/dialog.stories.tsx b/stories/overlays/dialog.stories.tsx
new file mode 100644
index 0000000..c1bab5b
--- /dev/null
+++ b/stories/overlays/dialog.stories.tsx
@@ -0,0 +1,139 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import {
+ expect,
+ screen,
+ userEvent,
+ waitFor,
+ waitForElementToBeRemoved,
+ within,
+} from "storybook/test";
+import {
+ Button,
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ Input,
+ Label,
+} from "../../src/index.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const meta: Meta = {
+ title: "Overlays/Dialog",
+ component: Dialog,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A modal dialog. The content is portalled to `document.body`, so a play function has to " +
+ "query the whole screen rather than the story canvas.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Edit contact
+
+
+
+ Edit contact
+ Changes are saved when you confirm.
+
+
+ Name
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Edit contact" }));
+
+ const dialog = await screen.findByRole("dialog");
+ await expectVisible(within(dialog).getByText("Edit contact"));
+
+ // The title and description are wired to the dialog, so it announces itself.
+ await expect(dialog).toHaveAttribute("aria-labelledby");
+ await expect(dialog).toHaveAttribute("aria-describedby");
+
+ await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
+ await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
+ },
+};
+
+export const ClosesOnEscape: Story = {
+ render: Default.render,
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Edit contact" }));
+ await screen.findByRole("dialog");
+ await userEvent.keyboard("{Escape}");
+ await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
+ },
+};
+
+export const WithoutCloseButton: Story = {
+ render: (args) => (
+
+
+ Open
+
+
+
+ No corner cross
+
+ Use this when the only way out should be an explicit choice in the footer.
+
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Open" }));
+ const dialog = await screen.findByRole("dialog");
+ // The corner cross is gone; the footer's own Close button is not.
+ await expect(within(dialog).getAllByRole("button", { name: "Close" })).toHaveLength(1);
+ // Worth knowing: `DialogFooter`'s built-in close renders the Radix primitive
+ // directly, so unlike the exported `DialogClose` it carries no
+ // `data-slot="dialog-close"` for a consuming app to hook styling onto.
+ await expect(dialog.querySelectorAll('[data-slot="dialog-close"]')).toHaveLength(0);
+ },
+};
+
+export const Controlled: Story = {
+ args: { open: true },
+ argTypes: { open: { control: "boolean" } },
+ render: (args) => (
+
+
+
+ Controlled from the Controls panel
+ Toggle the `open` arg to show and hide this dialog.
+
+
+
+ ),
+ play: async () => {
+ await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible());
+ },
+};
diff --git a/stories/overlays/popover.stories.tsx b/stories/overlays/popover.stories.tsx
new file mode 100644
index 0000000..2076395
--- /dev/null
+++ b/stories/overlays/popover.stories.tsx
@@ -0,0 +1,104 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitForElementToBeRemoved, within } from "storybook/test";
+import {
+ Button,
+ Input,
+ Label,
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+} from "../../src/index.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const meta: Meta = {
+ title: "Overlays/Popover",
+ component: Popover,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A non-modal panel anchored to its trigger. Positioning is Radix's; the component only " +
+ "supplies the surface, a default width of 18rem and the side-aware enter animation.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Set dimensions
+
+
+
+ Dimensions
+ Applied to the selected block.
+
+
+ Width
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const trigger = within(canvasElement).getByRole("button", { name: "Set dimensions" });
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+
+ await userEvent.click(trigger);
+ const content = await screen.findByText("Dimensions");
+ await expectVisible(content);
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
+
+ await userEvent.keyboard("{Escape}");
+ await waitForElementToBeRemoved(() => screen.queryByText("Dimensions"));
+ },
+};
+
+export const Sides: Story = {
+ render: (args) => (
+
+ {(["top", "right", "bottom", "left"] as const).map((side) => (
+
+
+ {side}
+
+
+ Anchored to the {side}.
+
+
+ ))}
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "right" }));
+ const content = await screen.findByText("Anchored to the right.");
+ // Radix records the resolved side, which may flip if there is no room.
+ await expect(content.closest("[data-side]")).toHaveAttribute("data-side");
+ },
+};
+
+export const Controlled: Story = {
+ args: { open: true },
+ argTypes: { open: { control: "boolean" } },
+ render: (args) => (
+
+
+
+ Controlled
+
+
+ Toggle the `open` arg to show and hide this.
+
+
+
+ ),
+};
diff --git a/stories/overlays/sheet.stories.tsx b/stories/overlays/sheet.stories.tsx
new file mode 100644
index 0000000..64d771a
--- /dev/null
+++ b/stories/overlays/sheet.stories.tsx
@@ -0,0 +1,129 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import {
+ expect,
+ screen,
+ userEvent,
+ waitFor,
+ waitForElementToBeRemoved,
+ within,
+} from "storybook/test";
+import {
+ Button,
+ Input,
+ Label,
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "../../src/index.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const SIDES = ["right", "left", "top", "bottom"] as const;
+
+const meta: Meta = {
+ title: "Overlays/Sheet",
+ component: Sheet,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A panel that slides in from an edge. Which edge is a prop on `SheetContent`, not on the " +
+ "root, because the root has no DOM of its own.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Edit profile
+
+
+
+ Edit profile
+ Changes apply immediately.
+
+
+ Display name
+
+
+
+
+ Done
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Edit profile" }));
+ const sheet = await screen.findByRole("dialog");
+ await expectVisible(within(sheet).getByText("Edit profile"));
+ await userEvent.click(within(sheet).getByRole("button", { name: "Done" }));
+ await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
+ },
+};
+
+export const Sides: Story = {
+ render: (args) => (
+
+ {SIDES.map((side) => (
+
+
+ From {side}
+
+
+
+ side="{side}"
+
+ Left and right take three quarters of the width; top and bottom size to their
+ content.
+
+
+
+
+ ))}
+
+ ),
+ play: async ({ canvasElement }) => {
+ // A left sheet is anchored to the left edge, which is what `side` actually controls.
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "From left" }));
+ const sheet = await screen.findByRole("dialog");
+ // It slides in from off-screen, so wait for it to land rather than catching
+ // it mid-transition.
+ await waitFor(() => expect(sheet.getBoundingClientRect().left).toBe(0));
+ },
+};
+
+export const WithoutCloseButton: Story = {
+ render: (args) => (
+
+
+ Open
+
+
+
+ No corner cross
+ Escape and the overlay still dismiss it.
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Open" }));
+ const sheet = await screen.findByRole("dialog");
+ await expect(within(sheet).queryByRole("button", { name: "Close" })).toBeNull();
+ await userEvent.keyboard("{Escape}");
+ await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
+ },
+};
diff --git a/stories/overlays/tooltip.stories.tsx b/stories/overlays/tooltip.stories.tsx
new file mode 100644
index 0000000..e24bfc2
--- /dev/null
+++ b/stories/overlays/tooltip.stories.tsx
@@ -0,0 +1,101 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Info } from "lucide-react";
+import { expect, screen, userEvent, within } from "storybook/test";
+import { Button, Tooltip, TooltipContent, TooltipTrigger } from "../../src/index.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const meta: Meta = {
+ title: "Overlays/Tooltip",
+ component: Tooltip,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A hover/focus hint. It must be inside a `TooltipProvider` — Radix throws otherwise — so " +
+ "the showcase supplies one globally, exactly as a consuming app does at its root.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ Hover me
+
+ Exports the current view as CSV
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await userEvent.hover(within(canvasElement).getByRole("button", { name: "Hover me" }));
+ await expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ "Exports the current view as CSV",
+ );
+ },
+};
+
+export const OpensOnFocus: Story = {
+ render: Default.render,
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Keyboard users get the same hint — the trigger reveals it on focus, not just hover.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ within(canvasElement).getByRole("button", { name: "Hover me" }).focus();
+ await expectVisible(await screen.findByRole("tooltip"));
+ },
+};
+
+export const Sides: Story = {
+ render: (args) => (
+
+ {(["top", "right", "bottom", "left"] as const).map((side) => (
+
+
+
+
+
+
+ Shown on the {side}
+
+ ))}
+
+ ),
+};
+
+export const OnPlainText: Story = {
+ render: (args) => (
+
+ The invoice is{" "}
+
+
+
+ overdue
+
+
+ Payment was due 14 days ago
+ {" "}
+ and a reminder has been sent.
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Any element can be the trigger via `asChild`, but it has to be focusable or keyboard " +
+ "users never see the hint — hence `tabIndex={0}` on the span.",
+ },
+ },
+ },
+};
diff --git a/stories/overlays/translate-dialog.stories.tsx b/stories/overlays/translate-dialog.stories.tsx
new file mode 100644
index 0000000..9781fe0
--- /dev/null
+++ b/stories/overlays/translate-dialog.stories.tsx
@@ -0,0 +1,118 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import { Button, TranslateDialog } from "../../src/index.ts";
+import type { TranslateResult } from "../../src/index.ts";
+import { TRANSLATE_DIALOG_TRANSLATIONS, delay } from "../support/fixtures.ts";
+import { expectVisible } from "../support/overlay.ts";
+
+const SOURCE_TEXT =
+ "Die Rechnung ist seit vierzehn Tagen überfällig; eine Erinnerung wurde bereits versendet.";
+
+interface TranslateStoryProps {
+ readonly sourceText: string;
+ readonly latencyMs: number;
+ readonly fails: boolean;
+}
+
+function TranslateHarness({ sourceText, latencyMs, fails }: TranslateStoryProps) {
+ const [open, setOpen] = useState(false);
+
+ async function onTranslate(text: string, targetLanguage: string): Promise {
+ await delay(latencyMs);
+ if (fails) throw new Error("Translation backend unavailable");
+ return {
+ translatedText: `[${targetLanguage}] The invoice has been overdue for fourteen days; a reminder has already been sent.\n\n(source: ${text.slice(0, 24)}…)`,
+ };
+ }
+
+ return (
+
+
{sourceText}
+
setOpen(true)}>
+ Translate
+
+
+
+ );
+}
+
+const meta: Meta = {
+ title: "Overlays/TranslateDialog",
+ render: (args) => ,
+ args: { sourceText: SOURCE_TEXT, latencyMs: 900, fails: false },
+ argTypes: {
+ sourceText: { control: "text" },
+ latencyMs: { control: { type: "range", min: 0, max: 4000, step: 100 } },
+ fails: { control: "boolean", description: "Make the translation request reject." },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Fires `onTranslate` every time `open` flips to true, targeting whichever language " +
+ "`useLanguage()` reports. The showcase's provider is fixed to English, so the mock is " +
+ 'called with `"en"`.',
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Translate" }));
+
+ const dialog = await screen.findByRole("dialog");
+ await expectVisible(within(dialog).getByTestId("translate-dialog-loading"));
+ // Nothing to copy while the request is in flight.
+ await expect(within(dialog).getByTestId("translate-dialog-copy")).toBeDisabled();
+
+ const result = await within(dialog).findByTestId("translate-dialog-result", undefined, {
+ timeout: 5000,
+ });
+ await expect(result).toHaveTextContent("The invoice has been overdue");
+ await expect(within(dialog).getByTestId("translate-dialog-copy")).toBeEnabled();
+ },
+};
+
+export const Failed: Story = {
+ args: { fails: true, latencyMs: 400 },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Translate" }));
+
+ const dialog = await screen.findByRole("dialog");
+ const error = await within(dialog).findByTestId("translate-dialog-error", undefined, {
+ timeout: 5000,
+ });
+ await expect(error).toHaveTextContent(TRANSLATE_DIALOG_TRANSLATIONS.error);
+ await expect(within(dialog).getByTestId("translate-dialog-copy")).toBeDisabled();
+ },
+};
+
+export const SlowRequest: Story = {
+ args: { latencyMs: 3000 },
+ parameters: {
+ docs: {
+ description: {
+ story: "Long enough to sit and look at the loading state.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByRole("button", { name: "Translate" }));
+ const dialog = await screen.findByRole("dialog");
+ await waitFor(() =>
+ expect(within(dialog).getByTestId("translate-dialog-loading")).toBeVisible(),
+ );
+ },
+};
diff --git a/stories/primitives/badge.stories.tsx b/stories/primitives/badge.stories.tsx
new file mode 100644
index 0000000..e57433a
--- /dev/null
+++ b/stories/primitives/badge.stories.tsx
@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Check, CircleAlert } from "lucide-react";
+import { expect, within } from "storybook/test";
+import { Badge } from "../../src/index.ts";
+
+const VARIANTS = ["default", "secondary", "destructive", "outline", "ghost", "link"] as const;
+
+const meta: Meta = {
+ title: "Primitives/Badge",
+ component: Badge,
+ args: { children: "Active" },
+ argTypes: {
+ variant: { control: "select", options: VARIANTS },
+ asChild: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A small status pill. Renders a `` by default; `asChild` swaps in an anchor, and " +
+ "the hover styles are written with `[a&]:` so they only apply once it is a link.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Variants: Story = {
+ render: (args) => (
+
+ {VARIANTS.map((variant) => (
+
+ {variant}
+
+ ))}
+
+ ),
+ parameters: { controls: { exclude: ["variant", "children"] } },
+};
+
+export const WithIcon: Story = {
+ args: { children: undefined },
+ render: (args) => (
+
+
+
+ Verified
+
+
+
+ Overdue
+
+
+ ),
+};
+
+export const AsLink: Story = {
+ args: { asChild: true, variant: "outline", children: undefined },
+ render: (args) => (
+
+
+ Documentation
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByRole("link")).toHaveAttribute("data-slot", "badge");
+ },
+};
diff --git a/stories/primitives/button.stories.tsx b/stories/primitives/button.stories.tsx
new file mode 100644
index 0000000..747d313
--- /dev/null
+++ b/stories/primitives/button.stories.tsx
@@ -0,0 +1,134 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Check, Download, Trash2 } from "lucide-react";
+import { expect, fn, userEvent, within } from "storybook/test";
+import { Button } from "../../src/index.ts";
+
+const VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"] as const;
+const SIZES = ["default", "xs", "sm", "lg", "icon", "icon-xs", "icon-sm", "icon-lg"] as const;
+
+const meta: Meta = {
+ title: "Primitives/Button",
+ component: Button,
+ args: { children: "Save changes", onClick: fn() },
+ argTypes: {
+ variant: { control: "select", options: VARIANTS },
+ size: { control: "select", options: SIZES },
+ disabled: { control: "boolean" },
+ asChild: { table: { disable: true } },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "The base button. `variant` and `size` come from a `cva` recipe, and both are mirrored " +
+ "onto `data-variant` / `data-size` so a consuming app can target them from CSS.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement, args }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Save changes" });
+ await expect(button).toHaveAttribute("data-variant", "default");
+ await userEvent.click(button);
+ await expect(args.onClick).toHaveBeenCalledOnce();
+ },
+};
+
+export const Variants: Story = {
+ render: (args) => (
+
+ {VARIANTS.map((variant) => (
+
+ {variant}
+
+ ))}
+
+ ),
+ parameters: { controls: { exclude: ["variant", "children"] } },
+};
+
+export const Sizes: Story = {
+ render: (args) => (
+
+ {SIZES.map((size) => (
+
+ {size.startsWith("icon") ? : size}
+
+ ))}
+
+ ),
+ parameters: { controls: { exclude: ["size", "children"] } },
+};
+
+export const WithIcon: Story = {
+ args: { children: undefined },
+ render: (args) => (
+
+
+
+ Download
+
+
+
+ Delete
+
+
+
+ Approve
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "An svg child is sized to 1rem and the horizontal padding tightens automatically — the " +
+ "recipe does it with `has-[>svg]:px-3`, so no extra prop is needed.",
+ },
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true },
+ play: async ({ canvasElement, args }) => {
+ const button = within(canvasElement).getByRole("button", { name: "Save changes" });
+ await expect(button).toBeDisabled();
+ // The recipe also removes pointer events, so a click never reaches the
+ // handler in the first place. (That is why `TooltipIconButton` has to wrap a
+ // disabled trigger in a span to keep its tooltip working.)
+ await expect(getComputedStyle(button).pointerEvents).toBe("none");
+ await expect(args.onClick).not.toHaveBeenCalled();
+ },
+};
+
+export const AsChild: Story = {
+ args: { asChild: true, children: undefined },
+ render: (args) => (
+
+
+ A link that looks like a button
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "`asChild` renders the styling onto the child element instead of a ``, which is " +
+ "how a link keeps link semantics while looking like a button.",
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ const link = within(canvasElement).getByRole("link");
+ await expect(link).toHaveAttribute("data-slot", "button");
+ await expect(canvasElement.querySelector("button")).toBeNull();
+ },
+};
diff --git a/stories/primitives/card.stories.tsx b/stories/primitives/card.stories.tsx
new file mode 100644
index 0000000..7003e57
--- /dev/null
+++ b/stories/primitives/card.stories.tsx
@@ -0,0 +1,120 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, fn } from "storybook/test";
+import {
+ Badge,
+ Button,
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Primitives/Card",
+ component: Card,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A surface with a header/content/footer rhythm. `CardHeader` is a grid that only opens a " +
+ "second column when a `CardAction` is present, so a card without an action needs no " +
+ "different markup.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+ Open Elements GmbH
+ Customer since March 2024
+
+
+
+ Three open opportunities and one support ticket awaiting a first response.
+
+
+
+ ),
+};
+
+export const WithAction: Story = {
+ render: (args) => (
+
+
+ Open Elements GmbH
+ Customer since March 2024
+
+ Key account
+
+
+
+ The action sits in a second column the header grows on demand.
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ const header = canvasElement.querySelector('[data-slot="card-header"]');
+ await expect(header).not.toBeNull();
+ // Two columns only because a CardAction is present.
+ await expect(getComputedStyle(header!).gridTemplateColumns.split(" ")).toHaveLength(2);
+ },
+};
+
+export const WithFooter: Story = {
+ render: (args) => (
+
+
+ Delete this company?
+ Its contacts stay, but the link to them is lost.
+
+
+ This cannot be undone.
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "Adding `border-b` to the header or `border-t` to the footer also adds the padding that " +
+ "goes with a divider — the recipe keys the spacing off the border class itself.",
+ },
+ },
+ },
+};
+
+export const Grid: Story = {
+ render: (args) => (
+
+ {["Open Elements GmbH", "Nordwind AG", "Baltic Systems"].map((name) => (
+
+
+ {name}
+ Customer
+
+
+ Last contact 12 days ago
+
+
+ ))}
+
+ ),
+};
diff --git a/stories/primitives/input.stories.tsx b/stories/primitives/input.stories.tsx
new file mode 100644
index 0000000..6eae77e
--- /dev/null
+++ b/stories/primitives/input.stories.tsx
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import { Input, Label } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Primitives/Input",
+ component: Input,
+ args: { placeholder: "anna.weber@example.com" },
+ argTypes: {
+ type: { control: "select", options: ["text", "email", "password", "number", "search", "file"] },
+ disabled: { control: "boolean" },
+ "aria-invalid": { control: "boolean", name: "aria-invalid" },
+ },
+ render: (args) => ,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A single-line field. The error styling is driven entirely by `aria-invalid`, so the " +
+ "accessible state and the visual state cannot drift apart.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ play: async ({ canvasElement }) => {
+ const input = within(canvasElement).getByRole("textbox");
+ await userEvent.type(input, "hello@example.com");
+ await expect(input).toHaveValue("hello@example.com");
+ },
+};
+
+export const WithLabel: Story = {
+ render: (args) => (
+
+ Email address
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ // The label is wired to the field, so the field is reachable by its name.
+ await expect(within(canvasElement).getByLabelText("Email address")).toBeVisible();
+ },
+};
+
+export const Invalid: Story = {
+ args: { "aria-invalid": true, defaultValue: "not-an-email" },
+ render: (args) => (
+
+
Email address
+
+
+ Enter a valid email address.
+
+
+ ),
+};
+
+export const Disabled: Story = {
+ args: { disabled: true, defaultValue: "Read only for now" },
+};
+
+export const Types: Story = {
+ render: (args) => (
+
+ {(["text", "email", "password", "number", "search"] as const).map((type) => (
+
+ {type}
+
+
+ ))}
+
+ ),
+ parameters: { controls: { exclude: ["type", "placeholder"] } },
+};
diff --git a/stories/primitives/label.stories.tsx b/stories/primitives/label.stories.tsx
new file mode 100644
index 0000000..65c2069
--- /dev/null
+++ b/stories/primitives/label.stories.tsx
@@ -0,0 +1,65 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { CircleAlert } from "lucide-react";
+import { expect, within } from "storybook/test";
+import { Input, Label } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Primitives/Label",
+ component: Label,
+ args: { children: "Company name" },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A form label. It dims itself when the field it names is disabled — via `peer-disabled:` " +
+ "for a sibling control and `group-data-[disabled=true]:` inside a disabled group — so the " +
+ "label never looks active next to a dead field.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+
+ ),
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).getByLabelText("Company name")).toBeVisible();
+ },
+};
+
+export const WithIcon: Story = {
+ args: { children: undefined },
+ render: (args) => (
+
+
+ Required field
+
+ ),
+};
+
+export const NextToADisabledField: Story = {
+ render: (args) => (
+
+ {/* `peer` on the control is what lets the label react to its disabled state. */}
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ "The dimming rule is `peer-disabled:`, so it only fires when the label follows the " +
+ "control in the DOM. Here the visual order is restored with `order-*`.",
+ },
+ },
+ },
+};
diff --git a/stories/primitives/separator.stories.tsx b/stories/primitives/separator.stories.tsx
new file mode 100644
index 0000000..22bb7b1
--- /dev/null
+++ b/stories/primitives/separator.stories.tsx
@@ -0,0 +1,76 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect } from "storybook/test";
+import { Separator } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Primitives/Separator",
+ component: Separator,
+ argTypes: {
+ orientation: { control: "inline-radio", options: ["horizontal", "vertical"] },
+ decorative: {
+ control: "boolean",
+ description:
+ "Decorative separators are hidden from assistive technology. Turn it off when the rule " +
+ "genuinely separates two regions.",
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: "A one-pixel rule. Its thickness swaps axis with `orientation`.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Horizontal: Story = {
+ args: { orientation: "horizontal" },
+ render: (args) => (
+
+
Contact details
+
+
anna.weber@example.com
+
+ ),
+ play: async ({ canvasElement }) => {
+ const rule = canvasElement.querySelector('[data-slot="separator"]');
+ await expect(rule).not.toBeNull();
+ await expect(getComputedStyle(rule!).height).toBe("1px");
+ },
+};
+
+export const Vertical: Story = {
+ args: { orientation: "vertical" },
+ render: (args) => (
+
+ Overview
+
+ Contacts
+
+ Tasks
+
+ ),
+ play: async ({ canvasElement }) => {
+ const rule = canvasElement.querySelector('[data-slot="separator"]');
+ await expect(getComputedStyle(rule!).width).toBe("1px");
+ },
+};
+
+export const Semantic: Story = {
+ args: { decorative: false },
+ render: (args) => (
+
+
Section one
+
+
Section two
+
+ ),
+ play: async ({ canvasElement }) => {
+ // Not decorative, so it is exposed as a separator rather than hidden.
+ await expect(canvasElement.querySelector('[role="separator"]')).not.toBeNull();
+ },
+};
diff --git a/stories/primitives/skeleton.stories.tsx b/stories/primitives/skeleton.stories.tsx
new file mode 100644
index 0000000..3ca146c
--- /dev/null
+++ b/stories/primitives/skeleton.stories.tsx
@@ -0,0 +1,75 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect } from "storybook/test";
+import { Card, CardContent, CardHeader, Skeleton } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Primitives/Skeleton",
+ component: Skeleton,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A pulsing placeholder. It carries no size of its own — the shape of what is loading is " +
+ "expressed with utility classes at the call site.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => ,
+ play: async ({ canvasElement }) => {
+ const skeleton = canvasElement.querySelector('[data-slot="skeleton"]');
+ await expect(skeleton).not.toBeNull();
+ // `animate-pulse` is a Tailwind core utility, so it must have resolved.
+ await expect(getComputedStyle(skeleton!).animationName).not.toBe("none");
+ },
+};
+
+export const TextBlock: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+};
+
+export const LoadingCard: Story = {
+ render: (args) => (
+
+
+
+
+
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: "The placeholder mirrors the real card's layout, so nothing jumps when data lands.",
+ },
+ },
+ },
+};
+
+export const Avatar: Story = {
+ render: (args) => (
+
+ ),
+};
diff --git a/stories/primitives/textarea.stories.tsx b/stories/primitives/textarea.stories.tsx
new file mode 100644
index 0000000..9bfb439
--- /dev/null
+++ b/stories/primitives/textarea.stories.tsx
@@ -0,0 +1,63 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import { Label, Textarea } from "../../src/index.ts";
+
+const meta: Meta = {
+ title: "Primitives/Textarea",
+ component: Textarea,
+ args: { placeholder: "Add a note…" },
+ argTypes: {
+ disabled: { control: "boolean" },
+ rows: { control: { type: "number", min: 1, max: 20 } },
+ "aria-invalid": { control: "boolean", name: "aria-invalid" },
+ },
+ render: (args) => ,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A multi-line field. It sets `field-sizing-content`, so in browsers that support it the " +
+ "box grows with the text instead of scrolling.",
+ },
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const GrowsWithContent: Story = {
+ play: async ({ canvasElement }) => {
+ const textarea = within(canvasElement).getByRole("textbox");
+ const before = textarea.getBoundingClientRect().height;
+ await userEvent.type(textarea, "one{Enter}two{Enter}three{Enter}four{Enter}five");
+ await expect(textarea.getBoundingClientRect().height).toBeGreaterThan(before);
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: "Typing five lines makes the box taller — no `rows` juggling in the consuming app.",
+ },
+ },
+ },
+};
+
+export const WithLabel: Story = {
+ render: (args) => (
+
+ Internal note
+
+
+ ),
+};
+
+export const Invalid: Story = {
+ args: { "aria-invalid": true, defaultValue: "Too short" },
+};
+
+export const Disabled: Story = {
+ args: { disabled: true, defaultValue: "This note cannot be edited." },
+};
diff --git a/stories/support/fixtures.ts b/stories/support/fixtures.ts
new file mode 100644
index 0000000..57921b1
--- /dev/null
+++ b/stories/support/fixtures.ts
@@ -0,0 +1,118 @@
+import type { TagDto, TagOption, UserOption } from "../../src/index.ts";
+
+/**
+ * Sample data shared across stories. Kept in one place so a component that
+ * shows a user, a tag or a colour looks the same everywhere in the catalogue.
+ */
+
+export const USERS: readonly UserOption[] = [
+ { id: "u-1", name: "Anna Weber", email: "anna.weber@example.com" },
+ { id: "u-2", name: "Ben Okafor", email: "ben.okafor@example.com" },
+ { id: "u-3", name: "Clara Nguyen", email: "clara.nguyen@example.com" },
+ { id: "u-4", name: "Diego Ramírez", email: "diego.ramirez@example.com" },
+ { id: "u-5", name: "Eva Lindqvist", email: "eva.lindqvist@example.com" },
+];
+
+function tag(id: string, name: string, color: string, description: string | null = null): TagDto {
+ return {
+ id,
+ name,
+ description,
+ color,
+ createdAt: "2026-01-15T09:00:00Z",
+ updatedAt: "2026-02-03T14:30:00Z",
+ companyCount: 4,
+ contactCount: 11,
+ taskCount: 2,
+ };
+}
+
+export const TAGS: readonly TagDto[] = [
+ tag("t-1", "Key account", "#5CBA9E", "Long-standing customer"),
+ tag("t-2", "Prospect", "#5DB9F5"),
+ tag("t-3", "Churn risk", "#E63277", "Needs attention this quarter"),
+ // Deliberately light, to exercise the automatic contrast colour.
+ tag("t-4", "Newsletter", "#F1E34B"),
+ // Deliberately malformed, to exercise the invalid-colour fallback.
+ tag("t-5", "Unclassified", "not-a-hex"),
+];
+
+export const TAG_OPTIONS: readonly TagOption[] = TAGS.map((t) => ({
+ value: t.id,
+ label: t.name,
+ color: t.color,
+}));
+
+export const TAG_MULTI_SELECT_TRANSLATIONS = {
+ placeholder: "Tags…",
+ empty: "No tags yet. Create your first tag to organize companies and contacts.",
+} as const;
+
+export const USER_MULTI_SELECT_TRANSLATIONS = {
+ placeholder: "Assign people…",
+ searchPlaceholder: "Search by name or email",
+ empty: "Nobody matches that search.",
+} as const;
+
+export const TAG_FORM_TRANSLATIONS = {
+ title: "New tag",
+ name: "Name",
+ nameRequired: "A name is required.",
+ namePlaceholder: "e.g. Key account",
+ nameConflict: "A tag with this name already exists.",
+ description: "Description",
+ descriptionPlaceholder: "What is this tag for?",
+ color: "Colour",
+ colorRequired: "A colour is required.",
+ colorInvalid: "Use a six-digit hex colour, e.g. #5CBA9E.",
+ colorPlaceholder: "#5CBA9E",
+ save: "Save",
+ cancel: "Cancel",
+} as const;
+
+export const HEALTH_STATUS_TRANSLATIONS = {
+ title: "Backend health",
+ statusUp: "All systems operational",
+ statusDown: "Backend unreachable",
+} as const;
+
+export const DETAIL_FIELD_TRANSLATIONS = {
+ copy: "Copy",
+ copied: "Copied",
+ open: "Open",
+ email: "Email",
+ call: "Call",
+} as const;
+
+export const PAGINATION_TRANSLATIONS = {
+ perPage: "per page",
+ previous: "Previous",
+ next: "Next",
+ totalOne: "{count} entry",
+ totalOther: "{count} entries",
+} as const;
+
+export const TRANSLATE_DIALOG_TRANSLATIONS = {
+ title: "Translation",
+ loading: "Translating…",
+ error: "The translation could not be loaded.",
+ copy: "Copy",
+ copied: "Copied",
+ close: "Close",
+} as const;
+
+export const TRANSLATE_BUTTON_TRANSLATIONS = {
+ button: "Translate",
+ dialog: TRANSLATE_DIALOG_TRANSLATIONS,
+} as const;
+
+export const USER_SECTION_TRANSLATIONS = {
+ uploadAvatar: "Change profile picture",
+ logout: "Sign out",
+ noRoles: "No roles assigned",
+} as const;
+
+/** Resolves after `ms`, so a story can show a pending state worth looking at. */
+export function delay(ms: number): Promise {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
diff --git a/stories/support/overlay.ts b/stories/support/overlay.ts
new file mode 100644
index 0000000..ccb2662
--- /dev/null
+++ b/stories/support/overlay.ts
@@ -0,0 +1,14 @@
+import { expect, waitFor } from "storybook/test";
+
+/**
+ * Assert that an element is actually visible, with a retry.
+ *
+ * The overlays fade and scale in (`animate-in fade-in-0 zoom-in-95`, from
+ * `tw-animate-css`), so there is a window where the node is in the DOM and
+ * reachable by role while its computed opacity is still 0 — which is exactly
+ * what `toBeVisible` rejects. Racing the animation makes a play function flake;
+ * retrying does not, and still fails if the element never becomes visible.
+ */
+export function expectVisible(element: HTMLElement): Promise {
+ return waitFor(() => expect(element).toBeVisible());
+}