@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.
- package/dist/index.cjs.js +1 -138
- package/dist/index.d.ts +1083 -715
- package/dist/index.es.js +7077 -56175
- 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 +119 -103
- 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 +6 -0
- 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 +226 -3
- 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 +25 -29
- 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 +26 -10
- 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 -451
- 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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 {
|
|
248
|
+
<FieldError className={classNames?.error} />
|
|
214
249
|
) : (
|
|
215
250
|
<FieldValidity>
|
|
216
251
|
{({ validity, error }) =>
|
|
217
|
-
!validity.valid && error === "" ? (
|
|
218
|
-
<FieldError {
|
|
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 };
|