@boxcustodia/library 2.0.0-alpha.13 → 2.0.0-alpha.15

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 (174) hide show
  1. package/dist/index.cjs.js +1 -138
  2. package/dist/index.d.ts +1083 -717
  3. package/dist/index.es.js +7059 -56179
  4. package/dist/theme.css +1 -1
  5. package/package.json +34 -26
  6. package/src/__doc__/Changelog.mdx +6 -6
  7. package/src/__doc__/Examples.tsx +1 -1
  8. package/src/__doc__/Intro.mdx +3 -3
  9. package/src/__doc__/Tabs.mdx +112 -0
  10. package/src/__doc__/V2.mdx +1245 -0
  11. package/src/components/accordion/accordion.stories.tsx +143 -0
  12. package/src/components/accordion/accordion.tsx +135 -0
  13. package/src/components/accordion/index.ts +1 -0
  14. package/src/components/alert/alert.stories.tsx +24 -4
  15. package/src/components/alert/alert.tsx +17 -9
  16. package/src/components/alert-dialog/alert-dialog.stories.tsx +24 -0
  17. package/src/components/alert-dialog/alert-dialog.test.tsx +1 -1
  18. package/src/components/alert-dialog/alert-dialog.tsx +58 -10
  19. package/src/components/auto-complete/auto-complete.stories.tsx +615 -200
  20. package/src/components/auto-complete/auto-complete.tsx +420 -68
  21. package/src/components/auto-complete/index.ts +0 -1
  22. package/src/components/avatar/avatar.stories.tsx +162 -21
  23. package/src/components/avatar/avatar.tsx +79 -20
  24. package/src/components/button/button.stories.tsx +236 -294
  25. package/src/components/button/button.test.tsx +10 -17
  26. package/src/components/button/button.tsx +53 -18
  27. package/src/components/button/components/base-button.tsx +25 -53
  28. package/src/components/button/index.ts +0 -1
  29. package/src/components/calendar/calendar.stories.tsx +1 -1
  30. package/src/components/calendar/calendar.tsx +4 -4
  31. package/src/components/card/card.stories.tsx +140 -69
  32. package/src/components/card/card.tsx +155 -54
  33. package/src/components/center/center.stories.tsx +22 -39
  34. package/src/components/checkbox/checkbox.stories.tsx +25 -5
  35. package/src/components/checkbox/checkbox.tsx +76 -15
  36. package/src/components/checkbox-group/checkbox-group.stories.tsx +116 -28
  37. package/src/components/checkbox-group/checkbox-group.tsx +84 -3
  38. package/src/components/combobox/combobox.stories.tsx +33 -23
  39. package/src/components/combobox/combobox.tsx +120 -104
  40. package/src/components/date-picker/date-input.stories.tsx +14 -6
  41. package/src/components/date-picker/date-input.tsx +3 -3
  42. package/src/components/date-picker/date-picker.model.ts +13 -4
  43. package/src/components/date-picker/date-picker.stories.tsx +38 -12
  44. package/src/components/date-picker/date-picker.tsx +29 -15
  45. package/src/components/dialog/dialog.stories.tsx +18 -0
  46. package/src/components/dialog/dialog.test.tsx +1 -1
  47. package/src/components/dialog/dialog.tsx +51 -20
  48. package/src/components/divider/divider.stories.tsx +6 -0
  49. package/src/components/dropzone/dropzone.stories.tsx +70 -90
  50. package/src/components/dropzone/dropzone.tsx +383 -105
  51. package/src/components/dropzone/index.ts +0 -1
  52. package/src/components/empty/empty.stories.tsx +164 -0
  53. package/src/components/empty/empty.tsx +156 -0
  54. package/src/components/empty/index.ts +1 -0
  55. package/src/components/field/field.stories.tsx +226 -3
  56. package/src/components/field/field.tsx +77 -42
  57. package/src/components/form/form.stories.tsx +320 -197
  58. package/src/components/form/form.tsx +3 -23
  59. package/src/components/index.ts +2 -6
  60. package/src/components/input/input.stories.tsx +5 -5
  61. package/src/components/input/input.tsx +5 -5
  62. package/src/components/kbd/kbd.stories.tsx +1 -0
  63. package/src/components/label/label.stories.tsx +16 -0
  64. package/src/components/label/label.tsx +13 -2
  65. package/src/components/loader/loader.stories.tsx +7 -5
  66. package/src/components/loader/loader.tsx +8 -3
  67. package/src/components/menu/menu-primitives.tsx +207 -196
  68. package/src/components/menu/menu.stories.tsx +275 -146
  69. package/src/components/menu/menu.tsx +146 -54
  70. package/src/components/number-input/number-input.stories.tsx +27 -4
  71. package/src/components/number-input/number-input.test.tsx +2 -2
  72. package/src/components/number-input/number-input.tsx +29 -33
  73. package/src/components/otp/index.ts +1 -0
  74. package/src/components/otp/otp.stories.tsx +209 -0
  75. package/src/components/otp/otp.tsx +100 -0
  76. package/src/components/pagination/index.ts +1 -0
  77. package/src/components/pagination/pagination.model.ts +2 -0
  78. package/src/components/pagination/pagination.stories.tsx +153 -59
  79. package/src/components/pagination/pagination.test.tsx +122 -57
  80. package/src/components/pagination/pagination.tsx +575 -77
  81. package/src/components/password/password.stories.tsx +18 -3
  82. package/src/components/password/password.tsx +26 -10
  83. package/src/components/popover/popover.stories.tsx +26 -5
  84. package/src/components/popover/popover.tsx +15 -23
  85. package/src/components/progress/progress.stories.tsx +1 -0
  86. package/src/components/radio-group/index.ts +1 -0
  87. package/src/components/radio-group/radio-group.stories.tsx +251 -0
  88. package/src/components/radio-group/radio-group.tsx +212 -0
  89. package/src/components/scroll-area/scroll-area.stories.tsx +1 -0
  90. package/src/components/select/select.stories.tsx +118 -19
  91. package/src/components/select/select.tsx +67 -62
  92. package/src/components/skeleton/skeleton.stories.tsx +1 -0
  93. package/src/components/stack/stack.stories.tsx +179 -89
  94. package/src/components/stack/stack.tsx +2 -2
  95. package/src/components/stepper/index.ts +1 -1
  96. package/src/components/stepper/stepper.stories.tsx +766 -83
  97. package/src/components/stepper/stepper.test.tsx +18 -18
  98. package/src/components/stepper/stepper.tsx +554 -0
  99. package/src/components/switch/switch.stories.tsx +15 -1
  100. package/src/components/switch/switch.tsx +17 -4
  101. package/src/components/table/index.ts +0 -2
  102. package/src/components/table/table.stories.tsx +131 -18
  103. package/src/components/table/table.test.tsx +1 -1
  104. package/src/components/table/table.tsx +183 -77
  105. package/src/components/tabs/tabs.stories.tsx +372 -155
  106. package/src/components/tabs/tabs.test.tsx +12 -12
  107. package/src/components/tabs/tabs.tsx +72 -149
  108. package/src/components/tag/index.ts +0 -1
  109. package/src/components/tag/tag.stories.tsx +147 -120
  110. package/src/components/tag/tag.tsx +47 -95
  111. package/src/components/textarea/textarea.stories.tsx +8 -22
  112. package/src/components/textarea/textarea.tsx +17 -79
  113. package/src/components/timeline/timeline.stories.tsx +322 -42
  114. package/src/components/timeline/timeline.tsx +359 -132
  115. package/src/components/toast/toast.stories.tsx +1 -0
  116. package/src/components/tooltip/tooltip.tsx +11 -9
  117. package/src/components/tree/index.ts +0 -1
  118. package/src/components/tree/tree.stories.tsx +364 -408
  119. package/src/components/tree/tree.test.tsx +163 -0
  120. package/src/components/tree/tree.tsx +212 -36
  121. package/src/hooks/useAsync/__doc__/useAsync.stories.tsx +5 -5
  122. package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +1 -3
  123. package/src/hooks/useDebounceCallback/__doc__/useDebouncedCallback.stories.tsx +6 -6
  124. package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +1 -1
  125. package/src/hooks/useEventListener/__test__/useEventListener.test.tsx +1 -1
  126. package/src/hooks/useLocalStorage/__doc__/useLocalStorage.stories.tsx +1 -1
  127. package/src/hooks/usePagination/usePagination.tsx +36 -24
  128. package/src/styles/theme.css +1 -1
  129. package/src/utils/form.tsx +69 -37
  130. package/src/utils/index.ts +1 -1
  131. package/src/__doc__/Migration.mdx +0 -451
  132. package/src/components/auto-complete/auto-complete-primitives.tsx +0 -155
  133. package/src/components/background-image/background-image.stories.tsx +0 -21
  134. package/src/components/background-image/background-image.test.tsx +0 -29
  135. package/src/components/background-image/background-image.tsx +0 -23
  136. package/src/components/background-image/index.ts +0 -1
  137. package/src/components/button/button.variants.ts +0 -44
  138. package/src/components/button/components/loader-overlay.tsx +0 -21
  139. package/src/components/button/components/loading-icon.tsx +0 -47
  140. package/src/components/dropzone/upload-primitives.tsx +0 -310
  141. package/src/components/dropzone/use-dropzone.ts +0 -122
  142. package/src/components/empty-state/empty-state.stories.tsx +0 -56
  143. package/src/components/empty-state/empty-state.tsx +0 -39
  144. package/src/components/empty-state/index.ts +0 -1
  145. package/src/components/heading/heading.stories.tsx +0 -74
  146. package/src/components/heading/heading.tsx +0 -28
  147. package/src/components/heading/heading.variants.ts +0 -27
  148. package/src/components/heading/index.ts +0 -1
  149. package/src/components/kbd/kbd.variants.ts +0 -26
  150. package/src/components/menu/util/render-menu-item.tsx +0 -54
  151. package/src/components/multi-select/hooks/use-multi-select.ts +0 -66
  152. package/src/components/multi-select/index.ts +0 -1
  153. package/src/components/multi-select/multi-select.stories.tsx +0 -294
  154. package/src/components/multi-select/multi-select.tsx +0 -300
  155. package/src/components/multi-select/multi-select.variants.ts +0 -22
  156. package/src/components/pagination/components/pagination-option.tsx +0 -27
  157. package/src/components/show/index.ts +0 -1
  158. package/src/components/show/show.stories.tsx +0 -197
  159. package/src/components/show/show.test.tsx +0 -41
  160. package/src/components/show/show.tsx +0 -16
  161. package/src/components/stepper/Stepper.tsx +0 -190
  162. package/src/components/stepper/context/stepper-context.tsx +0 -11
  163. package/src/components/table/table-primitives.tsx +0 -122
  164. package/src/components/table/table.model.ts +0 -20
  165. package/src/components/table-pagination/index.ts +0 -2
  166. package/src/components/table-pagination/table-pagination.model.ts +0 -2
  167. package/src/components/table-pagination/table-pagination.stories.tsx +0 -23
  168. package/src/components/table-pagination/table-pagination.test.tsx +0 -32
  169. package/src/components/table-pagination/table-pagination.tsx +0 -108
  170. package/src/components/tabs/context/tabs-context.tsx +0 -14
  171. package/src/components/tag/tag.variants.ts +0 -31
  172. package/src/components/timeline/timeline-status.ts +0 -5
  173. package/src/components/tree/hooks/use-controllable-tree-state.ts +0 -80
  174. package/src/components/tree/tree-primitives.tsx +0 -126
@@ -1,16 +1,12 @@
1
- import { zodResolver } from "@hookform/resolvers/zod";
2
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
3
- import type { ReactNode } from "react";
4
2
  import { useState } from "react";
5
- import {
6
- type FieldValues,
7
- type SubmitHandler,
8
- type UseFormProps,
9
- type UseFormReturn,
10
- useController,
11
- useForm,
12
- } from "react-hook-form";
13
3
  import { z } from "zod";
4
+ import {
5
+ HookField,
6
+ HookForm,
7
+ parseFormValues,
8
+ useHookForm,
9
+ } from "../../utils/form";
14
10
  import { Button } from "../button/button";
15
11
  import { Checkbox } from "../checkbox/checkbox";
16
12
  import { Combobox } from "../combobox";
@@ -19,13 +15,15 @@ import { Field, FieldError, FieldLabel, FieldRoot } from "../field/field";
19
15
  import { Input } from "../input/input";
20
16
  import { NumberInput } from "../number-input";
21
17
  import { PasswordRoot } from "../password/password";
18
+ import { RadioGroup } from "../radio-group";
22
19
  import { Select } from "../select";
20
+ import { Stack } from "../stack";
23
21
  import { Textarea } from "../textarea/textarea";
24
22
  import { toast } from "../toast";
25
23
  import { Form, FormPrimitive } from "./form";
26
24
 
27
25
  /**
28
- * Thin wrapper around Base UI Form. `onSubmit` receives `(data, event)` —
26
+ * Thin wrapper around Base UI Form. `onFormSubmit` receives `(data, event)` —
29
27
  * `data` is a `Record<string, unknown>` parsed from `FormData`, `event` is
30
28
  * the native submit event. `preventDefault` is called automatically.
31
29
  * `errors` propagates server-side messages to fields by `name`.
@@ -39,7 +37,7 @@ const meta: Meta<typeof Form> = {
39
37
  parameters: { layout: "centered" },
40
38
  argTypes: {
41
39
  children: { control: false },
42
- onSubmit: { control: false },
40
+ onFormSubmit: { control: false },
43
41
  actionsRef: { control: false },
44
42
  errors: { control: false },
45
43
  },
@@ -71,7 +69,10 @@ export const Default: Story = {
71
69
  const [done, setDone] = useState(false);
72
70
 
73
71
  return (
74
- <Form className="flex w-80 flex-col gap-4" onSubmit={() => setDone(true)}>
72
+ <Form
73
+ className="flex w-80 flex-col gap-4"
74
+ onFormSubmit={() => setDone(true)}
75
+ >
75
76
  <Field
76
77
  name="email"
77
78
  label="Email"
@@ -144,7 +145,6 @@ export const Default: Story = {
144
145
  >
145
146
  <Combobox
146
147
  items={NOTIFICATION_CHANNELS}
147
- defaultValue={[NOTIFICATION_CHANNELS[0]]}
148
148
  placeholder="Select channels…"
149
149
  multiple
150
150
  required
@@ -183,6 +183,23 @@ export const Default: Story = {
183
183
  />
184
184
  </Field>
185
185
 
186
+ <Field
187
+ name="plan"
188
+ label="Plan"
189
+ required
190
+ description="Choose the plan that fits your needs."
191
+ error={[{ message: "Please select a plan.", match: "valueMissing" }]}
192
+ >
193
+ <RadioGroup
194
+ required
195
+ items={[
196
+ { value: "free", label: "Free" },
197
+ { value: "pro", label: "Pro" },
198
+ { value: "enterprise", label: "Enterprise" },
199
+ ]}
200
+ />
201
+ </Field>
202
+
186
203
  <Field
187
204
  name="terms"
188
205
  inline
@@ -204,12 +221,12 @@ export const Default: Story = {
204
221
  };
205
222
 
206
223
  /**
207
- * `onSubmit` receives parsed `FormData` and is the right place for cross-field
224
+ * `onFormSubmit` receives parsed `FormData` and is the right place for cross-field
208
225
  * and custom-rule validation. Errors are stored in state and forwarded via
209
226
  * `Form.errors`, which routes each message to the matching field by `name` and
210
227
  * clears it automatically as soon as the user modifies that field.
211
228
  *
212
- * Native browser validation (email format, required) runs first — `onSubmit`
229
+ * Native browser validation (email format, required) runs first — `onFormSubmit`
213
230
  * only fires after all native constraints pass.
214
231
  */
215
232
  export const OnSubmitValidation: Story = {
@@ -220,7 +237,7 @@ export const OnSubmitValidation: Story = {
220
237
  <Form
221
238
  className="flex w-80 flex-col gap-4"
222
239
  errors={errors}
223
- onSubmit={(data) => {
240
+ onFormSubmit={(data) => {
224
241
  const next: Record<string, string> = {};
225
242
  const pwd = data.password as string;
226
243
 
@@ -304,8 +321,8 @@ export const ValidateOnChange: Story = {
304
321
  };
305
322
 
306
323
  /**
307
- * `onSubmit(data, event)` — `data` is parsed from `FormData`, `event` is the
308
- * native submit event. `preventDefault` is already called by the Form wrapper.
324
+ * `onFormSubmit(data, event)` — `data` is parsed from `FormData`, `event` is
325
+ * the native submit event. `preventDefault` is already called by Base UI.
309
326
  */
310
327
  export const OnFormSubmit: Story = {
311
328
  render: () => {
@@ -314,7 +331,7 @@ export const OnFormSubmit: Story = {
314
331
  return (
315
332
  <Form
316
333
  className="flex w-80 flex-col gap-4"
317
- onSubmit={(data) => setValues(data)}
334
+ onFormSubmit={(data) => setValues(data)}
318
335
  >
319
336
  <Field name="email" label="Email">
320
337
  <Input type="email" required placeholder="you@example.com" />
@@ -333,6 +350,77 @@ export const OnFormSubmit: Story = {
333
350
  },
334
351
  };
335
352
 
353
+ const ZodSchema = z.object({
354
+ name: z.string().min(1, "Name is required."),
355
+ age: z.coerce
356
+ .number({ message: "Age must be a number." })
357
+ .positive("Age must be a positive number."),
358
+ });
359
+
360
+ /**
361
+ * Validación con `zod` sin `react-hook-form`. Útil cuando querés mantener un
362
+ * schema como source of truth (tipado del data, coerción, transformaciones)
363
+ * pero no necesitás watchers, dependent fields ni multi-step. El form sigue
364
+ * siendo un `<form>` nativo — sin context provider, sin re-renders por
365
+ * keystroke, sin `useForm` orquestando estado.
366
+ *
367
+ * **Flujo:**
368
+ *
369
+ * 1. Definí el schema con `zod`. Usá `z.coerce.*` para campos cuyo `FormData`
370
+ * llega como string (`age`, fechas, números).
371
+ * 2. En `onFormSubmit`, pasá los `values` a `parseFormValues(schema, values)`.
372
+ * Devuelve `{ data, errors }`:
373
+ * - `data` es `z.infer<typeof schema>` cuando todas las validaciones pasan;
374
+ * `null` si alguna falla.
375
+ * - `errors` es `Record<string, string[]>` — keys son los `name` del Field,
376
+ * values son los mensajes de zod. Vacío `{}` en éxito.
377
+ * 3. Pasá `errors` al prop `errors` del Form. Base UI rutea cada mensaje al
378
+ * Field por `name` y lo limpia automáticamente cuando el usuario edita
379
+ * ese campo.
380
+ *
381
+ * Para componentes con valor tipado (Select, Combobox), su valor llega al
382
+ * `formValues` como el shape que vos definas en el `value` — coerce o
383
+ * transformá en el schema si lo querés normalizar.
384
+ *
385
+ * Para reactividad real entre campos (watch, dependent fields, wizards),
386
+ * usá `HookForm` + `HookField` (más abajo).
387
+ */
388
+ export const WithZod: Story = {
389
+ render: () => {
390
+ const [errors, setErrors] = useState<Record<string, string[]>>({});
391
+
392
+ return (
393
+ <Form
394
+ className="flex w-80 flex-col gap-4"
395
+ errors={errors}
396
+ onFormSubmit={(values) => {
397
+ const result = parseFormValues(ZodSchema, values);
398
+ setErrors(result.errors);
399
+
400
+ if (result.data) {
401
+ toast({
402
+ title: "Saved!",
403
+ description: (
404
+ <pre className="rounded-md bg-muted p-3 text-xs font-mono">
405
+ {JSON.stringify(result.data, null, 2)}
406
+ </pre>
407
+ ),
408
+ });
409
+ }
410
+ }}
411
+ >
412
+ <Field name="name" label="Name">
413
+ <Input placeholder="Enter name" />
414
+ </Field>
415
+ <Field name="age" label="Age" description="Must be positive.">
416
+ <Input placeholder="Enter age" />
417
+ </Field>
418
+ <Button type="submit">Submit</Button>
419
+ </Form>
420
+ );
421
+ },
422
+ };
423
+
336
424
  /**
337
425
  * `FormPrimitive` + `FieldRoot` primitives for full structural control.
338
426
  * Use when you need custom ordering or elements between parts.
@@ -366,139 +454,52 @@ export const Primitive: Story = {
366
454
  ),
367
455
  };
368
456
 
369
- const CATEGORIES = [
370
- { label: "Frontend", value: "frontend" },
371
- { label: "Backend", value: "backend" },
372
- { label: "DevOps", value: "devops" },
373
- { label: "Design", value: "design" },
374
- ];
457
+ // ── With zod + react-hook-form ───────────────────────────────────────────────────────────
375
458
 
376
- const schema = z.object({
377
- age: z.coerce
378
- .number({ message: "Please enter a number." })
379
- .positive({ message: "Number must be positive." }),
380
- name: z.string().min(1, { message: "Please enter a name." }),
381
- emoji: z.emoji({ message: "Please enter an emoji." }),
382
- category: z.string().min(1, { message: "Select a category." }),
383
- terms: z.literal(true, { message: "You must accept the terms." }),
459
+ const FormSchema = z.object({
460
+ email: z
461
+ .string()
462
+ .min(1, "Email is required.")
463
+ .email("Enter a valid email address."),
464
+ password: z.string().min(1, "Password is required."),
384
465
  });
385
466
 
386
- type FormDataType = z.infer<typeof schema>;
387
- type Errors = Partial<Record<keyof FormDataType, string[]>>;
388
-
389
- export const UsingZod: Story = {
390
- render: () => {
391
- const [loading, setLoading] = useState(false);
392
- const [errors, setErrors] = useState<Errors>({});
393
-
394
- const onSubmit = async (data: any) => {
395
- setLoading(true);
396
-
397
- try {
398
- const result = schema.safeParse(data);
399
-
400
- if (!result.success) {
401
- const { fieldErrors } = z.flattenError(result.error);
402
- setErrors(fieldErrors as Errors);
403
- return;
404
- }
405
-
406
- setErrors({});
407
- await new Promise((r) => setTimeout(r, 800));
408
-
409
- toast({
410
- title: "Success",
411
- description: (
412
- <pre className="rounded-md bg-muted p-3 text-sm font-mono">
413
- {JSON.stringify(result.data, null, 2)}
414
- </pre>
415
- ),
416
- });
417
- } finally {
418
- setLoading(false);
419
- }
420
- };
421
-
422
- return (
423
- <Form
424
- className="flex w-full max-w-64 flex-col gap-4"
425
- errors={errors}
426
- onSubmit={onSubmit}
427
- >
428
- <Field name="name" label="Name" required>
429
- <Input placeholder="Enter name" />
430
- </Field>
431
-
432
- <Field name="age" label="Age" description="Must be positive." required>
433
- <Input placeholder="Enter age" />
434
- </Field>
435
-
436
- <Field
437
- name="emoji"
438
- label="Emoji"
439
- required
440
- tooltip="What's your favorite emoji?"
441
- >
442
- <Input />
443
- </Field>
444
-
445
- <Field name="category" label="Category" required>
446
- <Combobox items={CATEGORIES} placeholder="Select a category…" />
447
- </Field>
448
-
449
- <Field
450
- name="terms"
451
- inline
452
- label="I accept the terms and conditions"
453
- required
454
- >
455
- <Checkbox />
456
- </Field>
457
-
458
- <Button loading={loading} type="submit">
459
- Submit
460
- </Button>
461
- </Form>
462
- );
463
- },
464
- };
465
-
466
- // ── React Hook Form ───────────────────────────────────────────────────────────
467
-
468
467
  /**
469
- * Integration with React Hook Form + Zod. The `RHFField` helper wraps `Field`
470
- * and feeds `invalid`/`error` from `fieldState` copy it into your project.
468
+ * `react-hook-form` + `zod` integration via `HookForm`, `HookField`, and
469
+ * `useHookForm` — exported directly from `@boxcustodia/library`.
471
470
  *
472
471
  * ```tsx
473
- * function RHFField({ name, label, description, rules, render }) {
474
- * const { field, fieldState } = useController({ name, rules });
475
- * return (
476
- * <Field
477
- * name={name}
478
- * label={label}
479
- * description={description}
480
- * invalid={fieldState.invalid}
481
- * error={fieldState.error?.message}
482
- * >
483
- * {render(field)}
484
- * </Field>
485
- * );
486
- * }
472
+ * import { HookForm, HookField, useHookForm } from "@boxcustodia/library";
473
+ *
474
+ * const form = useHookForm(MySchema, { defaultValues: { ... } });
475
+ *
476
+ * <HookForm form={form} onFormSubmit={(data) => console.log(data)}>
477
+ * <HookField
478
+ * name="email"
479
+ * label="Email"
480
+ * render={(field) => <Input {...field} type="email" />}
481
+ * />
482
+ * </HookForm>
487
483
  * ```
484
+ *
485
+ * `useHookForm(schema, options)` wraps `react-hook-form`'s `useForm` with
486
+ * `zodResolver` pre-wired. `HookForm` provides the `FormProvider` context.
487
+ * `HookField` wraps `Controller` + `Field` — errors and invalid state are
488
+ * managed automatically.
488
489
  */
489
490
  export const WithReactHookForm: Story = {
490
491
  render: () => {
491
- const form = useRHFForm(FormSchema, {
492
+ const form = useHookForm(FormSchema, {
492
493
  defaultValues: { email: "", password: "" },
493
494
  });
494
495
 
495
496
  return (
496
- <RHFForm
497
+ <HookForm
497
498
  form={form}
498
- onSubmit={() => {}}
499
+ onFormSubmit={() => {}}
499
500
  className="flex w-80 flex-col gap-4"
500
501
  >
501
- <RHFField
502
+ <HookField
502
503
  name="email"
503
504
  label="Email"
504
505
  description="We'll never share your email."
@@ -507,7 +508,7 @@ export const WithReactHookForm: Story = {
507
508
  )}
508
509
  />
509
510
 
510
- <RHFField
511
+ <HookField
511
512
  name="password"
512
513
  label="Password"
513
514
  render={(field) => (
@@ -520,75 +521,197 @@ export const WithReactHookForm: Story = {
520
521
  )}
521
522
 
522
523
  <Button type="submit">Create account</Button>
523
- </RHFForm>
524
+ </HookForm>
524
525
  );
525
526
  },
526
527
  };
527
528
 
528
- // ── RHF helpers (not exported by the library — copy into your project) ────────
529
+ /**
530
+ * `Field.validate` runs after native constraints pass. Return a string to fail,
531
+ * `null` to pass. Async functions are supported.
532
+ *
533
+ * **Important:** after the first submit attempt, Base UI switches to per-keystroke
534
+ * validation regardless of `validationMode` (`submitAttemptedRef` becomes `true`).
535
+ * For async validators that hit a backend, two guards are required:
536
+ *
537
+ * 1. `validationDebounceTime` — debounces the onChange trigger so the function
538
+ * only fires after the user pauses typing.
539
+ * 2. A length/format check at the top of `validate` — skip the network call when
540
+ * native constraints (required, minLength…) haven't passed yet. Base UI calls
541
+ * `validate` even while those are failing when in onChange mode.
542
+ */
543
+ export const FieldValidate: Story = {
544
+ render: () => {
545
+ const TAKEN = ["admin", "jane_doe", "johndoe"];
529
546
 
530
- const FormSchema = z.object({
547
+ return (
548
+ <Form className="flex w-80 flex-col gap-4" validationMode="onSubmit">
549
+ <Field
550
+ name="username"
551
+ label="Username"
552
+ description={`Already taken: ${TAKEN.join(", ")}`}
553
+ validationDebounceTime={400}
554
+ validationMode="onSubmit"
555
+ validate={async (value) => {
556
+ const str = String(value);
557
+ if (str.length < 3)
558
+ return "Username must be at least 3 characters.";
559
+ await new Promise((r) => setTimeout(r, 600));
560
+ return TAKEN.includes(str)
561
+ ? "This username is already taken."
562
+ : null;
563
+ }}
564
+ >
565
+ <Input placeholder="e.g. jane_doe" />
566
+ </Field>
567
+ <Button type="submit">Check availability</Button>
568
+ </Form>
569
+ );
570
+ },
571
+ };
572
+
573
+ // ── With react-hook-form (full example) ─────────────────────────────────────
574
+
575
+ const MemberSchema = z.object({
576
+ firstName: z.string().min(1, "First name is required."),
577
+ lastName: z.string().min(1, "Last name is required."),
531
578
  email: z
532
579
  .string()
533
580
  .min(1, "Email is required.")
534
581
  .email("Enter a valid email address."),
535
- password: z.string().min(1, "Password is required."),
582
+ role: z.string().min(1, "Role is required."),
583
+ bio: z.string().min(10, "Bio must be at least 10 characters."),
584
+ terms: z.literal(true, { message: "You must accept the terms." }),
536
585
  });
537
586
 
538
- function useRHFForm<T extends z.ZodType<FieldValues, FieldValues>>(
539
- schema: T,
540
- options?: Omit<UseFormProps<z.infer<T>>, "resolver">,
541
- ) {
542
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
543
- return useForm<z.infer<T>>({
544
- resolver: zodResolver(schema as any),
545
- ...options,
546
- });
547
- }
548
-
549
- type RHFFormProps<T extends FieldValues> = {
550
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
551
- form: UseFormReturn<T, any, any>;
552
- onSubmit: SubmitHandler<T>;
553
- children: ReactNode;
554
- className?: string;
555
- };
587
+ type MemberFormData = z.infer<typeof MemberSchema>;
556
588
 
557
- function RHFForm<T extends FieldValues>({
558
- form,
559
- onSubmit,
560
- children,
561
- ...props
562
- }: RHFFormProps<T>) {
563
- return (
564
- <Form
565
- // onSubmit={(data, event) => form.handleSubmit(onSubmit)(data, event)}
566
- {...props}
567
- >
568
- {children}
569
- </Form>
570
- );
571
- }
572
-
573
- interface RHFFieldProps {
574
- name: string;
575
- label?: ReactNode;
576
- description?: ReactNode;
577
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
578
- render: (field: any) => ReactNode;
579
- }
580
-
581
- function RHFField({ name, label, description, render }: RHFFieldProps) {
582
- const { field, fieldState } = useController({ name });
583
- return (
584
- <Field
585
- name={name}
586
- label={label}
587
- description={description}
588
- invalid={fieldState.invalid}
589
- error={fieldState.error?.message}
590
- >
591
- {render(field)}
592
- </Field>
593
- );
594
- }
589
+ /**
590
+ * Full real-world example using `useForm`, `Form`, and `FormField` from
591
+ * `@boxcustodia/library`. These are thin wrappers over `react-hook-form` +
592
+ * `zod` — just import and use, no boilerplate needed.
593
+ *
594
+ * ```tsx
595
+ * import { HookForm, HookField, useHookForm } from "@boxcustodia/library";
596
+ *
597
+ * const form = useHookForm(MySchema, { defaultValues: { ... } });
598
+ *
599
+ * <HookForm form={form} onFormSubmit={(data) => console.log(data)}>
600
+ * <HookField
601
+ * name="email"
602
+ * label="Email"
603
+ * render={(field) => <Input {...field} type="email" />}
604
+ * />
605
+ * </HookForm>
606
+ * ```
607
+ *
608
+ * `render` receives `ControllerRenderProps` plus `invalid: boolean`.
609
+ * Spread `{...field}` into inputs that accept `value` / `onChange` / `onBlur` / `ref`.
610
+ *
611
+ * Components with a typed item API require manual mapping:
612
+ * - `Select` — `onChange` returns `TItem | null`, extract `.value` for `z.string()` schemas.
613
+ * - `Checkbox` uses `checked` / `onCheckedChange`, not `value` / `onChange`.
614
+ */
615
+ export const WithReactHookFormFull: Story = {
616
+ render: () => {
617
+ const [loading, setLoading] = useState(false);
618
+
619
+ const form = useHookForm(MemberSchema, {
620
+ defaultValues: {
621
+ firstName: "",
622
+ lastName: "",
623
+ email: "",
624
+ role: "",
625
+ bio: "",
626
+ terms: false as unknown as true,
627
+ },
628
+ });
629
+
630
+ const onSubmit = async (data: MemberFormData) => {
631
+ setLoading(true);
632
+ await new Promise((r) => setTimeout(r, 800));
633
+ setLoading(false);
634
+ toast({
635
+ title: "Member created!",
636
+ description: (
637
+ <pre className="rounded-md bg-muted p-3 text-xs font-mono">
638
+ {JSON.stringify(data, null, 2)}
639
+ </pre>
640
+ ),
641
+ });
642
+ };
643
+
644
+ return (
645
+ <HookForm
646
+ form={form}
647
+ onFormSubmit={onSubmit}
648
+ className="flex w-80 flex-col gap-4"
649
+ >
650
+ <Stack>
651
+ <HookField
652
+ name="firstName"
653
+ label="First name"
654
+ required
655
+ render={(field) => <Input {...field} placeholder="Jane" />}
656
+ />
657
+ <HookField
658
+ name="lastName"
659
+ label="Last name"
660
+ required
661
+ render={(field) => <Input {...field} placeholder="Doe" />}
662
+ />
663
+ </Stack>
664
+
665
+ <HookField
666
+ name="email"
667
+ label="Email"
668
+ required
669
+ render={(field) => (
670
+ <Input {...field} type="email" placeholder="jane@example.com" />
671
+ )}
672
+ />
673
+
674
+ <HookField
675
+ name="role"
676
+ label="Role"
677
+ required
678
+ render={({ onChange, ...field }) => (
679
+ <Select
680
+ items={ROLES}
681
+ placeholder="Select a role…"
682
+ {...field}
683
+ value={field.value}
684
+ onValueChange={(item) => onChange(item?.value)}
685
+ />
686
+ )}
687
+ />
688
+
689
+ <HookField
690
+ name="bio"
691
+ label="Bio"
692
+ description="Min 10 characters."
693
+ render={(field) => (
694
+ <Textarea {...field} placeholder="Tell us about yourself…" />
695
+ )}
696
+ />
697
+
698
+ <HookField
699
+ name="terms"
700
+ inline
701
+ label="I accept the terms and conditions"
702
+ render={(field) => (
703
+ <Checkbox
704
+ {...field}
705
+ checked={field.value}
706
+ onCheckedChange={field.onChange}
707
+ />
708
+ )}
709
+ />
710
+
711
+ <Button loading={loading} type="submit">
712
+ Create member
713
+ </Button>
714
+ </HookForm>
715
+ );
716
+ },
717
+ };
@@ -1,30 +1,10 @@
1
1
  import { Form as FormPrimitive } from "@base-ui/react/form";
2
2
  import type React from "react";
3
3
 
4
- type BaseProps = FormPrimitive.Props;
4
+ export type FormProps = FormPrimitive.Props;
5
5
 
6
- export interface FormProps
7
- extends Omit<BaseProps, "onSubmit" | "onFormSubmit"> {
8
- onSubmit?: BaseProps["onFormSubmit"];
9
- onFormSubmit?: BaseProps["onFormSubmit"];
10
- }
11
-
12
- export function Form({
13
- onSubmit,
14
- onFormSubmit,
15
- className,
16
- ...props
17
- }: FormProps): React.ReactElement {
18
- const handleSubmit = onSubmit ?? onFormSubmit;
19
-
20
- return (
21
- <FormPrimitive
22
- className={className}
23
- onFormSubmit={handleSubmit}
24
- data-slot="form"
25
- {...props}
26
- />
27
- );
6
+ export function Form(props: FormProps): React.ReactElement {
7
+ return <FormPrimitive data-slot="form" {...props} />;
28
8
  }
29
9
 
30
10
  export { FormPrimitive };