@duro-app/ui 0.24.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 (69) hide show
  1. package/dist/components/ButtonGroup/ButtonGroup.meta.d.ts.map +1 -1
  2. package/dist/components/CheckboxGroup/CheckboxGroup.d.ts.map +1 -1
  3. package/dist/components/CheckboxGroup/CheckboxGroup.meta.d.ts.map +1 -1
  4. package/dist/components/Combobox/Combobox.d.ts +44 -0
  5. package/dist/components/Combobox/Combobox.d.ts.map +1 -0
  6. package/dist/components/Combobox/Combobox.meta.d.ts +3 -0
  7. package/dist/components/Combobox/Combobox.meta.d.ts.map +1 -0
  8. package/dist/components/Combobox/Combobox.stories.d.ts +9 -0
  9. package/dist/components/Combobox/Combobox.stories.d.ts.map +1 -0
  10. package/dist/components/Combobox/ComboboxContext.d.ts +19 -0
  11. package/dist/components/Combobox/ComboboxContext.d.ts.map +1 -0
  12. package/dist/components/Combobox/styles.css.d.ts +112 -0
  13. package/dist/components/Combobox/styles.css.d.ts.map +1 -0
  14. package/dist/components/Combobox/useComboboxRoot.d.ts +15 -0
  15. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -0
  16. package/dist/components/Field/Field.d.ts +4 -0
  17. package/dist/components/Field/Field.d.ts.map +1 -1
  18. package/dist/components/Field/Field.stories.d.ts +3 -0
  19. package/dist/components/Field/Field.stories.d.ts.map +1 -1
  20. package/dist/components/Field/FieldContext.d.ts +5 -0
  21. package/dist/components/Field/FieldContext.d.ts.map +1 -1
  22. package/dist/components/Field/styles.css.d.ts +26 -0
  23. package/dist/components/Field/styles.css.d.ts.map +1 -1
  24. package/dist/components/Form/Form.d.ts +7 -2
  25. package/dist/components/Form/Form.d.ts.map +1 -1
  26. package/dist/components/Form/Form.stories.d.ts +6 -0
  27. package/dist/components/Form/Form.stories.d.ts.map +1 -1
  28. package/dist/components/Form/FormContext.d.ts +9 -2
  29. package/dist/components/Form/FormContext.d.ts.map +1 -1
  30. package/dist/components/Input/Input.d.ts.map +1 -1
  31. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -1
  32. package/dist/components/RadioGroup/RadioGroup.meta.d.ts.map +1 -1
  33. package/dist/components/Table/SortIndicator.d.ts.map +1 -1
  34. package/dist/components/Table/Table.d.ts.map +1 -1
  35. package/dist/components/Table/Table.stories.d.ts.map +1 -1
  36. package/dist/components/Table/useDataTable.d.ts.map +1 -1
  37. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  38. package/dist/index.css +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +2964 -2381
  42. package/dist/index.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/components/ButtonGroup/ButtonGroup.meta.ts +10 -2
  45. package/src/components/CheckboxGroup/CheckboxGroup.meta.ts +4 -1
  46. package/src/components/CheckboxGroup/CheckboxGroup.tsx +2 -9
  47. package/src/components/Combobox/Combobox.meta.ts +35 -0
  48. package/src/components/Combobox/Combobox.stories.tsx +119 -0
  49. package/src/components/Combobox/Combobox.tsx +247 -0
  50. package/src/components/Combobox/ComboboxContext.ts +26 -0
  51. package/src/components/Combobox/styles.css.ts +119 -0
  52. package/src/components/Combobox/useComboboxRoot.ts +235 -0
  53. package/src/components/Field/Field.stories.tsx +60 -0
  54. package/src/components/Field/Field.tsx +97 -9
  55. package/src/components/Field/FieldContext.ts +5 -0
  56. package/src/components/Field/styles.css.ts +26 -0
  57. package/src/components/Form/Form.stories.tsx +275 -0
  58. package/src/components/Form/Form.tsx +73 -7
  59. package/src/components/Form/FormContext.ts +10 -1
  60. package/src/components/Input/Input.tsx +2 -1
  61. package/src/components/RadioGroup/RadioGroup.meta.ts +4 -1
  62. package/src/components/RadioGroup/RadioGroup.tsx +1 -4
  63. package/src/components/Table/SortIndicator.tsx +1 -4
  64. package/src/components/Table/Table.stories.tsx +135 -84
  65. package/src/components/Table/Table.tsx +10 -5
  66. package/src/components/Table/useDataTable.ts +34 -48
  67. package/src/components/Textarea/Textarea.tsx +2 -1
  68. package/src/index.ts +2 -0
  69. package/src/strict.css +1 -1
@@ -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
  }
@@ -13,7 +13,10 @@ export const meta: ComponentMeta = {
13
13
  'Binary on/off — use Switch',
14
14
  ],
15
15
  relatedTo: [
16
- {component: 'CheckboxGroup', relationship: 'CheckboxGroup for multi-select; RadioGroup for single-select'},
16
+ {
17
+ component: 'CheckboxGroup',
18
+ relationship: 'CheckboxGroup for multi-select; RadioGroup for single-select',
19
+ },
17
20
  {component: 'Select', relationship: 'Select for larger option sets or when space is limited'},
18
21
  {component: 'ToggleGroup', relationship: 'ToggleGroup for toolbar-style selection'},
19
22
  ],
@@ -84,10 +84,7 @@ function Item({value, disabled: itemDisabled = false, children}: ItemProps) {
84
84
  }
85
85
 
86
86
  return (
87
- <html.label
88
- style={[styles.item, isDisabled && styles.itemDisabled]}
89
- onClick={handleClick}
90
- >
87
+ <html.label style={[styles.item, isDisabled && styles.itemDisabled]} onClick={handleClick}>
91
88
  <html.input
92
89
  type="radio"
93
90
  value={value}
@@ -44,10 +44,7 @@ export function SortIndicator<TData>({column}: SortIndicatorProps<TData>) {
44
44
 
45
45
  return (
46
46
  <html.span
47
- style={[
48
- styles.root,
49
- sorted ? styles.active : styles.inactive,
50
- ]}
47
+ style={[styles.root, sorted ? styles.active : styles.inactive]}
51
48
  data-sort-indicator=""
52
49
  >
53
50
  {sorted === 'asc' ? '\u25B2' : sorted === 'desc' ? '\u25BC' : '\u21C5'}