@boxcustodia/library 2.0.0-alpha.13 → 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.
Files changed (173) hide show
  1. package/dist/index.cjs.js +1 -138
  2. package/dist/index.d.ts +1083 -715
  3. package/dist/index.es.js +7077 -56175
  4. package/dist/theme.css +1 -1
  5. package/package.json +34 -26
  6. package/src/__doc__/Examples.tsx +1 -1
  7. package/src/__doc__/Intro.mdx +3 -3
  8. package/src/__doc__/Tabs.mdx +112 -0
  9. package/src/__doc__/V2.mdx +1246 -0
  10. package/src/components/accordion/accordion.stories.tsx +143 -0
  11. package/src/components/accordion/accordion.tsx +135 -0
  12. package/src/components/accordion/index.ts +1 -0
  13. package/src/components/alert/alert.stories.tsx +24 -4
  14. package/src/components/alert/alert.tsx +17 -9
  15. package/src/components/alert-dialog/alert-dialog.stories.tsx +24 -0
  16. package/src/components/alert-dialog/alert-dialog.test.tsx +1 -1
  17. package/src/components/alert-dialog/alert-dialog.tsx +58 -10
  18. package/src/components/auto-complete/auto-complete.stories.tsx +616 -200
  19. package/src/components/auto-complete/auto-complete.tsx +420 -68
  20. package/src/components/auto-complete/index.ts +0 -1
  21. package/src/components/avatar/avatar.stories.tsx +162 -21
  22. package/src/components/avatar/avatar.tsx +79 -20
  23. package/src/components/button/button.stories.tsx +219 -294
  24. package/src/components/button/button.test.tsx +10 -17
  25. package/src/components/button/button.tsx +78 -19
  26. package/src/components/button/components/base-button.tsx +30 -53
  27. package/src/components/button/index.ts +0 -1
  28. package/src/components/calendar/calendar.stories.tsx +1 -1
  29. package/src/components/calendar/calendar.tsx +4 -4
  30. package/src/components/card/card.stories.tsx +141 -69
  31. package/src/components/card/card.tsx +155 -54
  32. package/src/components/center/center.stories.tsx +22 -39
  33. package/src/components/checkbox/checkbox.stories.tsx +25 -5
  34. package/src/components/checkbox/checkbox.tsx +76 -15
  35. package/src/components/checkbox-group/checkbox-group.stories.tsx +116 -28
  36. package/src/components/checkbox-group/checkbox-group.tsx +84 -3
  37. package/src/components/combobox/combobox.stories.tsx +33 -23
  38. package/src/components/combobox/combobox.tsx +119 -103
  39. package/src/components/date-picker/date-input.stories.tsx +14 -6
  40. package/src/components/date-picker/date-input.tsx +2 -2
  41. package/src/components/date-picker/date-picker.model.ts +13 -4
  42. package/src/components/date-picker/date-picker.stories.tsx +38 -12
  43. package/src/components/date-picker/date-picker.tsx +28 -14
  44. package/src/components/dialog/dialog.stories.tsx +18 -0
  45. package/src/components/dialog/dialog.test.tsx +1 -1
  46. package/src/components/dialog/dialog.tsx +51 -20
  47. package/src/components/divider/divider.stories.tsx +6 -0
  48. package/src/components/dropzone/dropzone.stories.tsx +71 -90
  49. package/src/components/dropzone/dropzone.tsx +383 -105
  50. package/src/components/dropzone/index.ts +0 -1
  51. package/src/components/empty/empty.stories.tsx +165 -0
  52. package/src/components/empty/empty.tsx +156 -0
  53. package/src/components/empty/index.ts +1 -0
  54. package/src/components/field/field.stories.tsx +226 -3
  55. package/src/components/field/field.tsx +77 -42
  56. package/src/components/form/form.stories.tsx +320 -197
  57. package/src/components/form/form.tsx +3 -23
  58. package/src/components/index.ts +2 -6
  59. package/src/components/input/input.stories.tsx +5 -5
  60. package/src/components/input/input.tsx +4 -4
  61. package/src/components/kbd/kbd.stories.tsx +1 -0
  62. package/src/components/label/label.stories.tsx +16 -0
  63. package/src/components/label/label.tsx +13 -2
  64. package/src/components/loader/loader.stories.tsx +7 -5
  65. package/src/components/loader/loader.tsx +8 -3
  66. package/src/components/menu/menu-primitives.tsx +207 -196
  67. package/src/components/menu/menu.stories.tsx +276 -146
  68. package/src/components/menu/menu.tsx +146 -54
  69. package/src/components/number-input/number-input.stories.tsx +27 -4
  70. package/src/components/number-input/number-input.test.tsx +2 -2
  71. package/src/components/number-input/number-input.tsx +25 -29
  72. package/src/components/otp/index.ts +1 -0
  73. package/src/components/otp/otp.stories.tsx +209 -0
  74. package/src/components/otp/otp.tsx +100 -0
  75. package/src/components/pagination/index.ts +1 -0
  76. package/src/components/pagination/pagination.model.ts +2 -0
  77. package/src/components/pagination/pagination.stories.tsx +154 -59
  78. package/src/components/pagination/pagination.test.tsx +122 -57
  79. package/src/components/pagination/pagination.tsx +575 -77
  80. package/src/components/password/password.stories.tsx +18 -3
  81. package/src/components/password/password.tsx +26 -10
  82. package/src/components/popover/popover.stories.tsx +26 -5
  83. package/src/components/popover/popover.tsx +15 -23
  84. package/src/components/progress/progress.stories.tsx +1 -0
  85. package/src/components/radio-group/index.ts +1 -0
  86. package/src/components/radio-group/radio-group.stories.tsx +251 -0
  87. package/src/components/radio-group/radio-group.tsx +212 -0
  88. package/src/components/scroll-area/scroll-area.stories.tsx +1 -0
  89. package/src/components/select/select.stories.tsx +118 -19
  90. package/src/components/select/select.tsx +67 -62
  91. package/src/components/skeleton/skeleton.stories.tsx +1 -0
  92. package/src/components/stack/stack.stories.tsx +179 -89
  93. package/src/components/stack/stack.tsx +2 -2
  94. package/src/components/stepper/index.ts +1 -1
  95. package/src/components/stepper/stepper.stories.tsx +767 -83
  96. package/src/components/stepper/stepper.test.tsx +18 -18
  97. package/src/components/stepper/stepper.tsx +554 -0
  98. package/src/components/switch/switch.stories.tsx +15 -1
  99. package/src/components/switch/switch.tsx +17 -4
  100. package/src/components/table/index.ts +0 -2
  101. package/src/components/table/table.stories.tsx +131 -18
  102. package/src/components/table/table.test.tsx +1 -1
  103. package/src/components/table/table.tsx +183 -77
  104. package/src/components/tabs/tabs.stories.tsx +373 -155
  105. package/src/components/tabs/tabs.test.tsx +12 -12
  106. package/src/components/tabs/tabs.tsx +72 -149
  107. package/src/components/tag/index.ts +0 -1
  108. package/src/components/tag/tag.stories.tsx +155 -120
  109. package/src/components/tag/tag.tsx +47 -95
  110. package/src/components/textarea/textarea.stories.tsx +8 -22
  111. package/src/components/textarea/textarea.tsx +17 -79
  112. package/src/components/timeline/timeline.stories.tsx +323 -42
  113. package/src/components/timeline/timeline.tsx +359 -132
  114. package/src/components/toast/toast.stories.tsx +1 -0
  115. package/src/components/tooltip/tooltip.tsx +11 -9
  116. package/src/components/tree/index.ts +0 -1
  117. package/src/components/tree/tree.stories.tsx +365 -408
  118. package/src/components/tree/tree.test.tsx +163 -0
  119. package/src/components/tree/tree.tsx +212 -36
  120. package/src/hooks/useAsync/__doc__/useAsync.stories.tsx +5 -5
  121. package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +1 -3
  122. package/src/hooks/useDebounceCallback/__doc__/useDebouncedCallback.stories.tsx +6 -6
  123. package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +1 -1
  124. package/src/hooks/useEventListener/__test__/useEventListener.test.tsx +1 -1
  125. package/src/hooks/useLocalStorage/__doc__/useLocalStorage.stories.tsx +1 -1
  126. package/src/hooks/usePagination/usePagination.tsx +36 -24
  127. package/src/styles/theme.css +1 -1
  128. package/src/utils/form.tsx +67 -37
  129. package/src/utils/index.ts +1 -1
  130. package/src/__doc__/Migration.mdx +0 -451
  131. package/src/components/auto-complete/auto-complete-primitives.tsx +0 -155
  132. package/src/components/background-image/background-image.stories.tsx +0 -21
  133. package/src/components/background-image/background-image.test.tsx +0 -29
  134. package/src/components/background-image/background-image.tsx +0 -23
  135. package/src/components/background-image/index.ts +0 -1
  136. package/src/components/button/button.variants.ts +0 -44
  137. package/src/components/button/components/loader-overlay.tsx +0 -21
  138. package/src/components/button/components/loading-icon.tsx +0 -47
  139. package/src/components/dropzone/upload-primitives.tsx +0 -310
  140. package/src/components/dropzone/use-dropzone.ts +0 -122
  141. package/src/components/empty-state/empty-state.stories.tsx +0 -56
  142. package/src/components/empty-state/empty-state.tsx +0 -39
  143. package/src/components/empty-state/index.ts +0 -1
  144. package/src/components/heading/heading.stories.tsx +0 -74
  145. package/src/components/heading/heading.tsx +0 -28
  146. package/src/components/heading/heading.variants.ts +0 -27
  147. package/src/components/heading/index.ts +0 -1
  148. package/src/components/kbd/kbd.variants.ts +0 -26
  149. package/src/components/menu/util/render-menu-item.tsx +0 -54
  150. package/src/components/multi-select/hooks/use-multi-select.ts +0 -66
  151. package/src/components/multi-select/index.ts +0 -1
  152. package/src/components/multi-select/multi-select.stories.tsx +0 -294
  153. package/src/components/multi-select/multi-select.tsx +0 -300
  154. package/src/components/multi-select/multi-select.variants.ts +0 -22
  155. package/src/components/pagination/components/pagination-option.tsx +0 -27
  156. package/src/components/show/index.ts +0 -1
  157. package/src/components/show/show.stories.tsx +0 -197
  158. package/src/components/show/show.test.tsx +0 -41
  159. package/src/components/show/show.tsx +0 -16
  160. package/src/components/stepper/Stepper.tsx +0 -190
  161. package/src/components/stepper/context/stepper-context.tsx +0 -11
  162. package/src/components/table/table-primitives.tsx +0 -122
  163. package/src/components/table/table.model.ts +0 -20
  164. package/src/components/table-pagination/index.ts +0 -2
  165. package/src/components/table-pagination/table-pagination.model.ts +0 -2
  166. package/src/components/table-pagination/table-pagination.stories.tsx +0 -23
  167. package/src/components/table-pagination/table-pagination.test.tsx +0 -32
  168. package/src/components/table-pagination/table-pagination.tsx +0 -108
  169. package/src/components/tabs/context/tabs-context.tsx +0 -14
  170. package/src/components/tag/tag.variants.ts +0 -31
  171. package/src/components/timeline/timeline-status.ts +0 -5
  172. package/src/components/tree/hooks/use-controllable-tree-state.ts +0 -80
  173. package/src/components/tree/tree-primitives.tsx +0 -126
@@ -0,0 +1,156 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import { Inbox } from "lucide-react";
3
+ import { type ComponentProps, type ReactNode } from "react";
4
+ import { cn } from "../../lib";
5
+
6
+ const emptyMediaVariants = cva(
7
+ "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default:
12
+ "bg-transparent text-muted-foreground [&_.lucide:not([class*='size-'])]:size-12",
13
+ icon: "size-8 rounded-lg bg-muted text-foreground [&_.lucide:not([class*='size-'])]:size-4",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ },
20
+ );
21
+
22
+ export function EmptyRoot({ className, ...props }: ComponentProps<"div">) {
23
+ return (
24
+ <div
25
+ className={cn(
26
+ "flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl p-6 text-center text-balance",
27
+ className,
28
+ )}
29
+ data-slot="empty"
30
+ {...props}
31
+ />
32
+ );
33
+ }
34
+
35
+ export function EmptyHeader({ className, ...props }: ComponentProps<"div">) {
36
+ return (
37
+ <div
38
+ className={cn("flex max-w-sm flex-col items-center gap-2", className)}
39
+ data-slot="empty-header"
40
+ {...props}
41
+ />
42
+ );
43
+ }
44
+
45
+ export function EmptyMedia({
46
+ className,
47
+ variant,
48
+ ...props
49
+ }: ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
50
+ return (
51
+ <div
52
+ className={cn(emptyMediaVariants({ variant }), className)}
53
+ data-slot="empty-media"
54
+ data-variant={variant ?? "default"}
55
+ {...props}
56
+ />
57
+ );
58
+ }
59
+
60
+ export function EmptyTitle({ className, ...props }: ComponentProps<"div">) {
61
+ return (
62
+ <div
63
+ data-slot="empty-title"
64
+ className={cn(
65
+ "cn-font-heading text-sm font-medium tracking-tight",
66
+ className,
67
+ )}
68
+ {...props}
69
+ />
70
+ );
71
+ }
72
+
73
+ export function EmptyDescription({
74
+ className,
75
+ ...props
76
+ }: ComponentProps<"div">) {
77
+ return (
78
+ <div
79
+ className={cn(
80
+ "text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
81
+ className,
82
+ )}
83
+ data-slot="empty-description"
84
+ {...props}
85
+ />
86
+ );
87
+ }
88
+
89
+ export function EmptyContent({ className, ...props }: ComponentProps<"div">) {
90
+ return (
91
+ <div
92
+ className={cn(
93
+ "flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5",
94
+ className,
95
+ )}
96
+ data-slot="empty-content"
97
+ {...props}
98
+ />
99
+ );
100
+ }
101
+
102
+ interface EmptyProps extends Omit<ComponentProps<typeof EmptyRoot>, "title"> {
103
+ icon?: ReactNode;
104
+ iconVariant?: VariantProps<typeof emptyMediaVariants>["variant"];
105
+ title?: ReactNode;
106
+ description?: ReactNode;
107
+ action?: ReactNode;
108
+ /** Styles applied to each internal slot. */
109
+ classNames?: {
110
+ /** Wrapper around media, title and description. */
111
+ header?: string;
112
+ /** Box that frames the icon (chip when `iconVariant="icon"`). */
113
+ media?: string;
114
+ /** Title text. */
115
+ title?: string;
116
+ /** Description text below the title. */
117
+ description?: string;
118
+ /** Wrapper around the `action` slot. */
119
+ content?: string;
120
+ };
121
+ }
122
+
123
+ export function Empty({
124
+ icon = <Inbox />,
125
+ iconVariant = "default",
126
+ title = "No hay datos",
127
+ description,
128
+ action,
129
+ children,
130
+ classNames,
131
+ ...props
132
+ }: EmptyProps) {
133
+ return (
134
+ <EmptyRoot {...props}>
135
+ <EmptyHeader className={classNames?.header}>
136
+ {icon && (
137
+ <EmptyMedia variant={iconVariant} className={classNames?.media}>
138
+ {icon}
139
+ </EmptyMedia>
140
+ )}
141
+ {title && (
142
+ <EmptyTitle className={classNames?.title}>{title}</EmptyTitle>
143
+ )}
144
+ {description && (
145
+ <EmptyDescription className={classNames?.description}>
146
+ {description}
147
+ </EmptyDescription>
148
+ )}
149
+ </EmptyHeader>
150
+ {action != null && (
151
+ <EmptyContent className={classNames?.content}>{action}</EmptyContent>
152
+ )}
153
+ {children}
154
+ </EmptyRoot>
155
+ );
156
+ }
@@ -0,0 +1 @@
1
+ export * from "./empty";
@@ -1,5 +1,8 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { Button } from "../button/button";
2
4
  import { CheckboxIndicator, CheckboxRoot } from "../checkbox/checkbox";
5
+ import { Form } from "../form/form";
3
6
  import { Input } from "../input/input";
4
7
  import { SwitchRoot, SwitchThumb } from "../switch/switch";
5
8
  import { Textarea } from "../textarea/textarea";
@@ -12,6 +15,7 @@ import {
12
15
  FieldLabel,
13
16
  FieldRoot,
14
17
  FieldValidity,
18
+ useFieldName,
15
19
  } from "./field";
16
20
 
17
21
  /**
@@ -48,9 +52,7 @@ const meta: Meta<typeof Field> = {
48
52
  },
49
53
  argTypes: {
50
54
  children: { control: false },
51
- labelProps: { control: false },
52
- descriptionProps: { control: false },
53
- errorProps: { control: false },
55
+ classNames: { control: false },
54
56
  error: { control: false },
55
57
  },
56
58
  };
@@ -60,6 +62,21 @@ type Story = StoryObj<typeof Field>;
60
62
 
61
63
  export const Default: Story = {};
62
64
 
65
+ /**
66
+ * `className` estila el root del Field. `classNames` expone los slots
67
+ * `label`, `description` y `error` para personalizar cada parte interna.
68
+ */
69
+ export const WithClassNames: Story = {
70
+ args: {
71
+ error: "Enter a valid email address.",
72
+ classNames: {
73
+ label: "text-primary",
74
+ description: "italic",
75
+ error: "font-mono",
76
+ },
77
+ },
78
+ };
79
+
63
80
  export const Required: Story = {
64
81
  args: {
65
82
  children: <Input type="email" required placeholder="you@example.com" />,
@@ -221,3 +238,209 @@ export const Primitive: Story = {
221
238
  </FieldRoot>
222
239
  ),
223
240
  };
241
+
242
+ // ── Custom control helpers ────────────────────────────────────────────────────
243
+
244
+ const TAG_CLASSES =
245
+ "inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs select-none";
246
+
247
+ function TagChips({
248
+ items,
249
+ onRemove,
250
+ }: {
251
+ items: string[];
252
+ onRemove: (item: string) => void;
253
+ }) {
254
+ if (!items.length) return null;
255
+ return (
256
+ <div className="flex flex-wrap gap-1.5">
257
+ {items.map((item) => (
258
+ <span key={item} className={TAG_CLASSES}>
259
+ {item}
260
+ <button
261
+ type="button"
262
+ onClick={() => onRemove(item)}
263
+ className="text-muted-foreground hover:text-foreground leading-none"
264
+ aria-label={`Remove ${item}`}
265
+ >
266
+ ×
267
+ </button>
268
+ </span>
269
+ ))}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ /**
275
+ * Approach 1 — `FieldControl` + hidden input (public API).
276
+ * `FieldControl` registers the element with Field and exposes an `inputRef`
277
+ * pointing to a real DOM input. The serialized value flows through that input
278
+ * so `Field.validate` receives it as a string — parse it back inside validate.
279
+ */
280
+ function TagInputHidden() {
281
+ const [items, setItems] = useState<string[]>([]);
282
+ const [draft, setDraft] = useState("");
283
+
284
+ function add() {
285
+ const tag = draft.trim();
286
+ if (tag && !items.includes(tag)) {
287
+ setItems((prev) => [...prev, tag]);
288
+ setDraft("");
289
+ }
290
+ }
291
+
292
+ return (
293
+ <div className="flex flex-col gap-2">
294
+ {/* Hidden input = what FieldControl registers as the field control.
295
+ value keeps it in sync so validate() receives the serialized list. */}
296
+ <FieldControl
297
+ render={<input type="hidden" />}
298
+ value={JSON.stringify(items)}
299
+ />
300
+ <TagChips
301
+ items={items}
302
+ onRemove={(t) => setItems((p) => p.filter((i) => i !== t))}
303
+ />
304
+ <div className="flex gap-2">
305
+ <Input
306
+ value={draft}
307
+ onChange={(value) => setDraft(value)}
308
+ onKeyDown={(e) => {
309
+ if (e.key === "Enter") {
310
+ e.preventDefault();
311
+ add();
312
+ }
313
+ }}
314
+ placeholder="Add a tag…"
315
+ />
316
+ <Button type="button" variant="outline" size="sm" onClick={add}>
317
+ Add
318
+ </Button>
319
+ </div>
320
+ </div>
321
+ );
322
+ }
323
+
324
+ /**
325
+ * Approach 2 — native hidden input + form-level validation.
326
+ * The component renders a `<input type="hidden">` for native `FormData`
327
+ * participation. `useFieldName` reads the `name` from the parent `Field`
328
+ * context automatically. Validation runs in `Form.onFormSubmit` and errors
329
+ * route back via `Form.errors` — no `Field.validate` needed.
330
+ */
331
+ function TagInputNative() {
332
+ const [items, setItems] = useState<string[]>([]);
333
+ const [draft, setDraft] = useState("");
334
+ const fieldName = useFieldName();
335
+
336
+ function add() {
337
+ const tag = draft.trim();
338
+ if (tag && !items.includes(tag)) {
339
+ setItems((prev) => [...prev, tag]);
340
+ setDraft("");
341
+ }
342
+ }
343
+
344
+ return (
345
+ <div className="flex flex-col gap-2">
346
+ {/* Hidden input participates in FormData under the Field's name.
347
+ No FieldControl needed — validation is handled at the form level. */}
348
+ <input type="hidden" name={fieldName} value={JSON.stringify(items)} />
349
+ <TagChips
350
+ items={items}
351
+ onRemove={(t) => setItems((p) => p.filter((i) => i !== t))}
352
+ />
353
+ <div className="flex gap-2">
354
+ <Input
355
+ value={draft}
356
+ onChange={(value) => setDraft(value)}
357
+ onKeyDown={(e) => {
358
+ if (e.key === "Enter") {
359
+ e.preventDefault();
360
+ add();
361
+ }
362
+ }}
363
+ placeholder="Add a tag…"
364
+ />
365
+ <Button type="button" variant="outline" size="sm" onClick={add}>
366
+ Add
367
+ </Button>
368
+ </div>
369
+ </div>
370
+ );
371
+ }
372
+
373
+ // ── Custom control stories ────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * **Approach 1 — `FieldControl` + `Field.validate` (per-field validation).**
377
+ * `FieldControl render={<input type="hidden" />}` registers a hidden input as
378
+ * the field control. Pass `value={JSON.stringify(state)}` to keep it in sync.
379
+ * `Field.validate` receives the serialized string — parse it back to validate.
380
+ *
381
+ * Best for: inline async validation, per-field error display, custom rules
382
+ * that run outside of `onFormSubmit`.
383
+ */
384
+ export const CustomControlHiddenInput: Story = {
385
+ render: () => (
386
+ <Form className="flex w-80 flex-col gap-4">
387
+ <Field
388
+ name="tags"
389
+ label="Tags"
390
+ description="1–3 tags. Submit to validate."
391
+ validate={(value) => {
392
+ const tags = JSON.parse(String(value || "[]")) as string[];
393
+ if (!tags.length) return "Add at least one tag.";
394
+ if (tags.length > 3) return "Max 3 tags.";
395
+ return null;
396
+ }}
397
+ >
398
+ <TagInputHidden />
399
+ </Field>
400
+ <Button type="submit">Submit</Button>
401
+ </Form>
402
+ ),
403
+ };
404
+
405
+ /**
406
+ * **Approach 2 — native hidden input + `Form.onFormSubmit` (form-level validation).**
407
+ * The component renders `<input type="hidden" name={fieldName}>` — no
408
+ * `FieldControl` needed. `useFieldName` reads the name from parent `Field`
409
+ * context. Validation runs in `onFormSubmit`; errors route back via `Form.errors`.
410
+ *
411
+ * Best for: synchronous validation, cross-field rules, or when you want to
412
+ * keep the component decoupled from Field's validation lifecycle.
413
+ */
414
+ export const CustomControlNativeInput: Story = {
415
+ render: () => {
416
+ const [errors, setErrors] = useState<Record<string, string>>({});
417
+
418
+ return (
419
+ <Form
420
+ className="flex w-80 flex-col gap-4"
421
+ errors={errors}
422
+ onFormSubmit={(data) => {
423
+ const tags = JSON.parse(String(data.tags || "[]")) as string[];
424
+ if (!tags.length) {
425
+ setErrors({ tags: "Add at least one tag." });
426
+ return;
427
+ }
428
+ if (tags.length > 3) {
429
+ setErrors({ tags: "Max 3 tags." });
430
+ return;
431
+ }
432
+ setErrors({});
433
+ }}
434
+ >
435
+ <Field
436
+ name="tags"
437
+ label="Tags"
438
+ description="1–3 tags. Submit to validate."
439
+ >
440
+ <TagInputNative />
441
+ </Field>
442
+ <Button type="submit">Submit</Button>
443
+ </Form>
444
+ );
445
+ },
446
+ };
@@ -1,5 +1,6 @@
1
1
  import { Field as FieldPrimitive } from "@base-ui/react/field";
2
- import { type ReactNode } from "react";
2
+ import { Fieldset as FieldsetPrimitive } from "@base-ui/react/fieldset";
3
+ import { createContext, type ReactNode, useContext } from "react";
3
4
  import { cn } from "../../lib";
4
5
  import { Label } from "../label";
5
6
 
@@ -16,18 +17,34 @@ export type FieldErrorProp = string | FieldErrorItem | FieldErrorItem[];
16
17
 
17
18
  // ── Primitives ────────────────────────────────────────────────────────────────
18
19
 
20
+ const FieldNameContext = createContext<string | undefined>(undefined);
21
+ export function useFieldName(): string | undefined {
22
+ return useContext(FieldNameContext);
23
+ }
24
+
25
+ const FieldRootPresentContext = createContext(false);
26
+ export function useIsInsideFieldRoot(): boolean {
27
+ return useContext(FieldRootPresentContext);
28
+ }
29
+
19
30
  export function FieldRoot({
20
31
  className,
21
32
  required,
33
+ name,
22
34
  ...props
23
35
  }: FieldPrimitive.Root.Props & { required?: boolean }) {
24
36
  return (
25
- <FieldPrimitive.Root
26
- className={cn("group flex flex-col gap-1", className)}
27
- data-slot="field"
28
- data-required={required || undefined}
29
- {...props}
30
- />
37
+ <FieldRootPresentContext.Provider value={true}>
38
+ <FieldNameContext.Provider value={name}>
39
+ <FieldPrimitive.Root
40
+ name={name}
41
+ className={cn("group flex flex-col gap-1", className)}
42
+ data-slot="field"
43
+ data-required={required || undefined}
44
+ {...props}
45
+ />
46
+ </FieldNameContext.Provider>
47
+ </FieldRootPresentContext.Provider>
31
48
  );
32
49
  }
33
50
 
@@ -91,9 +108,37 @@ export const FieldControl: typeof FieldPrimitive.Control =
91
108
  export const FieldValidity: typeof FieldPrimitive.Validity =
92
109
  FieldPrimitive.Validity;
93
110
 
111
+ export function FieldSet({
112
+ className,
113
+ ...props
114
+ }: FieldsetPrimitive.Root.Props) {
115
+ return (
116
+ <FieldsetPrimitive.Root
117
+ className={cn("flex flex-col gap-3", className)}
118
+ data-slot="fieldset"
119
+ {...props}
120
+ />
121
+ );
122
+ }
123
+
124
+ export function FieldLegend({
125
+ className,
126
+ ...props
127
+ }: FieldsetPrimitive.Legend.Props) {
128
+ return (
129
+ <FieldsetPrimitive.Legend
130
+ className={cn("text-sm font-medium leading-none", className)}
131
+ data-slot="fieldset-legend"
132
+ {...props}
133
+ />
134
+ );
135
+ }
136
+
94
137
  // ── Composite ─────────────────────────────────────────────────────────────────
95
138
 
96
- function normalizeError(error: FieldErrorProp | undefined): FieldErrorItem[] {
139
+ export function normalizeError(
140
+ error: FieldErrorProp | undefined,
141
+ ): FieldErrorItem[] {
97
142
  if (error === undefined) return [];
98
143
  if (typeof error === "string") return [{ message: error, match: true }];
99
144
  if (Array.isArray(error)) return error;
@@ -123,13 +168,15 @@ export interface FieldProps
123
168
  */
124
169
  inline?: boolean;
125
170
  children?: ReactNode;
126
- // Escape hatches for each sub-component
127
- labelProps?: FieldPrimitive.Label.Props;
128
- labelClassName?: string;
129
- descriptionProps?: FieldPrimitive.Description.Props;
130
- descriptionClassName?: string;
131
- errorProps?: Omit<FieldPrimitive.Error.Props, "match" | "children">;
132
- errorClassName?: string;
171
+ /** Styles applied to each internal slot. */
172
+ classNames?: {
173
+ /** Label rendered above (or beside, when `inline`) the control. */
174
+ label?: string;
175
+ /** Helper text below the control. */
176
+ description?: string;
177
+ /** Error message rendered when the field is invalid. */
178
+ error?: string;
179
+ };
133
180
  }
134
181
 
135
182
  export function Field({
@@ -141,12 +188,7 @@ export function Field({
141
188
  inline = false,
142
189
  children,
143
190
  className,
144
- labelProps,
145
- labelClassName,
146
- descriptionProps,
147
- descriptionClassName,
148
- errorProps,
149
- errorClassName,
191
+ classNames,
150
192
  invalid,
151
193
  ...rootProps
152
194
  }: FieldProps) {
@@ -158,8 +200,7 @@ export function Field({
158
200
  const labelNode = label && (
159
201
  <FieldLabel
160
202
  render={tooltip ? <Label tooltip={tooltip} /> : undefined}
161
- className={labelClassName}
162
- {...labelProps}
203
+ className={classNames?.label}
163
204
  >
164
205
  <span className="after:text-error group-has-[*:required]:after:content-['*'] group-data-[required]:after:content-['*']">
165
206
  {label}
@@ -186,36 +227,30 @@ export function Field({
186
227
  </>
187
228
  )}
188
229
  {description && (
189
- <FieldDescription
190
- className={descriptionClassName}
191
- {...descriptionProps}
192
- >
230
+ <FieldDescription className={classNames?.description}>
193
231
  {description}
194
232
  </FieldDescription>
195
233
  )}
196
234
  {errors.map((item, i) => (
197
- <FieldError
198
- key={i}
199
- match={item.match}
200
- className={errorClassName}
201
- {...errorProps}
202
- >
235
+ <FieldError key={i} match={item.match} className={classNames?.error}>
203
236
  {item.message}
204
237
  </FieldError>
205
238
  ))}
206
- {/* Catch-all for server errors propagated via `Form.errors` (React context,
207
- not `setCustomValidity`). When all user errors have a `match`, none of
208
- them fire for server-side errors render a plain `FieldError` guarded
209
- by `FieldValidity` so it only shows when the field is invalid AND native
210
- validation passed (validity.error === ""). */}
239
+ {/* Catch-all for errors not covered by any specific `match`:
240
+ - Server errors via `Form.errors`: field marked invalid externally,
241
+ validityData.error stays "" caught by `error === ""`
242
+ - `Field.validate` errors: setCustomValidity(msg) customError = true,
243
+ error = msg caught by `validity.customError`
244
+ Native constraint failures have customError = false and are handled by
245
+ their specific `match` items, so this won't double-render them. */}
211
246
  {!errors.some((item) => item.match === undefined) &&
212
247
  (errors.length === 0 ? (
213
- <FieldError {...errorProps} />
248
+ <FieldError className={classNames?.error} />
214
249
  ) : (
215
250
  <FieldValidity>
216
251
  {({ validity, error }) =>
217
- !validity.valid && error === "" ? (
218
- <FieldError {...errorProps} />
252
+ !validity.valid && (error === "" || validity.customError) ? (
253
+ <FieldError className={classNames?.error} />
219
254
  ) : null
220
255
  }
221
256
  </FieldValidity>
@@ -226,4 +261,4 @@ export function Field({
226
261
 
227
262
  // ── Primitive escape hatch ────────────────────────────────────────────────────
228
263
 
229
- export { FieldPrimitive };
264
+ export { FieldPrimitive, FieldsetPrimitive };