@duro-app/ui 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/components/ActionBar/ActionBar.d.ts +3 -1
  2. package/dist/components/ActionBar/ActionBar.d.ts.map +1 -1
  3. package/dist/components/Button/Button.meta.d.ts +3 -0
  4. package/dist/components/Button/Button.meta.d.ts.map +1 -0
  5. package/dist/components/Checkbox/Checkbox.meta.d.ts +3 -0
  6. package/dist/components/Checkbox/Checkbox.meta.d.ts.map +1 -0
  7. package/dist/components/Cluster/Cluster.meta.d.ts +3 -0
  8. package/dist/components/Cluster/Cluster.meta.d.ts.map +1 -0
  9. package/dist/components/Field/Field.d.ts +2 -1
  10. package/dist/components/Field/Field.d.ts.map +1 -1
  11. package/dist/components/Field/Field.meta.d.ts +3 -0
  12. package/dist/components/Field/Field.meta.d.ts.map +1 -0
  13. package/dist/components/Field/FieldContext.d.ts +9 -1
  14. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  15. package/dist/components/Fieldset/Fieldset.meta.d.ts +3 -0
  16. package/dist/components/Fieldset/Fieldset.meta.d.ts.map +1 -0
  17. package/dist/components/Form/Form.d.ts +11 -0
  18. package/dist/components/Form/Form.d.ts.map +1 -0
  19. package/dist/components/Form/Form.meta.d.ts +3 -0
  20. package/dist/components/Form/Form.meta.d.ts.map +1 -0
  21. package/dist/components/Form/Form.stories.d.ts +10 -0
  22. package/dist/components/Form/Form.stories.d.ts.map +1 -0
  23. package/dist/components/Form/FormContext.d.ts +3 -0
  24. package/dist/components/Form/FormContext.d.ts.map +1 -0
  25. package/dist/components/Grid/Grid.meta.d.ts +3 -0
  26. package/dist/components/Grid/Grid.meta.d.ts.map +1 -0
  27. package/dist/components/Inline/Inline.meta.d.ts +3 -0
  28. package/dist/components/Inline/Inline.meta.d.ts.map +1 -0
  29. package/dist/components/Input/Input.d.ts +3 -1
  30. package/dist/components/Input/Input.d.ts.map +1 -1
  31. package/dist/components/Input/Input.meta.d.ts +3 -0
  32. package/dist/components/Input/Input.meta.d.ts.map +1 -0
  33. package/dist/components/LinkButton/LinkButton.meta.d.ts +3 -0
  34. package/dist/components/LinkButton/LinkButton.meta.d.ts.map +1 -0
  35. package/dist/components/Menu/Menu.meta.d.ts +3 -0
  36. package/dist/components/Menu/Menu.meta.d.ts.map +1 -0
  37. package/dist/components/PageShell/PageShell.meta.d.ts +3 -0
  38. package/dist/components/PageShell/PageShell.meta.d.ts.map +1 -0
  39. package/dist/components/ScrollArea/ScrollArea.meta.d.ts +3 -0
  40. package/dist/components/ScrollArea/ScrollArea.meta.d.ts.map +1 -0
  41. package/dist/components/Select/Select.meta.d.ts +3 -0
  42. package/dist/components/Select/Select.meta.d.ts.map +1 -0
  43. package/dist/components/SideNav/SideNav.meta.d.ts +3 -0
  44. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -0
  45. package/dist/components/Stack/Stack.meta.d.ts +3 -0
  46. package/dist/components/Stack/Stack.meta.d.ts.map +1 -0
  47. package/dist/components/Switch/Switch.meta.d.ts +3 -0
  48. package/dist/components/Switch/Switch.meta.d.ts.map +1 -0
  49. package/dist/components/Table/Table.meta.d.ts +3 -0
  50. package/dist/components/Table/Table.meta.d.ts.map +1 -0
  51. package/dist/components/Tabs/Tabs.meta.d.ts +3 -0
  52. package/dist/components/Tabs/Tabs.meta.d.ts.map +1 -0
  53. package/dist/components/Textarea/Textarea.d.ts +3 -1
  54. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  55. package/dist/components/Textarea/Textarea.meta.d.ts +3 -0
  56. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -0
  57. package/dist/components/Toggle/Toggle.meta.d.ts +3 -0
  58. package/dist/components/Toggle/Toggle.meta.d.ts.map +1 -0
  59. package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts +3 -0
  60. package/dist/components/ToggleGroup/ToggleGroup.meta.d.ts.map +1 -0
  61. package/dist/components/Tooltip/Tooltip.meta.d.ts +3 -0
  62. package/dist/components/Tooltip/Tooltip.meta.d.ts.map +1 -0
  63. package/dist/components/component-meta.d.ts +15 -0
  64. package/dist/components/component-meta.d.ts.map +1 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +1500 -1400
  68. package/dist/index.js.map +1 -1
  69. package/package.json +19 -2
  70. package/src/components/ActionBar/ActionBar.tsx +20 -15
  71. package/src/components/Button/Button.meta.ts +23 -0
  72. package/src/components/Checkbox/Checkbox.meta.ts +21 -0
  73. package/src/components/Cluster/Cluster.meta.ts +25 -0
  74. package/src/components/Field/Field.meta.ts +37 -0
  75. package/src/components/Field/Field.tsx +56 -3
  76. package/src/components/Field/FieldContext.ts +10 -0
  77. package/src/components/Fieldset/Fieldset.meta.ts +38 -0
  78. package/src/components/Form/Form.meta.ts +38 -0
  79. package/src/components/Form/Form.stories.tsx +252 -0
  80. package/src/components/Form/Form.tsx +59 -0
  81. package/src/components/Form/FormContext.ts +7 -0
  82. package/src/components/Grid/Grid.meta.ts +30 -0
  83. package/src/components/Inline/Inline.meta.ts +23 -0
  84. package/src/components/Input/Input.meta.ts +23 -0
  85. package/src/components/Input/Input.tsx +18 -4
  86. package/src/components/LinkButton/LinkButton.meta.ts +24 -0
  87. package/src/components/Menu/Menu.meta.ts +33 -0
  88. package/src/components/PageShell/PageShell.meta.ts +24 -0
  89. package/src/components/ScrollArea/ScrollArea.meta.ts +27 -0
  90. package/src/components/Select/Select.meta.ts +34 -0
  91. package/src/components/SideNav/SideNav.meta.ts +31 -0
  92. package/src/components/Stack/Stack.meta.ts +22 -0
  93. package/src/components/Switch/Switch.meta.ts +25 -0
  94. package/src/components/Table/Table.meta.ts +36 -0
  95. package/src/components/Tabs/Tabs.meta.ts +31 -0
  96. package/src/components/Textarea/Textarea.meta.ts +22 -0
  97. package/src/components/Textarea/Textarea.tsx +20 -4
  98. package/src/components/Toggle/Toggle.meta.ts +30 -0
  99. package/src/components/ToggleGroup/ToggleGroup.meta.ts +30 -0
  100. package/src/components/ToggleGroup/ToggleGroup.stories.tsx +2 -2
  101. package/src/components/Tooltip/Tooltip.meta.ts +22 -0
  102. package/src/components/component-meta.ts +8 -0
  103. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,18 +33,35 @@
33
33
  "./reset.css": "./src/global-reset.css"
34
34
  },
35
35
  "peerDependencies": {
36
+ "@hookform/resolvers": "^5.0.0",
37
+ "effect": "^3.0.0",
36
38
  "react": "^19.0.0",
37
39
  "react-dom": "^19.0.0",
40
+ "react-hook-form": "^7.55.0",
38
41
  "react-strict-dom": "^0.0.55"
39
42
  },
43
+ "peerDependenciesMeta": {
44
+ "react-hook-form": {
45
+ "optional": true
46
+ },
47
+ "@hookform/resolvers": {
48
+ "optional": true
49
+ },
50
+ "effect": {
51
+ "optional": true
52
+ }
53
+ },
40
54
  "dependencies": {
41
- "@duro-app/tokens": "^0.15.2"
55
+ "@duro-app/tokens": "^0.17.0"
42
56
  },
43
57
  "devDependencies": {
44
58
  "@babel/preset-typescript": "^7.28.0",
59
+ "@hookform/resolvers": "^5.0.0",
45
60
  "@types/react": "^19.0.0",
46
61
  "@types/react-dom": "^19.0.0",
47
62
  "@vitejs/plugin-react": "^4.0.0",
63
+ "effect": "^3.0.0",
64
+ "react-hook-form": "^7.55.0",
48
65
  "typescript": "^5.7.0",
49
66
  "vite": "^6.0.0",
50
67
  "vite-plugin-babel": "^1.5.0",
@@ -13,6 +13,8 @@ export interface ActionBarProps {
13
13
  isEmphasized?: boolean
14
14
  /** Called when the clear/close button is pressed. */
15
15
  onClearSelection: () => void
16
+ /** Whether the bar can be dismissed. When false, the close button is hidden. @default true */
17
+ dismissible?: boolean
16
18
  /** Action buttons to display. */
17
19
  children: ReactNode
18
20
  }
@@ -22,6 +24,7 @@ function ActionBarInner({
22
24
  selectedLabel,
23
25
  isEmphasized = false,
24
26
  onClearSelection,
27
+ dismissible = true,
25
28
  children,
26
29
  }: ActionBarProps) {
27
30
  const container = useOverlayContainer()
@@ -42,21 +45,23 @@ function ActionBarInner({
42
45
  </html.span>
43
46
  <html.div style={[styles.separator, isEmphasized && styles.separatorEmphasized]} />
44
47
  <html.div style={styles.actions}>{children}</html.div>
45
- <html.button
46
- type="button"
47
- aria-label="Clear selection"
48
- onClick={onClearSelection}
49
- style={[styles.closeButton, isEmphasized && styles.closeButtonEmphasized]}
50
- >
51
- <svg width={10} height={10} viewBox="0 0 10 10" fill="none" aria-hidden="true">
52
- <path
53
- d="M1 1l8 8M9 1l-8 8"
54
- stroke="currentColor"
55
- strokeWidth={1.5}
56
- strokeLinecap="round"
57
- />
58
- </svg>
59
- </html.button>
48
+ {dismissible && (
49
+ <html.button
50
+ type="button"
51
+ aria-label="Clear selection"
52
+ onClick={onClearSelection}
53
+ style={[styles.closeButton, isEmphasized && styles.closeButtonEmphasized]}
54
+ >
55
+ <svg width={10} height={10} viewBox="0 0 10 10" fill="none" aria-hidden="true">
56
+ <path
57
+ d="M1 1l8 8M9 1l-8 8"
58
+ stroke="currentColor"
59
+ strokeWidth={1.5}
60
+ strokeLinecap="round"
61
+ />
62
+ </svg>
63
+ </html.button>
64
+ )}
60
65
  </html.div>
61
66
  )
62
67
 
@@ -0,0 +1,23 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Standard interactive button. For navigation (links styled as buttons), use LinkButton instead.',
6
+ whenToUse: [
7
+ 'Triggering actions: submit, save, delete, open dialog',
8
+ 'Primary and secondary actions in forms and toolbars',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Navigating to a URL — use LinkButton (renders <a>)',
12
+ 'Toggling state — use Toggle',
13
+ ],
14
+ relatedTo: [
15
+ {component: 'LinkButton', relationship: 'LinkButton for navigation; Button for actions'},
16
+ {component: 'Toggle', relationship: 'Toggle for pressed/unpressed state'},
17
+ ],
18
+ example: `<Inline gap="sm">
19
+ <Button variant="primary" type="submit">Save</Button>
20
+ <Button variant="secondary">Cancel</Button>
21
+ <Button variant="danger">Delete</Button>
22
+ </Inline>`,
23
+ }
@@ -0,0 +1,21 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Checkbox input with optional visible label. Supports controlled and uncontrolled modes.',
6
+ whenToUse: [
7
+ 'Boolean opt-in/opt-out (terms acceptance, feature toggle)',
8
+ 'Multi-select from a list (inside a Fieldset)',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Binary toggle in settings — prefer Switch for on/off semantics',
12
+ 'Selecting one of several options — use Select or ToggleGroup',
13
+ ],
14
+ relatedTo: [
15
+ {component: 'Switch', relationship: 'Switch for on/off settings; Checkbox for opt-in/selection'},
16
+ {component: 'Toggle', relationship: 'Toggle for toolbar actions; Checkbox for form input'},
17
+ ],
18
+ example: `<Checkbox name="terms" onChange={(e) => console.log(e.target.checked)}>
19
+ I agree to the terms and conditions
20
+ </Checkbox>`,
21
+ }
@@ -0,0 +1,25 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Horizontal flex layout that WRAPS to the next line when items overflow.',
6
+ whenToUse: [
7
+ 'Tags, badges, or chips that should wrap when they exceed the container width',
8
+ 'Any set of inline elements with unknown/dynamic count',
9
+ 'Filter pills, keyword lists, avatar groups',
10
+ ],
11
+ whenNotToUse: [
12
+ 'Items that must stay on one line — use Inline',
13
+ 'Vertical stacking — use Stack',
14
+ ],
15
+ relatedTo: [
16
+ {component: 'Inline', relationship: 'Same but does NOT wrap (nowrap)'},
17
+ {component: 'Stack', relationship: 'Vertical equivalent'},
18
+ ],
19
+ example: `<Cluster gap="xs">
20
+ <Badge>React</Badge>
21
+ <Badge>TypeScript</Badge>
22
+ <Badge>Design Systems</Badge>
23
+ <Badge>Accessibility</Badge>
24
+ </Cluster>`,
25
+ }
@@ -0,0 +1,37 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Compound form field with label, description, and error display. Inside Form, auto-binds to react-hook-form via name prop. Works standalone with manual invalid prop.',
6
+ whenToUse: [
7
+ 'Any form input that needs a label and/or error message',
8
+ 'Standalone labeled input (without Form) with manual error display',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Bare input without label — use Input directly (rare)',
12
+ ],
13
+ anatomy: {
14
+ required: ['Root'],
15
+ optional: ['Label', 'Description', 'Error'],
16
+ },
17
+ relatedTo: [
18
+ {component: 'Form', relationship: 'Field.Root auto-binds when name matches schema key'},
19
+ {component: 'Input', relationship: 'Place Input inside Field.Root'},
20
+ {component: 'Textarea', relationship: 'Place Textarea inside Field.Root'},
21
+ {component: 'Select', relationship: 'Place Select inside Field.Root'},
22
+ ],
23
+ example: `// Inside Form (auto-binds validation)
24
+ <Field.Root name="email">
25
+ <Field.Label>Email</Field.Label>
26
+ <Input type="email" placeholder="you@example.com" />
27
+ <Field.Description>We'll never share your email.</Field.Description>
28
+ <Field.Error />
29
+ </Field.Root>
30
+
31
+ // Standalone (manual error)
32
+ <Field.Root invalid>
33
+ <Field.Label>Email</Field.Label>
34
+ <Input variant="error" />
35
+ <Field.Error>This email is already taken.</Field.Error>
36
+ </Field.Root>`,
37
+ }
@@ -1,15 +1,67 @@
1
1
  import {type ReactNode, useId, useMemo} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
+ import {useFormContext as useRHFFormContext, useController} from 'react-hook-form'
3
4
  import {FieldContext, useFieldContext} from './FieldContext'
5
+ import {useFormContext} from '../Form/FormContext'
4
6
  import {styles} from './styles.css'
5
7
 
6
8
  // --- Root ---
7
9
  interface RootProps {
10
+ name?: string
8
11
  invalid?: boolean
9
12
  children: ReactNode
10
13
  }
11
14
 
12
- function Root({invalid = false, children}: RootProps) {
15
+ function Root({name, ...props}: RootProps) {
16
+ const insideForm = useFormContext()
17
+ if (name && insideForm) {
18
+ return <ControlledRoot name={name} {...props} />
19
+ }
20
+ return <StaticRoot {...props} />
21
+ }
22
+
23
+ // Always calls useController — no conditional hooks
24
+ function ControlledRoot({
25
+ name,
26
+ invalid: invalidProp = false,
27
+ children,
28
+ }: {
29
+ name: string
30
+ invalid?: boolean
31
+ children: ReactNode
32
+ }) {
33
+ const id = useId()
34
+ const {control} = useRHFFormContext()
35
+ const {field, fieldState} = useController({control, name})
36
+ const invalid = invalidProp || !!fieldState.error
37
+
38
+ const ctx = useMemo(
39
+ () => ({
40
+ controlId: `${id}-control`,
41
+ descriptionId: `${id}-description`,
42
+ errorId: `${id}-error`,
43
+ invalid,
44
+ field: {
45
+ value: field.value,
46
+ onChange: field.onChange,
47
+ onBlur: field.onBlur,
48
+ ref: field.ref,
49
+ name: field.name,
50
+ },
51
+ errorMessage: fieldState.error?.message,
52
+ }),
53
+ [id, invalid, field.value, field.onChange, field.onBlur, field.ref, field.name, fieldState.error],
54
+ )
55
+
56
+ return (
57
+ <FieldContext.Provider value={ctx}>
58
+ <html.div style={styles.root}>{children}</html.div>
59
+ </FieldContext.Provider>
60
+ )
61
+ }
62
+
63
+ // Current behavior, no RHF dependency
64
+ function StaticRoot({invalid = false, children}: {invalid?: boolean; children: ReactNode}) {
13
65
  const id = useId()
14
66
  const ctx = useMemo(
15
67
  () => ({
@@ -63,11 +115,12 @@ interface ErrorProps {
63
115
 
64
116
  function Error({children}: ErrorProps) {
65
117
  const ctx = useFieldContext()
66
- if (!ctx?.invalid && !children) return null
118
+ const content = children ?? ctx?.errorMessage
119
+ if (!ctx?.invalid && !content) return null
67
120
 
68
121
  return (
69
122
  <html.span id={ctx?.errorId} role="alert" style={styles.error}>
70
- {children}
123
+ {content}
71
124
  </html.span>
72
125
  )
73
126
  }
@@ -5,8 +5,18 @@ interface FieldContextValue {
5
5
  descriptionId: string
6
6
  errorId: string
7
7
  invalid: boolean
8
+ field?: {
9
+ value: unknown
10
+ onChange: (...event: unknown[]) => void
11
+ onBlur: () => void
12
+ ref: React.RefCallback<unknown>
13
+ name: string
14
+ }
15
+ errorMessage?: string
8
16
  }
9
17
 
18
+ export type {FieldContextValue}
19
+
10
20
  export const FieldContext = createContext<FieldContextValue | null>(null)
11
21
 
12
22
  export function useFieldContext() {
@@ -0,0 +1,38 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Groups related form controls with consistent gap spacing and an optional legend. Supports disabled cascade.',
6
+ whenToUse: [
7
+ 'Grouping related fields within a form (e.g., "Personal Info", "Address")',
8
+ 'Applying consistent vertical gap between form fields',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Non-form content grouping — use Stack or Card',
12
+ ],
13
+ anatomy: {
14
+ required: ['Root'],
15
+ optional: ['Legend'],
16
+ },
17
+ relatedTo: [
18
+ {component: 'Form', relationship: 'Fieldset.Root goes inside Form'},
19
+ {component: 'Field', relationship: 'Field.Root goes inside Fieldset.Root'},
20
+ {component: 'Stack', relationship: 'Similar vertical spacing, but Fieldset has form semantics'},
21
+ ],
22
+ example: `<Form schema={schema} defaultValues={defaults} onSubmit={onSubmit}>
23
+ <Fieldset.Root gap="md">
24
+ <Fieldset.Legend>Account Details</Fieldset.Legend>
25
+ <Field.Root name="email">
26
+ <Field.Label>Email</Field.Label>
27
+ <Input type="email" />
28
+ <Field.Error />
29
+ </Field.Root>
30
+ <Field.Root name="password">
31
+ <Field.Label>Password</Field.Label>
32
+ <Input type="password" />
33
+ <Field.Error />
34
+ </Field.Root>
35
+ <Button type="submit">Create account</Button>
36
+ </Fieldset.Root>
37
+ </Form>`,
38
+ }
@@ -0,0 +1,38 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components.',
6
+ whenToUse: [
7
+ 'Any form that needs validation',
8
+ 'Multi-field forms with submit handling',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Single standalone field without validation — use Field.Root directly',
12
+ 'Non-form interactive UIs — use individual components',
13
+ ],
14
+ relatedTo: [
15
+ {component: 'Field', relationship: 'Field.Root auto-binds to Form via name prop'},
16
+ {component: 'Fieldset', relationship: 'Groups fields visually within a Form'},
17
+ ],
18
+ example: `import {Schema} from 'effect'
19
+
20
+ const MySchema = Schema.Struct({
21
+ email: Schema.String.pipe(
22
+ Schema.pattern(/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/, {message: () => 'Invalid email'}),
23
+ ),
24
+ })
25
+
26
+ <Form schema={MySchema} defaultValues={{email: ''}} onSubmit={handleSubmit}>
27
+ {({formState}) => (
28
+ <Fieldset.Root gap="md">
29
+ <Field.Root name="email">
30
+ <Field.Label>Email</Field.Label>
31
+ <Input type="email" />
32
+ <Field.Error />
33
+ </Field.Root>
34
+ <Button type="submit" disabled={!formState.isValid}>Submit</Button>
35
+ </Fieldset.Root>
36
+ )}
37
+ </Form>`,
38
+ }
@@ -0,0 +1,252 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, userEvent, within} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Schema} from 'effect'
5
+ import {Form} from './Form'
6
+ import {Field} from '../Field/Field'
7
+ import {Input} from '../Input/Input'
8
+ import {Textarea} from '../Textarea/Textarea'
9
+ import {Fieldset} from '../Fieldset/Fieldset'
10
+ import {Button} from '../Button/Button'
11
+
12
+ const meta: Meta = {
13
+ title: 'Components/Form',
14
+ }
15
+
16
+ export default meta
17
+ type Story = StoryObj
18
+
19
+ // --- Schemas ---
20
+
21
+ const LoginSchema = Schema.Struct({
22
+ username: Schema.String.pipe(
23
+ Schema.minLength(3, {message: () => 'Username must be at least 3 characters'}),
24
+ ),
25
+ password: Schema.String.pipe(
26
+ Schema.minLength(8, {message: () => 'Password must be at least 8 characters'}),
27
+ ),
28
+ })
29
+
30
+ const FeedbackSchema = Schema.Struct({
31
+ email: Schema.String.pipe(
32
+ Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {message: () => 'Enter a valid email address'}),
33
+ ),
34
+ message: Schema.String.pipe(
35
+ Schema.minLength(10, {message: () => 'Message must be at least 10 characters'}),
36
+ ),
37
+ })
38
+
39
+ const wrapStyles = css.create({
40
+ wrap: {maxWidth: 400},
41
+ })
42
+
43
+ // --- Stories ---
44
+
45
+ export const Default: Story = {
46
+ render: () => {
47
+ const handleSubmit = fn()
48
+ return (
49
+ <html.div style={wrapStyles.wrap}>
50
+ <Form
51
+ schema={LoginSchema}
52
+ defaultValues={{username: '', password: ''}}
53
+ onSubmit={handleSubmit}
54
+ >
55
+ {({formState}) => (
56
+ <Fieldset.Root gap="md">
57
+ <Field.Root name="username">
58
+ <Field.Label>Username</Field.Label>
59
+ <Input placeholder="Enter username" />
60
+ <Field.Description>3-32 characters</Field.Description>
61
+ <Field.Error />
62
+ </Field.Root>
63
+
64
+ <Field.Root name="password">
65
+ <Field.Label>Password</Field.Label>
66
+ <Input type="password" placeholder="Enter password" />
67
+ <Field.Error />
68
+ </Field.Root>
69
+
70
+ <Button type="submit" disabled={!formState.isValid}>
71
+ Log in
72
+ </Button>
73
+ </Fieldset.Root>
74
+ )}
75
+ </Form>
76
+ </html.div>
77
+ )
78
+ },
79
+ play: async ({canvas}) => {
80
+ // Submit button starts disabled (empty fields are invalid)
81
+ const button = canvas.getByRole('button', {name: 'Log in'})
82
+ await expect(button).toBeDisabled()
83
+
84
+ // Labels and inputs rendered
85
+ await expect(canvas.getByText('Username')).toBeInTheDocument()
86
+ await expect(canvas.getByText('Password')).toBeInTheDocument()
87
+ await expect(canvas.getByPlaceholderText('Enter username')).toBeInTheDocument()
88
+ await expect(canvas.getByPlaceholderText('Enter password')).toBeInTheDocument()
89
+ },
90
+ }
91
+
92
+ export const ValidationOnBlur: Story = {
93
+ name: 'Validation — on blur',
94
+ render: () => (
95
+ <html.div style={wrapStyles.wrap}>
96
+ <Form
97
+ schema={LoginSchema}
98
+ defaultValues={{username: '', password: ''}}
99
+ onSubmit={fn()}
100
+ >
101
+ <Fieldset.Root gap="md">
102
+ <Field.Root name="username">
103
+ <Field.Label>Username</Field.Label>
104
+ <Input placeholder="Enter username" />
105
+ <Field.Error />
106
+ </Field.Root>
107
+
108
+ <Field.Root name="password">
109
+ <Field.Label>Password</Field.Label>
110
+ <Input type="password" placeholder="Enter password" />
111
+ <Field.Error />
112
+ </Field.Root>
113
+
114
+ <Button type="submit">Log in</Button>
115
+ </Fieldset.Root>
116
+ </Form>
117
+ </html.div>
118
+ ),
119
+ play: async ({canvas, step}) => {
120
+ const usernameInput = canvas.getByPlaceholderText('Enter username')
121
+ const passwordInput = canvas.getByPlaceholderText('Enter password')
122
+
123
+ await step('No errors before interaction', async () => {
124
+ expect(canvas.queryByRole('alert')).not.toBeInTheDocument()
125
+ })
126
+
127
+ await step('Type short username then blur — error appears', async () => {
128
+ await userEvent.type(usernameInput, 'ab')
129
+ await userEvent.tab() // blur
130
+ const alert = await canvas.findByRole('alert')
131
+ await expect(alert).toHaveTextContent('Username must be at least 3 characters')
132
+ })
133
+
134
+ await step('Fix username by typing more — error clears (reValidateMode: onChange)', async () => {
135
+ await userEvent.type(usernameInput, 'c') // now "abc"
136
+ expect(canvas.queryByText('Username must be at least 3 characters')).not.toBeInTheDocument()
137
+ })
138
+
139
+ await step('Blur empty password — error appears', async () => {
140
+ await userEvent.click(passwordInput)
141
+ await userEvent.tab()
142
+ const alerts = canvas.getAllByRole('alert')
143
+ const pwAlert = alerts.find((el) => el.textContent?.includes('Password'))
144
+ await expect(pwAlert).toBeDefined()
145
+ })
146
+ },
147
+ }
148
+
149
+ export const WithTextarea: Story = {
150
+ name: 'With Textarea',
151
+ render: () => (
152
+ <html.div style={wrapStyles.wrap}>
153
+ <Form
154
+ schema={FeedbackSchema}
155
+ defaultValues={{email: '', message: ''}}
156
+ onSubmit={fn()}
157
+ >
158
+ {({formState}) => (
159
+ <Fieldset.Root gap="md">
160
+ <Field.Root name="email">
161
+ <Field.Label>Email</Field.Label>
162
+ <Input type="email" placeholder="you@example.com" />
163
+ <Field.Error />
164
+ </Field.Root>
165
+
166
+ <Field.Root name="message">
167
+ <Field.Label>Message</Field.Label>
168
+ <Textarea placeholder="Tell us what you think..." rows={4} />
169
+ <Field.Description>At least 10 characters</Field.Description>
170
+ <Field.Error />
171
+ </Field.Root>
172
+
173
+ <Button type="submit" disabled={!formState.isValid}>
174
+ Send feedback
175
+ </Button>
176
+ </Fieldset.Root>
177
+ )}
178
+ </Form>
179
+ </html.div>
180
+ ),
181
+ play: async ({canvas}) => {
182
+ await expect(canvas.getByPlaceholderText('you@example.com')).toBeInTheDocument()
183
+ await expect(canvas.getByPlaceholderText('Tell us what you think...')).toBeInTheDocument()
184
+
185
+ const button = canvas.getByRole('button', {name: 'Send feedback'})
186
+ await expect(button).toBeDisabled()
187
+ },
188
+ }
189
+
190
+ export const SuccessfulSubmit: Story = {
191
+ name: 'Successful submit',
192
+ render: () => {
193
+ const onSubmit = fn()
194
+ return (
195
+ <html.div style={wrapStyles.wrap}>
196
+ <Form
197
+ schema={LoginSchema}
198
+ defaultValues={{username: '', password: ''}}
199
+ onSubmit={onSubmit}
200
+ >
201
+ <Fieldset.Root gap="md">
202
+ <Field.Root name="username">
203
+ <Field.Label>Username</Field.Label>
204
+ <Input placeholder="Enter username" />
205
+ <Field.Error />
206
+ </Field.Root>
207
+
208
+ <Field.Root name="password">
209
+ <Field.Label>Password</Field.Label>
210
+ <Input type="password" placeholder="Enter password" />
211
+ <Field.Error />
212
+ </Field.Root>
213
+
214
+ <Button type="submit">Log in</Button>
215
+ </Fieldset.Root>
216
+ </Form>
217
+ </html.div>
218
+ )
219
+ },
220
+ play: async ({canvas}) => {
221
+ const usernameInput = canvas.getByPlaceholderText('Enter username')
222
+ const passwordInput = canvas.getByPlaceholderText('Enter password')
223
+
224
+ // Fill valid values
225
+ await userEvent.type(usernameInput, 'johndoe')
226
+ await userEvent.type(passwordInput, 'supersecret1')
227
+
228
+ // No errors should be present
229
+ expect(canvas.queryByRole('alert')).not.toBeInTheDocument()
230
+ },
231
+ }
232
+
233
+ export const StandaloneFieldStillWorks: Story = {
234
+ name: 'Standalone Field (no Form)',
235
+ render: () => (
236
+ <html.div style={wrapStyles.wrap}>
237
+ <Field.Root invalid>
238
+ <Field.Label>Email</Field.Label>
239
+ <Input variant="error" placeholder="Enter email" />
240
+ <Field.Error>Manual error — no Form wrapper needed.</Field.Error>
241
+ </Field.Root>
242
+ </html.div>
243
+ ),
244
+ play: async ({canvas}) => {
245
+ // Standalone usage still works without Form context
246
+ const input = canvas.getByPlaceholderText('Enter email')
247
+ await expect(input).toHaveAttribute('aria-invalid', 'true')
248
+
249
+ const alert = canvas.getByRole('alert')
250
+ await expect(alert).toHaveTextContent('Manual error — no Form wrapper needed.')
251
+ },
252
+ }