@duro-app/ui 0.23.0 → 0.25.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 (100) hide show
  1. package/dist/components/ButtonGroup/ButtonGroup.d.ts +14 -0
  2. package/dist/components/ButtonGroup/ButtonGroup.d.ts.map +1 -0
  3. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts +3 -0
  4. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -0
  5. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +11 -0
  6. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -0
  7. package/dist/components/ButtonGroup/styles.css.d.ts +45 -0
  8. package/dist/components/ButtonGroup/styles.css.d.ts.map +1 -0
  9. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  10. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
  11. package/dist/components/Combobox/Combobox.d.ts +44 -0
  12. package/dist/components/Combobox/Combobox.d.ts.map +1 -0
  13. package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
  14. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
  15. package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
  16. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
  17. package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
  18. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
  19. package/dist/components/Combobox/styles.css.d.ts +112 -0
  20. package/dist/components/Combobox/styles.css.d.ts.map +1 -0
  21. package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
  22. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
  23. package/dist/components/Field/Field.d.ts +4 -0
  24. package/dist/components/Field/Field.d.ts.map +1 -1
  25. package/dist/components/Field/Field.stories.d.ts +3 -0
  26. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  27. package/dist/components/Field/FieldContext.d.ts +5 -0
  28. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  29. package/dist/components/Field/styles.css.d.ts +26 -0
  30. package/dist/components/Field/styles.css.d.ts.map +1 -1
  31. package/dist/components/Form/Form.d.ts +7 -2
  32. package/dist/components/Form/Form.d.ts.map +1 -1
  33. package/dist/components/Form/Form.stories.d.ts +6 -0
  34. package/dist/components/Form/Form.stories.d.ts.map +1 -1
  35. package/dist/components/Form/FormContext.d.ts +9 -2
  36. package/dist/components/Form/FormContext.d.ts.map +1 -1
  37. package/dist/components/Input/Input.d.ts.map +1 -1
  38. package/dist/components/List/List.d.ts.map +1 -1
  39. package/dist/components/List/List.meta.d.ts.map +1 -1
  40. package/dist/components/List/List.stories.d.ts.map +1 -1
  41. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  42. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
  43. package/dist/components/Table/ColumnFilter.d.ts +8 -0
  44. package/dist/components/Table/ColumnFilter.d.ts.map +1 -0
  45. package/dist/components/Table/Pagination.d.ts +7 -0
  46. package/dist/components/Table/Pagination.d.ts.map +1 -0
  47. package/dist/components/Table/SortIndicator.d.ts +16 -0
  48. package/dist/components/Table/SortIndicator.d.ts.map +1 -0
  49. package/dist/components/Table/Table.d.ts +7 -1
  50. package/dist/components/Table/Table.d.ts.map +1 -1
  51. package/dist/components/Table/Table.stories.d.ts +4 -0
  52. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  53. package/dist/components/Table/styles.css.d.ts +11 -3
  54. package/dist/components/Table/styles.css.d.ts.map +1 -1
  55. package/dist/components/Table/useDataTable.d.ts +32 -0
  56. package/dist/components/Table/useDataTable.d.ts.map +1 -0
  57. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  58. package/dist/index.css +1 -1
  59. package/dist/index.d.ts +3 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +2863 -2026
  62. package/dist/index.js.map +1 -1
  63. package/package.json +3 -2
  64. package/src/components/ActionBar/ActionBar.stories.tsx +3 -3
  65. package/src/components/ButtonGroup/ButtonGroup.meta.ts +32 -0
  66. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +103 -0
  67. package/src/components/ButtonGroup/ButtonGroup.tsx +59 -0
  68. package/src/components/ButtonGroup/styles.css.ts +28 -0
  69. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
  70. package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
  71. package/src/components/Combobox/Combobox.meta.ts +35 -0
  72. package/src/components/Combobox/Combobox.stories.tsx +119 -0
  73. package/src/components/Combobox/Combobox.tsx +247 -0
  74. package/src/components/Combobox/ComboboxContext.ts +26 -0
  75. package/src/components/Combobox/styles.css.ts +119 -0
  76. package/src/components/Combobox/useComboboxRoot.ts +235 -0
  77. package/src/components/Field/Field.stories.tsx +60 -0
  78. package/src/components/Field/Field.tsx +97 -9
  79. package/src/components/Field/FieldContext.ts +5 -0
  80. package/src/components/Field/styles.css.ts +26 -0
  81. package/src/components/Form/Form.stories.tsx +275 -0
  82. package/src/components/Form/Form.tsx +73 -7
  83. package/src/components/Form/FormContext.ts +10 -1
  84. package/src/components/Input/Input.tsx +2 -1
  85. package/src/components/List/List.meta.ts +8 -2
  86. package/src/components/List/List.stories.tsx +35 -15
  87. package/src/components/List/List.tsx +1 -5
  88. package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
  89. package/src/components/RadioGroup/RadioGroup.tsx +1 -4
  90. package/src/components/Table/ColumnFilter.tsx +46 -0
  91. package/src/components/Table/Pagination.tsx +82 -0
  92. package/src/components/Table/SortIndicator.tsx +53 -0
  93. package/src/components/Table/Table.stories.tsx +397 -0
  94. package/src/components/Table/Table.tsx +58 -24
  95. package/src/components/Table/styles.css.ts +16 -6
  96. package/src/components/Table/useDataTable.ts +99 -0
  97. package/src/components/Textarea/Textarea.tsx +2 -1
  98. package/src/docs/helpers.tsx +2 -2
  99. package/src/index.ts +5 -0
  100. package/src/strict.css +5 -0
@@ -1,21 +1,25 @@
1
- import {type ReactNode, useId, useMemo} from 'react'
1
+ import {type ReactNode, Children, isValidElement, useId, useMemo} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {useFormContext as useRHFFormContext, useController} from 'react-hook-form'
4
4
  import {FieldContext, useFieldContext} from './FieldContext'
5
5
  import {useFormContext} from '../Form/FormContext'
6
+ import type {LabelPosition, NecessityIndicator} from '../Form/FormContext'
6
7
  import {styles} from './styles.css'
7
8
 
8
9
  // --- Root ---
9
10
  interface RootProps {
10
11
  name?: string
11
12
  invalid?: boolean
13
+ required?: boolean
14
+ disabled?: boolean
15
+ labelPosition?: LabelPosition
12
16
  children: ReactNode
13
17
  }
14
18
 
15
19
  function Root({name, ...props}: RootProps) {
16
- const insideForm = useFormContext()
17
- if (name && insideForm) {
18
- return <ControlledRoot name={name} {...props} />
20
+ const formCtx = useFormContext()
21
+ if (name && formCtx) {
22
+ return <ControlledRoot name={name} formCtx={formCtx} {...props} />
19
23
  }
20
24
  return <StaticRoot {...props} />
21
25
  }
@@ -23,11 +27,19 @@ function Root({name, ...props}: RootProps) {
23
27
  // Always calls useController — no conditional hooks
24
28
  function ControlledRoot({
25
29
  name,
30
+ formCtx,
26
31
  invalid: invalidProp = false,
32
+ required,
33
+ disabled,
34
+ labelPosition,
27
35
  children,
28
36
  }: {
29
37
  name: string
38
+ formCtx: NonNullable<ReturnType<typeof useFormContext>>
30
39
  invalid?: boolean
40
+ required?: boolean
41
+ disabled?: boolean
42
+ labelPosition?: LabelPosition
31
43
  children: ReactNode
32
44
  }) {
33
45
  const id = useId()
@@ -35,12 +47,20 @@ function ControlledRoot({
35
47
  const {field, fieldState} = useController({control, name})
36
48
  const invalid = invalidProp || !!fieldState.error
37
49
 
50
+ const effectiveDisabled = disabled ?? formCtx.disabled
51
+ const effectiveLabelPosition = labelPosition ?? formCtx.labelPosition
52
+ const effectiveNecessityIndicator = formCtx.necessityIndicator
53
+
38
54
  const ctx = useMemo(
39
55
  () => ({
40
56
  controlId: `${id}-control`,
41
57
  descriptionId: `${id}-description`,
42
58
  errorId: `${id}-error`,
43
59
  invalid,
60
+ required,
61
+ disabled: effectiveDisabled,
62
+ labelPosition: effectiveLabelPosition,
63
+ necessityIndicator: effectiveNecessityIndicator,
44
64
  field: {
45
65
  value: field.value,
46
66
  onChange: field.onChange,
@@ -53,6 +73,10 @@ function ControlledRoot({
53
73
  [
54
74
  id,
55
75
  invalid,
76
+ required,
77
+ effectiveDisabled,
78
+ effectiveLabelPosition,
79
+ effectiveNecessityIndicator,
56
80
  field.value,
57
81
  field.onChange,
58
82
  field.onBlur,
@@ -64,13 +88,25 @@ function ControlledRoot({
64
88
 
65
89
  return (
66
90
  <FieldContext.Provider value={ctx}>
67
- <html.div style={styles.root}>{children}</html.div>
91
+ <FieldLayout labelPosition={effectiveLabelPosition}>{children}</FieldLayout>
68
92
  </FieldContext.Provider>
69
93
  )
70
94
  }
71
95
 
72
96
  // Current behavior, no RHF dependency
73
- function StaticRoot({invalid = false, children}: {invalid?: boolean; children: ReactNode}) {
97
+ function StaticRoot({
98
+ invalid = false,
99
+ required,
100
+ disabled,
101
+ labelPosition = 'top',
102
+ children,
103
+ }: {
104
+ invalid?: boolean
105
+ required?: boolean
106
+ disabled?: boolean
107
+ labelPosition?: LabelPosition
108
+ children: ReactNode
109
+ }) {
74
110
  const id = useId()
75
111
  const ctx = useMemo(
76
112
  () => ({
@@ -78,17 +114,53 @@ function StaticRoot({invalid = false, children}: {invalid?: boolean; children: R
78
114
  descriptionId: `${id}-description`,
79
115
  errorId: `${id}-error`,
80
116
  invalid,
117
+ required,
118
+ disabled,
119
+ labelPosition,
81
120
  }),
82
- [id, invalid],
121
+ [id, invalid, required, disabled, labelPosition],
83
122
  )
84
123
 
85
124
  return (
86
125
  <FieldContext.Provider value={ctx}>
87
- <html.div style={styles.root}>{children}</html.div>
126
+ <FieldLayout labelPosition={labelPosition}>{children}</FieldLayout>
88
127
  </FieldContext.Provider>
89
128
  )
90
129
  }
91
130
 
131
+ // --- FieldLayout ---
132
+ // When labelPosition is 'side', separates Label from other children
133
+ // and wraps the non-label children in a content column.
134
+ function FieldLayout({
135
+ labelPosition,
136
+ children,
137
+ }: {
138
+ labelPosition: LabelPosition
139
+ children: ReactNode
140
+ }) {
141
+ if (labelPosition !== 'side') {
142
+ return <html.div style={styles.root}>{children}</html.div>
143
+ }
144
+
145
+ let labelNode: ReactNode = null
146
+ const rest: ReactNode[] = []
147
+
148
+ Children.forEach(children, (child) => {
149
+ if (isValidElement(child) && child.type === Label && !labelNode) {
150
+ labelNode = child
151
+ } else {
152
+ rest.push(child)
153
+ }
154
+ })
155
+
156
+ return (
157
+ <html.div style={styles.rootSide}>
158
+ {labelNode}
159
+ <html.div style={styles.fieldContent}>{rest}</html.div>
160
+ </html.div>
161
+ )
162
+ }
163
+
92
164
  // --- Label ---
93
165
  interface LabelProps {
94
166
  children: ReactNode
@@ -96,9 +168,25 @@ interface LabelProps {
96
168
 
97
169
  function Label({children}: LabelProps) {
98
170
  const ctx = useFieldContext()
171
+ const isSide = ctx?.labelPosition === 'side'
172
+ const indicator = ctx?.necessityIndicator
173
+
99
174
  return (
100
- <html.label for={ctx?.controlId} style={styles.label}>
175
+ <html.label
176
+ for={ctx?.controlId}
177
+ style={[styles.label, isSide && styles.labelSide]}
178
+ >
101
179
  {children}
180
+ {indicator === 'icon' && ctx?.required && (
181
+ <html.span style={styles.necessityIcon} aria-hidden={true}>
182
+ {' *'}
183
+ </html.span>
184
+ )}
185
+ {indicator === 'label' && (
186
+ <html.span style={styles.necessityLabel}>
187
+ {ctx?.required ? ' (required)' : ' (optional)'}
188
+ </html.span>
189
+ )}
102
190
  </html.label>
103
191
  )
104
192
  }
@@ -1,10 +1,15 @@
1
1
  import {createContext, useContext} from 'react'
2
+ import type {LabelPosition, NecessityIndicator} from '../Form/FormContext'
2
3
 
3
4
  interface FieldContextValue {
4
5
  controlId: string
5
6
  descriptionId: string
6
7
  errorId: string
7
8
  invalid: boolean
9
+ required?: boolean
10
+ disabled?: boolean
11
+ labelPosition?: LabelPosition
12
+ necessityIndicator?: NecessityIndicator
8
13
  field?: {
9
14
  value: unknown
10
15
  onChange: (...event: unknown[]) => void
@@ -9,11 +9,22 @@ export const styles = css.create({
9
9
  flexDirection: 'column',
10
10
  gap: spacing.xs,
11
11
  },
12
+ rootSide: {
13
+ display: 'flex',
14
+ flexDirection: 'row',
15
+ alignItems: 'flex-start',
16
+ gap: spacing.md,
17
+ },
12
18
  label: {
13
19
  fontSize: typography.fontSizeSm,
14
20
  fontWeight: typography.fontWeightMedium,
15
21
  color: colors.text,
16
22
  },
23
+ labelSide: {
24
+ minWidth: 120,
25
+ paddingTop: spacing.sm,
26
+ flexShrink: 0,
27
+ },
17
28
  description: {
18
29
  fontSize: typography.fontSizeXs,
19
30
  color: colors.textMuted,
@@ -22,4 +33,19 @@ export const styles = css.create({
22
33
  fontSize: typography.fontSizeXs,
23
34
  color: colors.error,
24
35
  },
36
+ fieldContent: {
37
+ display: 'flex',
38
+ flexDirection: 'column',
39
+ gap: spacing.xs,
40
+ flex: 1,
41
+ minWidth: 0,
42
+ },
43
+ necessityIcon: {
44
+ color: colors.error,
45
+ },
46
+ necessityLabel: {
47
+ color: colors.textMuted,
48
+ fontWeight: typography.fontWeightNormal,
49
+ fontSize: typography.fontSizeXs,
50
+ },
25
51
  })
@@ -1,3 +1,4 @@
1
+ import {useState} from 'react'
1
2
  import type {Meta, StoryObj} from '@storybook/react'
2
3
  import {expect, fn, userEvent, within} from 'storybook/test'
3
4
  import {css, html} from 'react-strict-dom'
@@ -36,8 +37,19 @@ const FeedbackSchema = Schema.Struct({
36
37
  ),
37
38
  })
38
39
 
40
+ const ProfileSchema = Schema.Struct({
41
+ name: Schema.String.pipe(
42
+ Schema.minLength(2, {message: () => 'Name must be at least 2 characters'}),
43
+ ),
44
+ email: Schema.String.pipe(
45
+ Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, {message: () => 'Enter a valid email'}),
46
+ ),
47
+ bio: Schema.String,
48
+ })
49
+
39
50
  const wrapStyles = css.create({
40
51
  wrap: {maxWidth: 400},
52
+ wrapWide: {maxWidth: 600},
41
53
  })
42
54
 
43
55
  // --- Stories ---
@@ -241,3 +253,266 @@ export const StandaloneFieldStillWorks: Story = {
241
253
  await expect(alert).toHaveTextContent('Manual error — no Form wrapper needed.')
242
254
  },
243
255
  }
256
+
257
+ // --- New feature stories ---
258
+
259
+ export const NecessityIndicatorIcon: Story = {
260
+ name: 'Necessity indicator — icon',
261
+ render: () => (
262
+ <html.div style={wrapStyles.wrap}>
263
+ <Form
264
+ schema={ProfileSchema}
265
+ defaultValues={{name: '', email: '', bio: ''}}
266
+ onSubmit={fn()}
267
+ necessityIndicator="icon"
268
+ >
269
+ <Fieldset.Root gap="md">
270
+ <Field.Root name="name" required>
271
+ <Field.Label>Full name</Field.Label>
272
+ <Input placeholder="Jane Doe" />
273
+ <Field.Error />
274
+ </Field.Root>
275
+
276
+ <Field.Root name="email" required>
277
+ <Field.Label>Email</Field.Label>
278
+ <Input type="email" placeholder="you@example.com" />
279
+ <Field.Error />
280
+ </Field.Root>
281
+
282
+ <Field.Root name="bio">
283
+ <Field.Label>Bio</Field.Label>
284
+ <Textarea placeholder="Tell us about yourself..." rows={3} />
285
+ </Field.Root>
286
+
287
+ <Button type="submit">Save</Button>
288
+ </Fieldset.Root>
289
+ </Form>
290
+ </html.div>
291
+ ),
292
+ play: async ({canvas}) => {
293
+ // Required fields show asterisk
294
+ const nameLabel = canvas.getByText('Full name')
295
+ await expect(nameLabel.closest('label')?.textContent).toContain('*')
296
+
297
+ const emailLabel = canvas.getByText('Email')
298
+ await expect(emailLabel.closest('label')?.textContent).toContain('*')
299
+
300
+ // Optional field has no asterisk
301
+ const bioLabel = canvas.getByText('Bio')
302
+ await expect(bioLabel.closest('label')?.textContent).not.toContain('*')
303
+ },
304
+ }
305
+
306
+ export const NecessityIndicatorLabel: Story = {
307
+ name: 'Necessity indicator — label',
308
+ render: () => (
309
+ <html.div style={wrapStyles.wrap}>
310
+ <Form
311
+ schema={ProfileSchema}
312
+ defaultValues={{name: '', email: '', bio: ''}}
313
+ onSubmit={fn()}
314
+ necessityIndicator="label"
315
+ >
316
+ <Fieldset.Root gap="md">
317
+ <Field.Root name="name" required>
318
+ <Field.Label>Full name</Field.Label>
319
+ <Input placeholder="Jane Doe" />
320
+ <Field.Error />
321
+ </Field.Root>
322
+
323
+ <Field.Root name="email" required>
324
+ <Field.Label>Email</Field.Label>
325
+ <Input type="email" placeholder="you@example.com" />
326
+ <Field.Error />
327
+ </Field.Root>
328
+
329
+ <Field.Root name="bio">
330
+ <Field.Label>Bio</Field.Label>
331
+ <Textarea placeholder="Tell us about yourself..." rows={3} />
332
+ </Field.Root>
333
+
334
+ <Button type="submit">Save</Button>
335
+ </Fieldset.Root>
336
+ </Form>
337
+ </html.div>
338
+ ),
339
+ play: async ({canvas}) => {
340
+ // Required fields show "(required)" — there are two
341
+ const requiredIndicators = canvas.getAllByText('(required)')
342
+ await expect(requiredIndicators).toHaveLength(2)
343
+
344
+ // Optional field shows "(optional)"
345
+ await expect(canvas.getByText('(optional)')).toBeInTheDocument()
346
+ },
347
+ }
348
+
349
+ export const FocusFirstInvalid: Story = {
350
+ name: 'Focus first invalid on submit',
351
+ render: () => (
352
+ <html.div style={wrapStyles.wrap}>
353
+ <Form schema={LoginSchema} defaultValues={{username: '', password: ''}} onSubmit={fn()}>
354
+ <Fieldset.Root gap="md">
355
+ <Field.Root name="username">
356
+ <Field.Label>Username</Field.Label>
357
+ <Input placeholder="Enter username" />
358
+ <Field.Error />
359
+ </Field.Root>
360
+
361
+ <Field.Root name="password">
362
+ <Field.Label>Password</Field.Label>
363
+ <Input type="password" placeholder="Enter password" />
364
+ <Field.Error />
365
+ </Field.Root>
366
+
367
+ <Button type="submit">Log in</Button>
368
+ </Fieldset.Root>
369
+ </Form>
370
+ </html.div>
371
+ ),
372
+ play: async ({canvas}) => {
373
+ // Submit the form with empty fields
374
+ const button = canvas.getByRole('button', {name: 'Log in'})
375
+ await userEvent.click(button)
376
+
377
+ // First invalid field (username) should be focused
378
+ const usernameInput = canvas.getByPlaceholderText('Enter username')
379
+ await expect(usernameInput).toHaveFocus()
380
+ },
381
+ }
382
+
383
+ export const FormDisabled: Story = {
384
+ name: 'Form disabled',
385
+ render: () => (
386
+ <html.div style={wrapStyles.wrap}>
387
+ <Form
388
+ schema={LoginSchema}
389
+ defaultValues={{username: 'johndoe', password: 'supersecret1'}}
390
+ onSubmit={fn()}
391
+ disabled
392
+ >
393
+ <Fieldset.Root gap="md">
394
+ <Field.Root name="username">
395
+ <Field.Label>Username</Field.Label>
396
+ <Input placeholder="Enter username" />
397
+ </Field.Root>
398
+
399
+ <Field.Root name="password">
400
+ <Field.Label>Password</Field.Label>
401
+ <Input type="password" placeholder="Enter password" />
402
+ </Field.Root>
403
+
404
+ <Button type="submit">Log in</Button>
405
+ </Fieldset.Root>
406
+ </Form>
407
+ </html.div>
408
+ ),
409
+ play: async ({canvas}) => {
410
+ const usernameInput = canvas.getByPlaceholderText('Enter username')
411
+ const passwordInput = canvas.getByPlaceholderText('Enter password')
412
+ await expect(usernameInput).toBeDisabled()
413
+ await expect(passwordInput).toBeDisabled()
414
+ },
415
+ }
416
+
417
+ export const LabelPositionSide: Story = {
418
+ name: 'Label position — side',
419
+ render: () => (
420
+ <html.div style={wrapStyles.wrapWide}>
421
+ <Form
422
+ schema={ProfileSchema}
423
+ defaultValues={{name: '', email: '', bio: ''}}
424
+ onSubmit={fn()}
425
+ labelPosition="side"
426
+ necessityIndicator="icon"
427
+ >
428
+ {({formState}) => (
429
+ <Fieldset.Root gap="md">
430
+ <Field.Root name="name" required>
431
+ <Field.Label>Full name</Field.Label>
432
+ <Input placeholder="Jane Doe" />
433
+ <Field.Error />
434
+ </Field.Root>
435
+
436
+ <Field.Root name="email" required>
437
+ <Field.Label>Email</Field.Label>
438
+ <Input type="email" placeholder="you@example.com" />
439
+ <Field.Description>We will never share your email.</Field.Description>
440
+ <Field.Error />
441
+ </Field.Root>
442
+
443
+ <Field.Root name="bio">
444
+ <Field.Label>Bio</Field.Label>
445
+ <Textarea placeholder="Tell us about yourself..." rows={3} />
446
+ </Field.Root>
447
+
448
+ <Button type="submit" disabled={!formState.isValid}>
449
+ Save profile
450
+ </Button>
451
+ </Fieldset.Root>
452
+ )}
453
+ </Form>
454
+ </html.div>
455
+ ),
456
+ play: async ({canvas}) => {
457
+ await expect(canvas.getByPlaceholderText('Jane Doe')).toBeInTheDocument()
458
+ await expect(canvas.getByPlaceholderText('you@example.com')).toBeInTheDocument()
459
+ },
460
+ }
461
+
462
+ export const ServerValidationErrors: Story = {
463
+ name: 'Server validation errors',
464
+ render: () => {
465
+ function ServerErrorDemo() {
466
+ const [serverErrors, setServerErrors] = useState<
467
+ Partial<Record<'username' | 'password', string>>
468
+ >({})
469
+
470
+ return (
471
+ <html.div style={wrapStyles.wrap}>
472
+ <Form
473
+ schema={LoginSchema}
474
+ defaultValues={{username: '', password: ''}}
475
+ onSubmit={() => {
476
+ // Simulate server response with errors
477
+ setServerErrors({username: 'This username is already taken'})
478
+ }}
479
+ validationErrors={serverErrors}
480
+ >
481
+ <Fieldset.Root gap="md">
482
+ <Field.Root name="username">
483
+ <Field.Label>Username</Field.Label>
484
+ <Input placeholder="Enter username" />
485
+ <Field.Error />
486
+ </Field.Root>
487
+
488
+ <Field.Root name="password">
489
+ <Field.Label>Password</Field.Label>
490
+ <Input type="password" placeholder="Enter password" />
491
+ <Field.Error />
492
+ </Field.Root>
493
+
494
+ <Button type="submit">Register</Button>
495
+ </Fieldset.Root>
496
+ </Form>
497
+ </html.div>
498
+ )
499
+ }
500
+
501
+ return <ServerErrorDemo />
502
+ },
503
+ play: async ({canvas}) => {
504
+ const usernameInput = canvas.getByPlaceholderText('Enter username')
505
+ const passwordInput = canvas.getByPlaceholderText('Enter password')
506
+
507
+ // Fill valid values and submit
508
+ await userEvent.type(usernameInput, 'johndoe')
509
+ await userEvent.type(passwordInput, 'supersecret1')
510
+
511
+ const button = canvas.getByRole('button', {name: 'Register'})
512
+ await userEvent.click(button)
513
+
514
+ // Server error appears
515
+ const alert = await canvas.findByRole('alert')
516
+ await expect(alert).toHaveTextContent('This username is already taken')
517
+ },
518
+ }
@@ -1,27 +1,48 @@
1
- import {type ReactNode, useCallback, useEffect, useRef} from 'react'
2
- import {html} from 'react-strict-dom'
1
+ import {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'
2
+ import {css, html} from 'react-strict-dom'
3
3
  import {
4
4
  useForm,
5
5
  FormProvider,
6
6
  type UseFormReturn,
7
7
  type DefaultValues,
8
8
  type FieldValues,
9
+ type FieldErrors,
10
+ type Path,
9
11
  } from 'react-hook-form'
10
12
  import {effectTsResolver} from '@hookform/resolvers/effect-ts'
11
13
  import type {Schema} from 'effect'
12
- import {FormContext} from './FormContext'
14
+ import {
15
+ FormContext,
16
+ type LabelPosition,
17
+ type NecessityIndicator,
18
+ } from './FormContext'
13
19
 
14
20
  export interface FormProps<T extends FieldValues> {
15
21
  schema: Schema.Schema<T>
16
22
  defaultValues: DefaultValues<T>
17
23
  onSubmit: (data: T) => void | Promise<void>
24
+ disabled?: boolean
25
+ labelPosition?: LabelPosition
26
+ necessityIndicator?: NecessityIndicator
27
+ validationErrors?: Partial<Record<Path<T>, string>>
18
28
  children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode)
19
29
  }
20
30
 
31
+ const formStyles = css.create({
32
+ disabled: {
33
+ opacity: 0.5,
34
+ pointerEvents: 'none',
35
+ },
36
+ })
37
+
21
38
  export function Form<T extends FieldValues>({
22
39
  schema,
23
40
  defaultValues,
24
41
  onSubmit,
42
+ disabled = false,
43
+ labelPosition = 'top',
44
+ necessityIndicator = false,
45
+ validationErrors,
25
46
  children,
26
47
  }: FormProps<T>) {
27
48
  const methods = useForm<T>({
@@ -31,13 +52,49 @@ export function Form<T extends FieldValues>({
31
52
  reValidateMode: 'onChange',
32
53
  })
33
54
 
55
+ // Sync server-side validation errors into RHF
56
+ const prevErrorKeysRef = useRef<string[]>([])
57
+ useEffect(() => {
58
+ const prevKeys = prevErrorKeysRef.current
59
+ const nextKeys = validationErrors ? Object.keys(validationErrors) : []
60
+
61
+ // Clear errors that were removed
62
+ for (const key of prevKeys) {
63
+ if (!nextKeys.includes(key)) {
64
+ methods.clearErrors(key as Path<T>)
65
+ }
66
+ }
67
+
68
+ // Set new errors
69
+ if (validationErrors) {
70
+ for (const [key, message] of Object.entries(validationErrors)) {
71
+ if (message) {
72
+ methods.setError(key as Path<T>, {type: 'server', message: message as string})
73
+ }
74
+ }
75
+ }
76
+
77
+ prevErrorKeysRef.current = nextKeys
78
+ }, [validationErrors, methods])
79
+
34
80
  const formRef = useRef<HTMLFormElement>(null)
81
+
82
+ const onInvalid = useCallback(
83
+ (errors: FieldErrors<T>) => {
84
+ const firstField = Object.keys(errors)[0]
85
+ if (firstField) {
86
+ methods.setFocus(firstField as Path<T>)
87
+ }
88
+ },
89
+ [methods],
90
+ )
91
+
35
92
  const handleSubmit = useCallback(
36
93
  (e: Event) => {
37
94
  e.preventDefault()
38
- void methods.handleSubmit(onSubmit)()
95
+ void methods.handleSubmit(onSubmit, onInvalid)()
39
96
  },
40
- [methods, onSubmit],
97
+ [methods, onSubmit, onInvalid],
41
98
  )
42
99
 
43
100
  useEffect(() => {
@@ -47,10 +104,19 @@ export function Form<T extends FieldValues>({
47
104
  return () => el.removeEventListener('submit', handleSubmit)
48
105
  }, [handleSubmit])
49
106
 
107
+ const formCtx = useMemo(
108
+ () => ({disabled, labelPosition, necessityIndicator}),
109
+ [disabled, labelPosition, necessityIndicator],
110
+ )
111
+
50
112
  return (
51
113
  <FormProvider {...methods}>
52
- <FormContext.Provider value={true}>
53
- <html.form ref={formRef}>
114
+ <FormContext.Provider value={formCtx}>
115
+ <html.form
116
+ ref={formRef}
117
+ aria-disabled={disabled || undefined}
118
+ style={disabled && formStyles.disabled}
119
+ >
54
120
  {typeof children === 'function' ? children(methods) : children}
55
121
  </html.form>
56
122
  </FormContext.Provider>
@@ -1,6 +1,15 @@
1
1
  import {createContext, useContext} from 'react'
2
2
 
3
- export const FormContext = createContext(false)
3
+ export type LabelPosition = 'top' | 'side'
4
+ export type NecessityIndicator = 'icon' | 'label' | false
5
+
6
+ export interface FormContextValue {
7
+ disabled: boolean
8
+ labelPosition: LabelPosition
9
+ necessityIndicator: NecessityIndicator
10
+ }
11
+
12
+ export const FormContext = createContext<FormContextValue | null>(null)
4
13
 
5
14
  export function useFormContext() {
6
15
  return useContext(FormContext)
@@ -59,6 +59,7 @@ export function Input({
59
59
  const effectiveOnChange = onChange ?? fieldCtx?.onChange
60
60
  const effectiveOnBlur = onBlur ?? fieldCtx?.onBlur
61
61
  const effectiveRef = ref ?? fieldCtx?.ref
62
+ const effectiveDisabled = disabled ?? ctx?.disabled
62
63
  const effectiveVariant = ctx?.invalid ? 'error' : variant
63
64
 
64
65
  // react-strict-dom omits web-only `pattern` from its types, but the
@@ -76,7 +77,7 @@ export function Input({
76
77
  autoComplete={autoComplete}
77
78
  value={effectiveValue}
78
79
  defaultValue={defaultValue}
79
- disabled={disabled}
80
+ disabled={effectiveDisabled}
80
81
  aria-describedby={
81
82
  ctx ? `${ctx.descriptionId} ${ctx.invalid ? ctx.errorId : ''}`.trim() : undefined
82
83
  }