@boxcustodia/library 2.0.0-alpha.12 → 2.0.0-alpha.14
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.
- package/dist/index.cjs.js +1 -138
- package/dist/index.d.ts +1087 -720
- package/dist/index.es.js +7011 -56097
- package/dist/theme.css +1 -1
- package/package.json +34 -26
- package/src/__doc__/Examples.tsx +1 -1
- package/src/__doc__/Intro.mdx +3 -3
- package/src/__doc__/Tabs.mdx +112 -0
- package/src/__doc__/V2.mdx +1246 -0
- package/src/components/accordion/accordion.stories.tsx +143 -0
- package/src/components/accordion/accordion.tsx +135 -0
- package/src/components/accordion/index.ts +1 -0
- package/src/components/alert/alert.stories.tsx +24 -4
- package/src/components/alert/alert.tsx +17 -9
- package/src/components/alert-dialog/alert-dialog.stories.tsx +24 -0
- package/src/components/alert-dialog/alert-dialog.test.tsx +1 -1
- package/src/components/alert-dialog/alert-dialog.tsx +58 -10
- package/src/components/auto-complete/auto-complete.stories.tsx +616 -200
- package/src/components/auto-complete/auto-complete.tsx +420 -68
- package/src/components/auto-complete/index.ts +0 -1
- package/src/components/avatar/avatar.stories.tsx +162 -21
- package/src/components/avatar/avatar.tsx +79 -20
- package/src/components/button/button.stories.tsx +219 -294
- package/src/components/button/button.test.tsx +10 -17
- package/src/components/button/button.tsx +78 -19
- package/src/components/button/components/base-button.tsx +30 -53
- package/src/components/button/index.ts +0 -1
- package/src/components/calendar/calendar.stories.tsx +1 -1
- package/src/components/calendar/calendar.tsx +4 -4
- package/src/components/card/card.stories.tsx +141 -69
- package/src/components/card/card.tsx +155 -54
- package/src/components/center/center.stories.tsx +22 -39
- package/src/components/checkbox/checkbox.stories.tsx +25 -5
- package/src/components/checkbox/checkbox.tsx +76 -15
- package/src/components/checkbox-group/checkbox-group.stories.tsx +116 -28
- package/src/components/checkbox-group/checkbox-group.tsx +84 -3
- package/src/components/combobox/combobox.stories.tsx +33 -23
- package/src/components/combobox/combobox.tsx +99 -77
- package/src/components/date-picker/date-input.stories.tsx +14 -6
- package/src/components/date-picker/date-input.tsx +2 -2
- package/src/components/date-picker/date-picker.model.ts +13 -4
- package/src/components/date-picker/date-picker.stories.tsx +38 -12
- package/src/components/date-picker/date-picker.tsx +28 -14
- package/src/components/dialog/dialog.stories.tsx +18 -0
- package/src/components/dialog/dialog.test.tsx +1 -1
- package/src/components/dialog/dialog.tsx +51 -20
- package/src/components/divider/divider.stories.tsx +126 -51
- package/src/components/divider/divider.tsx +16 -16
- package/src/components/dropzone/dropzone.stories.tsx +71 -90
- package/src/components/dropzone/dropzone.tsx +383 -105
- package/src/components/dropzone/index.ts +0 -1
- package/src/components/empty/empty.stories.tsx +165 -0
- package/src/components/empty/empty.tsx +156 -0
- package/src/components/empty/index.ts +1 -0
- package/src/components/field/field.stories.tsx +227 -4
- package/src/components/field/field.tsx +77 -42
- package/src/components/form/form.stories.tsx +320 -197
- package/src/components/form/form.tsx +3 -23
- package/src/components/index.ts +2 -6
- package/src/components/input/input.stories.tsx +5 -5
- package/src/components/input/input.tsx +4 -4
- package/src/components/kbd/kbd.stories.tsx +1 -0
- package/src/components/label/label.stories.tsx +16 -0
- package/src/components/label/label.tsx +13 -2
- package/src/components/loader/loader.stories.tsx +7 -5
- package/src/components/loader/loader.tsx +8 -3
- package/src/components/menu/menu-primitives.tsx +207 -196
- package/src/components/menu/menu.stories.tsx +276 -146
- package/src/components/menu/menu.tsx +146 -54
- package/src/components/number-input/number-input.stories.tsx +27 -4
- package/src/components/number-input/number-input.test.tsx +2 -2
- package/src/components/number-input/number-input.tsx +31 -33
- package/src/components/otp/index.ts +1 -0
- package/src/components/otp/otp.stories.tsx +209 -0
- package/src/components/otp/otp.tsx +100 -0
- package/src/components/pagination/index.ts +1 -0
- package/src/components/pagination/pagination.model.ts +2 -0
- package/src/components/pagination/pagination.stories.tsx +154 -59
- package/src/components/pagination/pagination.test.tsx +122 -57
- package/src/components/pagination/pagination.tsx +575 -77
- package/src/components/password/password.stories.tsx +18 -3
- package/src/components/password/password.tsx +29 -9
- package/src/components/popover/popover.stories.tsx +26 -5
- package/src/components/popover/popover.tsx +15 -23
- package/src/components/progress/progress.stories.tsx +1 -0
- package/src/components/radio-group/index.ts +1 -0
- package/src/components/radio-group/radio-group.stories.tsx +251 -0
- package/src/components/radio-group/radio-group.tsx +212 -0
- package/src/components/scroll-area/scroll-area.stories.tsx +1 -0
- package/src/components/select/select.stories.tsx +118 -19
- package/src/components/select/select.tsx +67 -62
- package/src/components/skeleton/skeleton.stories.tsx +1 -0
- package/src/components/stack/stack.stories.tsx +179 -89
- package/src/components/stack/stack.tsx +2 -2
- package/src/components/stepper/index.ts +1 -1
- package/src/components/stepper/stepper.stories.tsx +767 -83
- package/src/components/stepper/stepper.test.tsx +18 -18
- package/src/components/stepper/stepper.tsx +554 -0
- package/src/components/switch/switch.stories.tsx +15 -1
- package/src/components/switch/switch.tsx +17 -4
- package/src/components/table/index.ts +0 -2
- package/src/components/table/table.stories.tsx +131 -18
- package/src/components/table/table.test.tsx +1 -1
- package/src/components/table/table.tsx +183 -77
- package/src/components/tabs/tabs.stories.tsx +373 -155
- package/src/components/tabs/tabs.test.tsx +12 -12
- package/src/components/tabs/tabs.tsx +72 -149
- package/src/components/tag/index.ts +0 -1
- package/src/components/tag/tag.stories.tsx +155 -120
- package/src/components/tag/tag.tsx +47 -95
- package/src/components/textarea/textarea.stories.tsx +8 -22
- package/src/components/textarea/textarea.tsx +17 -79
- package/src/components/timeline/timeline.stories.tsx +323 -42
- package/src/components/timeline/timeline.tsx +359 -132
- package/src/components/toast/toast.stories.tsx +1 -0
- package/src/components/tooltip/tooltip.tsx +11 -9
- package/src/components/tree/index.ts +0 -1
- package/src/components/tree/tree.stories.tsx +365 -408
- package/src/components/tree/tree.test.tsx +163 -0
- package/src/components/tree/tree.tsx +212 -36
- package/src/hooks/useAsync/__doc__/useAsync.stories.tsx +5 -5
- package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +1 -3
- package/src/hooks/useDebounceCallback/__doc__/useDebouncedCallback.stories.tsx +6 -6
- package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +1 -1
- package/src/hooks/useEventListener/__test__/useEventListener.test.tsx +1 -1
- package/src/hooks/useLocalStorage/__doc__/useLocalStorage.stories.tsx +1 -1
- package/src/hooks/usePagination/usePagination.tsx +36 -24
- package/src/styles/theme.css +1 -1
- package/src/utils/form.tsx +67 -37
- package/src/utils/index.ts +1 -1
- package/src/__doc__/Migration.mdx +0 -475
- package/src/components/auto-complete/auto-complete-primitives.tsx +0 -155
- package/src/components/background-image/background-image.stories.tsx +0 -21
- package/src/components/background-image/background-image.test.tsx +0 -29
- package/src/components/background-image/background-image.tsx +0 -23
- package/src/components/background-image/index.ts +0 -1
- package/src/components/button/button.variants.ts +0 -44
- package/src/components/button/components/loader-overlay.tsx +0 -21
- package/src/components/button/components/loading-icon.tsx +0 -47
- package/src/components/dropzone/upload-primitives.tsx +0 -310
- package/src/components/dropzone/use-dropzone.ts +0 -122
- package/src/components/empty-state/empty-state.stories.tsx +0 -56
- package/src/components/empty-state/empty-state.tsx +0 -39
- package/src/components/empty-state/index.ts +0 -1
- package/src/components/heading/heading.stories.tsx +0 -74
- package/src/components/heading/heading.tsx +0 -28
- package/src/components/heading/heading.variants.ts +0 -27
- package/src/components/heading/index.ts +0 -1
- package/src/components/kbd/kbd.variants.ts +0 -26
- package/src/components/menu/util/render-menu-item.tsx +0 -54
- package/src/components/multi-select/hooks/use-multi-select.ts +0 -66
- package/src/components/multi-select/index.ts +0 -1
- package/src/components/multi-select/multi-select.stories.tsx +0 -294
- package/src/components/multi-select/multi-select.tsx +0 -300
- package/src/components/multi-select/multi-select.variants.ts +0 -22
- package/src/components/pagination/components/pagination-option.tsx +0 -27
- package/src/components/show/index.ts +0 -1
- package/src/components/show/show.stories.tsx +0 -197
- package/src/components/show/show.test.tsx +0 -41
- package/src/components/show/show.tsx +0 -16
- package/src/components/stepper/Stepper.tsx +0 -190
- package/src/components/stepper/context/stepper-context.tsx +0 -11
- package/src/components/table/table-primitives.tsx +0 -122
- package/src/components/table/table.model.ts +0 -20
- package/src/components/table-pagination/index.ts +0 -2
- package/src/components/table-pagination/table-pagination.model.ts +0 -2
- package/src/components/table-pagination/table-pagination.stories.tsx +0 -23
- package/src/components/table-pagination/table-pagination.test.tsx +0 -32
- package/src/components/table-pagination/table-pagination.tsx +0 -108
- package/src/components/tabs/context/tabs-context.tsx +0 -14
- package/src/components/tag/tag.variants.ts +0 -31
- package/src/components/timeline/timeline-status.ts +0 -5
- package/src/components/tree/hooks/use-controllable-tree-state.ts +0 -80
- 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. `
|
|
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
|
-
|
|
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
|
|
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
|
-
* `
|
|
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 — `
|
|
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
|
-
|
|
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
|
-
* `
|
|
308
|
-
* native submit event. `preventDefault` is already called by
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
377
|
-
|
|
378
|
-
.
|
|
379
|
-
.
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
*
|
|
470
|
-
*
|
|
468
|
+
* `react-hook-form` + `zod` integration via `HookForm`, `HookField`, and
|
|
469
|
+
* `useHookForm` — exported directly from `@boxcustodia/library`.
|
|
471
470
|
*
|
|
472
471
|
* ```tsx
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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 =
|
|
492
|
+
const form = useHookForm(FormSchema, {
|
|
492
493
|
defaultValues: { email: "", password: "" },
|
|
493
494
|
});
|
|
494
495
|
|
|
495
496
|
return (
|
|
496
|
-
<
|
|
497
|
+
<HookForm
|
|
497
498
|
form={form}
|
|
498
|
-
|
|
499
|
+
onFormSubmit={() => {}}
|
|
499
500
|
className="flex w-80 flex-col gap-4"
|
|
500
501
|
>
|
|
501
|
-
<
|
|
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
|
-
<
|
|
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
|
-
</
|
|
524
|
+
</HookForm>
|
|
524
525
|
);
|
|
525
526
|
},
|
|
526
527
|
};
|
|
527
528
|
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
4
|
+
export type FormProps = FormPrimitive.Props;
|
|
5
5
|
|
|
6
|
-
export
|
|
7
|
-
|
|
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 };
|