@shadow-js/ui 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/dist/cli.mjs +2293 -0
- package/dist/dev/cli/schema.d.ts +73 -0
- package/dist/dev/index.d.ts +6 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/locales/ar.d.ts +141 -0
- package/dist/dev/locales/ar.js +128 -0
- package/dist/dev/locales/ar.js.map +1 -0
- package/dist/dev/locales/en.d.ts +141 -0
- package/dist/dev/locales/en.js +128 -0
- package/dist/dev/locales/en.js.map +1 -0
- package/dist/dev/registry/utils/utils.d.ts +29 -0
- package/dist/dev/registry/utils/utils.js +27 -0
- package/dist/dev/registry/utils/utils.js.map +1 -0
- package/dist/dev/theme/theme-logic.d.ts +187 -0
- package/dist/dev/theme/theme-logic.js +184 -0
- package/dist/dev/theme/theme-logic.js.map +1 -0
- package/dist/dev/theme/theme-provider.d.ts +90 -0
- package/dist/dev/theme/theme-provider.js +150 -0
- package/dist/dev/theme/theme-provider.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/locales/ar.js +2 -0
- package/dist/prod/locales/ar.js.map +1 -0
- package/dist/prod/locales/en.js +2 -0
- package/dist/prod/locales/en.js.map +1 -0
- package/dist/prod/registry/utils/utils.js +2 -0
- package/dist/prod/registry/utils/utils.js.map +1 -0
- package/dist/prod/theme/theme-logic.js +2 -0
- package/dist/prod/theme/theme-logic.js.map +1 -0
- package/dist/prod/theme/theme-provider.js +2 -0
- package/dist/prod/theme/theme-provider.js.map +1 -0
- package/package.json +102 -4
- package/registry/accordion/accordion.tsx +210 -0
- package/registry/accordion/item.json +4 -0
- package/registry/alert/alert.tsx +200 -0
- package/registry/alert/item.json +4 -0
- package/registry/alert-dialog/alert-dialog.tsx +530 -0
- package/registry/alert-dialog/item.json +4 -0
- package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
- package/registry/aspect-ratio/item.json +4 -0
- package/registry/attachment/attachment.tsx +459 -0
- package/registry/attachment/item.json +4 -0
- package/registry/avatar/avatar.tsx +385 -0
- package/registry/avatar/item.json +4 -0
- package/registry/badge/badge.tsx +108 -0
- package/registry/badge/item.json +4 -0
- package/registry/breadcrumb/breadcrumb.tsx +354 -0
- package/registry/breadcrumb/item.json +4 -0
- package/registry/bubble/bubble.tsx +260 -0
- package/registry/bubble/item.json +4 -0
- package/registry/button/button.tsx +125 -0
- package/registry/button/item.json +4 -0
- package/registry/button-group/button-group.tsx +189 -0
- package/registry/button-group/item.json +4 -0
- package/registry/calendar/calendar.tsx +348 -0
- package/registry/calendar/item.json +4 -0
- package/registry/card/card.tsx +311 -0
- package/registry/card/item.json +4 -0
- package/registry/carousel/carousel.tsx +851 -0
- package/registry/carousel/item.json +4 -0
- package/registry/chart/chart.tsx +1956 -0
- package/registry/chart/item.json +4 -0
- package/registry/chart-scale/chart-scale.ts +670 -0
- package/registry/chart-scale/item.json +5 -0
- package/registry/checkbox/checkbox.tsx +79 -0
- package/registry/checkbox/item.json +4 -0
- package/registry/code-block/code-block.tsx +470 -0
- package/registry/code-block/item.json +4 -0
- package/registry/collapsible/collapsible.tsx +117 -0
- package/registry/collapsible/item.json +4 -0
- package/registry/combobox/combobox.tsx +977 -0
- package/registry/combobox/item.json +4 -0
- package/registry/command/command.tsx +757 -0
- package/registry/command/item.json +4 -0
- package/registry/context-menu/context-menu.tsx +697 -0
- package/registry/context-menu/item.json +4 -0
- package/registry/data-table/data-table.tsx +437 -0
- package/registry/data-table/item.json +4 -0
- package/registry/date-picker/date-picker.tsx +349 -0
- package/registry/date-picker/item.json +4 -0
- package/registry/dialog/dialog.tsx +412 -0
- package/registry/dialog/item.json +4 -0
- package/registry/direction/direction.tsx +88 -0
- package/registry/direction/item.json +4 -0
- package/registry/drawer/drawer.tsx +787 -0
- package/registry/drawer/item.json +4 -0
- package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
- package/registry/dropdown-menu/item.json +4 -0
- package/registry/empty/empty.tsx +240 -0
- package/registry/empty/item.json +4 -0
- package/registry/field/field.tsx +624 -0
- package/registry/field/item.json +4 -0
- package/registry/form/form.tsx +1112 -0
- package/registry/form/item.json +4 -0
- package/registry/hover-card/hover-card.tsx +144 -0
- package/registry/hover-card/item.json +4 -0
- package/registry/icons/icons.tsx +1691 -0
- package/registry/icons/item.json +4 -0
- package/registry/input/input.tsx +43 -0
- package/registry/input/item.json +4 -0
- package/registry/input-group/input-group.tsx +315 -0
- package/registry/input-group/item.json +4 -0
- package/registry/input-otp/input-otp.tsx +329 -0
- package/registry/input-otp/item.json +4 -0
- package/registry/item/item.json +4 -0
- package/registry/item/item.tsx +439 -0
- package/registry/kbd/item.json +4 -0
- package/registry/kbd/kbd.tsx +65 -0
- package/registry/label/item.json +4 -0
- package/registry/label/label.tsx +60 -0
- package/registry/marker/item.json +4 -0
- package/registry/marker/marker.tsx +175 -0
- package/registry/menubar/item.json +4 -0
- package/registry/menubar/menubar.tsx +694 -0
- package/registry/message/item.json +4 -0
- package/registry/message/message.tsx +280 -0
- package/registry/message-scroller/item.json +4 -0
- package/registry/message-scroller/message-scroller.tsx +262 -0
- package/registry/native-select/item.json +4 -0
- package/registry/native-select/native-select.tsx +113 -0
- package/registry/navigation-menu/item.json +4 -0
- package/registry/navigation-menu/navigation-menu.tsx +362 -0
- package/registry/pagination/item.json +4 -0
- package/registry/pagination/pagination.tsx +460 -0
- package/registry/password-input/item.json +5 -0
- package/registry/password-input/password-input.tsx +494 -0
- package/registry/popover/item.json +4 -0
- package/registry/popover/popover.tsx +284 -0
- package/registry/progress/item.json +4 -0
- package/registry/progress/progress.tsx +190 -0
- package/registry/questionnaire/item.json +4 -0
- package/registry/questionnaire/questionnaire.tsx +569 -0
- package/registry/radio-group/item.json +4 -0
- package/registry/radio-group/radio-group.tsx +101 -0
- package/registry/resizable/item.json +4 -0
- package/registry/resizable/resizable.tsx +455 -0
- package/registry/scroll-area/item.json +4 -0
- package/registry/scroll-area/scroll-area.tsx +149 -0
- package/registry/select/item.json +4 -0
- package/registry/select/select.tsx +571 -0
- package/registry/separator/item.json +4 -0
- package/registry/separator/separator.tsx +52 -0
- package/registry/sheet/item.json +4 -0
- package/registry/sheet/sheet.tsx +476 -0
- package/registry/sidebar/item.json +4 -0
- package/registry/sidebar/sidebar.tsx +1870 -0
- package/registry/skeleton/item.json +4 -0
- package/registry/skeleton/skeleton.tsx +47 -0
- package/registry/slider/item.json +4 -0
- package/registry/slider/slider.tsx +149 -0
- package/registry/spinner/item.json +4 -0
- package/registry/spinner/spinner.tsx +51 -0
- package/registry/switch/item.json +4 -0
- package/registry/switch/switch.tsx +93 -0
- package/registry/table/item.json +4 -0
- package/registry/table/table.tsx +373 -0
- package/registry/tabs/item.json +4 -0
- package/registry/tabs/tabs.tsx +209 -0
- package/registry/textarea/item.json +4 -0
- package/registry/textarea/textarea.tsx +51 -0
- package/registry/toast/item.json +4 -0
- package/registry/toast/toast.tsx +949 -0
- package/registry/toggle/item.json +4 -0
- package/registry/toggle/toggle.tsx +110 -0
- package/registry/toggle-group/item.json +4 -0
- package/registry/toggle-group/toggle-group.tsx +189 -0
- package/registry/tooltip/item.json +4 -0
- package/registry/tooltip/tooltip.tsx +169 -0
- package/registry/trace/item.json +4 -0
- package/registry/trace/trace.tsx +356 -0
- package/registry/ui-strings/item.json +5 -0
- package/registry/ui-strings/ui-strings.tsx +454 -0
- package/registry/use-highlighted-code/item.json +5 -0
- package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
- package/registry/use-mobile/item.json +5 -0
- package/registry/use-mobile/use-mobile.ts +46 -0
- package/registry/utils/item.json +5 -0
- package/registry/utils/utils.ts +38 -0
- package/registry.json +3274 -0
- package/styles/animations.css +321 -0
- package/styles/theme-core.css +55 -0
- package/styles/theme.css +12 -0
- package/styles/themes/gray.css +69 -0
- package/styles/themes/neutral.css +69 -0
- package/styles/themes/slate.css +69 -0
- package/styles/themes/stone.css +69 -0
- package/styles/themes/zinc.css +69 -0
- package/styles/typeset.css +251 -0
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Accessor,
|
|
3
|
+
onMount,
|
|
4
|
+
splitProps,
|
|
5
|
+
untrack,
|
|
6
|
+
useEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useSignal,
|
|
9
|
+
} from "@shadow-js/core";
|
|
10
|
+
import {
|
|
11
|
+
createPartContext,
|
|
12
|
+
mergeElementProps,
|
|
13
|
+
type PartContext,
|
|
14
|
+
type PartRegistration,
|
|
15
|
+
Polymorphic,
|
|
16
|
+
type PolymorphicProps,
|
|
17
|
+
type PolymorphicTarget,
|
|
18
|
+
partId,
|
|
19
|
+
useBaseId,
|
|
20
|
+
usePartRegistration,
|
|
21
|
+
useRefSignal,
|
|
22
|
+
} from "@shadow-js/primitives";
|
|
23
|
+
import { type ComponentProps, For, type JSX, Show } from "@shadow-js/web";
|
|
24
|
+
import { Label, type LabelProps } from "@/components/ui/label";
|
|
25
|
+
import { useUiStrings } from "@/lib/ui-strings";
|
|
26
|
+
import { type ClassValue, cn } from "@/lib/utils";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A value or an accessor of it. Form libraries hand out either; the form components read both the
|
|
30
|
+
* same way.
|
|
31
|
+
* @since 1.0.0
|
|
32
|
+
* @see {@link FormFieldState}
|
|
33
|
+
*/
|
|
34
|
+
export type FormFieldSource<T> = T | (() => T);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a field can report as its error: one message, a list (the first is shown), or nothing.
|
|
38
|
+
* @since 1.0.0
|
|
39
|
+
* @see {@link FormFieldState}
|
|
40
|
+
*/
|
|
41
|
+
export type FormFieldError = string | readonly string[] | null | undefined | false;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The state of one field, as any form library or a hand-written object can supply it. Every member
|
|
45
|
+
* is optional: a field without `error` never shows one, without `touched` its error shows as soon
|
|
46
|
+
* as it exists. `value`, `error`, `touched` and `submitted` may each be an accessor (a signal
|
|
47
|
+
* getter) or a plain value; read inside the components, so accessors stay reactive.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* import { useSignal } from "@shadow-js/core";
|
|
51
|
+
* import type { FormFieldState } from "@/components/ui/form";
|
|
52
|
+
*
|
|
53
|
+
* export function useEmailField() {
|
|
54
|
+
* const [email, setEmail] = useSignal("");
|
|
55
|
+
* const field: FormFieldState<string> = {
|
|
56
|
+
* value: email,
|
|
57
|
+
* error: () => (email().includes("@") ? undefined : "Enter a valid email"),
|
|
58
|
+
* onInput: (event) => setEmail((event.target as HTMLInputElement).value),
|
|
59
|
+
* };
|
|
60
|
+
* return field;
|
|
61
|
+
* }
|
|
62
|
+
* @see {@link FormField}
|
|
63
|
+
* @since 1.0.0
|
|
64
|
+
*/
|
|
65
|
+
export interface FormFieldState<TValue = unknown> {
|
|
66
|
+
/**
|
|
67
|
+
* The current value of the field.
|
|
68
|
+
* @default none
|
|
69
|
+
*/
|
|
70
|
+
readonly value?: FormFieldSource<TValue> | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The current error, if any: one message, a list of messages (the first one is shown), or nothing.
|
|
73
|
+
* @default none
|
|
74
|
+
*/
|
|
75
|
+
readonly error?: FormFieldSource<FormFieldError> | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Whether the user has interacted with the field. An error is shown only once it is `true`, or once
|
|
78
|
+
* the form was submitted.
|
|
79
|
+
* @default none (an error shows at once)
|
|
80
|
+
*/
|
|
81
|
+
readonly touched?: FormFieldSource<boolean | undefined> | undefined;
|
|
82
|
+
/**
|
|
83
|
+
* Whether the form this field belongs to has been submitted. A submit shows every field's error,
|
|
84
|
+
* including a field the user never touched: `submitted: true` shows the error whatever `touched` says.
|
|
85
|
+
* Pass your form library's "was submitted" state; a library that marks every field touched on submit
|
|
86
|
+
* does not need it.
|
|
87
|
+
* @default none
|
|
88
|
+
*/
|
|
89
|
+
readonly submitted?: FormFieldSource<boolean | undefined> | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* Called with the `input` event of the control.
|
|
92
|
+
* @default none
|
|
93
|
+
*/
|
|
94
|
+
readonly onInput?: ((event: Event) => void) | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* Called with the `blur` event of the control.
|
|
97
|
+
* @default none
|
|
98
|
+
*/
|
|
99
|
+
readonly onBlur?: ((event: Event) => void) | undefined;
|
|
100
|
+
/**
|
|
101
|
+
* Called with the `focus` event of the control.
|
|
102
|
+
* @default none
|
|
103
|
+
*/
|
|
104
|
+
readonly onFocus?: ((event: Event) => void) | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Called with the `change` event of a control, for custom parts that listen for it.
|
|
107
|
+
* @default none
|
|
108
|
+
*/
|
|
109
|
+
readonly onChange?: ((event: Event) => void) | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Writes a value, for controls that do not fire `input` events (a select, a checkbox, a date picker
|
|
112
|
+
* built on primitives). {@link FormControl} hands it to `render` as `setValue`.
|
|
113
|
+
* @default none
|
|
114
|
+
*/
|
|
115
|
+
setValue?(value: unknown): void;
|
|
116
|
+
/**
|
|
117
|
+
* Called with the control element when it is rendered, so the form library can move focus to it (to the
|
|
118
|
+
* first invalid control after a failed submit). {@link FormControl} calls it for you.
|
|
119
|
+
* @default none
|
|
120
|
+
*/
|
|
121
|
+
readonly ref?: ((element: Element | null) => void) | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* Whether the field is disabled. {@link FormControl} then disables its control.
|
|
124
|
+
* @default none
|
|
125
|
+
*/
|
|
126
|
+
readonly disabled?: FormFieldSource<boolean | undefined> | undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Reads a value that may be an accessor. */
|
|
130
|
+
function read<T>(source: FormFieldSource<T> | undefined): T | undefined {
|
|
131
|
+
return typeof source === "function" ? (source as () => T)() : source;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The first message of an error, or `undefined` when there is none. */
|
|
135
|
+
function firstMessage(error: FormFieldError): string | undefined {
|
|
136
|
+
if (typeof error === "string") return error === "" ? undefined : error;
|
|
137
|
+
if (Array.isArray(error)) return error.find((message) => message !== "");
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What the parts under a {@link FormField} read from it. */
|
|
142
|
+
interface FormFieldContextValue {
|
|
143
|
+
readonly name: Accessor<string>;
|
|
144
|
+
readonly value: Accessor<unknown>;
|
|
145
|
+
readonly rawError: Accessor<FormFieldError>;
|
|
146
|
+
readonly touched: Accessor<boolean | undefined>;
|
|
147
|
+
readonly submitted: Accessor<boolean | undefined>;
|
|
148
|
+
readonly disabled: Accessor<boolean | undefined>;
|
|
149
|
+
readonly onInput: (event: Event) => void;
|
|
150
|
+
readonly onBlur: (event: Event) => void;
|
|
151
|
+
readonly onFocus: (event: Event) => void;
|
|
152
|
+
readonly onChange: (event: Event) => void;
|
|
153
|
+
readonly setValue: (value: unknown) => void;
|
|
154
|
+
readonly ref: (element: Element | null) => void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** What the parts under a {@link FormItem} read from it. */
|
|
158
|
+
interface FormItemContextValue {
|
|
159
|
+
readonly id: string;
|
|
160
|
+
readonly description: PartRegistration;
|
|
161
|
+
readonly message: PartRegistration;
|
|
162
|
+
/** The label element, kept so the error summary can name the field. */
|
|
163
|
+
readonly setLabel: (element: Element | undefined) => void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What a {@link FormItem} tells the {@link Form} about its field, for the error summary. @internal */
|
|
167
|
+
interface FormRegistration {
|
|
168
|
+
readonly name: Accessor<string>;
|
|
169
|
+
readonly controlId: string;
|
|
170
|
+
readonly label: Accessor<string | undefined>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What the parts under a {@link Form} read from it. @internal */
|
|
174
|
+
interface FormRootContextValue {
|
|
175
|
+
readonly form: Accessor<FormApiLike>;
|
|
176
|
+
readonly register: (registration: FormRegistration) => () => void;
|
|
177
|
+
readonly find: (name: string) => FormRegistration | undefined;
|
|
178
|
+
/** Changes whenever a field registers or leaves, so the summary reads the names again. */
|
|
179
|
+
readonly revision: Accessor<number>;
|
|
180
|
+
/** Tells the field messages that a summary announces the errors, so they need not. */
|
|
181
|
+
readonly summary: PartRegistration;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const formFieldContext: PartContext<FormFieldContextValue> =
|
|
185
|
+
createPartContext<FormFieldContextValue>("FormField");
|
|
186
|
+
const formItemContext: PartContext<FormItemContextValue> =
|
|
187
|
+
createPartContext<FormItemContextValue>("FormItem");
|
|
188
|
+
const formRootContext: PartContext<FormRootContextValue> =
|
|
189
|
+
createPartContext<FormRootContextValue>("Form");
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* One problem of a form, as the error summary lists it: the field path (`""` for the form as a whole) and
|
|
193
|
+
* the text. The `issues()` of a ShadowJS forms form returns these.
|
|
194
|
+
* @see {@link FormApiLike}
|
|
195
|
+
* @since 1.0.0
|
|
196
|
+
*/
|
|
197
|
+
export interface FormIssueLike {
|
|
198
|
+
/** The path of the field the problem belongs to; empty for a problem of the form as a whole. */
|
|
199
|
+
readonly path: string;
|
|
200
|
+
/** The text for the user. */
|
|
201
|
+
readonly message: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* What {@link Form} needs from a form library: the state of a field by name, and a way to submit. A
|
|
206
|
+
* ShadowJS forms form (the result of `useForm`) fits as it is, and so does any object with these
|
|
207
|
+
* members. Only `fieldAt` and `handleSubmit` are required; the rest improve the summary and the focus.
|
|
208
|
+
* @see {@link Form}
|
|
209
|
+
* @since 1.0.0
|
|
210
|
+
*/
|
|
211
|
+
export interface FormApiLike {
|
|
212
|
+
/** The state of one field, by its name (the `name` of a {@link FormField}). */
|
|
213
|
+
fieldAt(path: string): FormFieldState;
|
|
214
|
+
/** Validates and submits. Called with the `submit` event. */
|
|
215
|
+
handleSubmit(event?: Event): unknown;
|
|
216
|
+
/**
|
|
217
|
+
* Attributes for the `<form>`: its `id` and, while a submit runs, `aria-busy`.
|
|
218
|
+
* @default none
|
|
219
|
+
*/
|
|
220
|
+
props?(): {
|
|
221
|
+
readonly id?: string | undefined;
|
|
222
|
+
readonly "aria-busy"?: true | undefined;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* The problems to list in a {@link FormErrorSummary}.
|
|
226
|
+
* @default none
|
|
227
|
+
*/
|
|
228
|
+
issues?(): readonly FormIssueLike[];
|
|
229
|
+
/**
|
|
230
|
+
* How many submits were attempted. The summary shows after the first, and takes focus again on each.
|
|
231
|
+
* @default none
|
|
232
|
+
*/
|
|
233
|
+
submitCount?(): number;
|
|
234
|
+
/**
|
|
235
|
+
* Moves focus to the control of a field. Returns whether a control got focus.
|
|
236
|
+
* @default none
|
|
237
|
+
*/
|
|
238
|
+
focus?(path: string): boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Props of {@link FormField}: the field's name and state, and the items that render it.
|
|
243
|
+
* @see {@link FormField}
|
|
244
|
+
* @since 1.0.0
|
|
245
|
+
*/
|
|
246
|
+
export interface FormFieldProps {
|
|
247
|
+
/**
|
|
248
|
+
* The field's name: the control's `name` attribute, and what `useFormField().name()` returns.
|
|
249
|
+
* @default none (required)
|
|
250
|
+
*/
|
|
251
|
+
readonly name: string;
|
|
252
|
+
/**
|
|
253
|
+
* The field's state (see {@link FormFieldState}). Inside a {@link Form} it defaults to the form's field of
|
|
254
|
+
* this name. Without either the field only supplies ids and the name.
|
|
255
|
+
* @default the field of the surrounding {@link Form}, if any
|
|
256
|
+
*/
|
|
257
|
+
readonly field?: FormFieldState | undefined;
|
|
258
|
+
/**
|
|
259
|
+
* The {@link FormItem}s, or any other parts, of this field.
|
|
260
|
+
* @default none
|
|
261
|
+
*/
|
|
262
|
+
readonly children?: JSX.Element | undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Connects the parts inside it (label, control, description and message) to one field. It renders no
|
|
267
|
+
* element: it gives the field's name and state to the {@link FormItem} and to the parts under it. Pass
|
|
268
|
+
* the state of your form library, or a {@link FormFieldState} of your own, as `field`.
|
|
269
|
+
*
|
|
270
|
+
* Keyboard and ARIA: this is a wiring component and has no keys. The control gets `id`, `name`,
|
|
271
|
+
* `aria-describedby` (the description and the message while they are rendered), `aria-invalid` (while an
|
|
272
|
+
* error shows), `disabled` (while the field is disabled) and the field's `value`, `onInput`, `onBlur`,
|
|
273
|
+
* `onFocus` and `ref`. The label points at the control. The message has `role="alert"` while it shows an
|
|
274
|
+
* error, so a screen reader announces it (unless a {@link FormErrorSummary} does).
|
|
275
|
+
*
|
|
276
|
+
* shadcn/ui differences: this is not tied to react-hook-form. It takes any state that has the members of
|
|
277
|
+
* {@link FormFieldState}, and the parts are `FormItem`, `FormLabel`, `FormControl`, `FormDescription` and
|
|
278
|
+
* `FormMessage` as in shadcn. `as` replaces `asChild` on `FormControl`, and the prop is `class`, not `className`.
|
|
279
|
+
*
|
|
280
|
+
* @param props The field props: see {@link FormFieldProps}.
|
|
281
|
+
* @returns The provider around the children; it renders no element of its own.
|
|
282
|
+
* @example
|
|
283
|
+
* import { useSignal } from "@shadow-js/core";
|
|
284
|
+
* import {
|
|
285
|
+
* FormControl,
|
|
286
|
+
* FormDescription,
|
|
287
|
+
* FormField,
|
|
288
|
+
* FormItem,
|
|
289
|
+
* FormLabel,
|
|
290
|
+
* FormMessage,
|
|
291
|
+
* } from "@/components/ui/form";
|
|
292
|
+
* import { Input } from "@/components/ui/input";
|
|
293
|
+
*
|
|
294
|
+
* export function EmailField() {
|
|
295
|
+
* const [email, setEmail] = useSignal("");
|
|
296
|
+
* return (
|
|
297
|
+
* <FormField
|
|
298
|
+
* name="email"
|
|
299
|
+
* field={{
|
|
300
|
+
* value: email,
|
|
301
|
+
* error: () => (email().includes("@") ? undefined : "Enter a valid email"),
|
|
302
|
+
* onInput: (event) => setEmail((event.target as HTMLInputElement).value),
|
|
303
|
+
* }}
|
|
304
|
+
* >
|
|
305
|
+
* <FormItem>
|
|
306
|
+
* <FormLabel>Email</FormLabel>
|
|
307
|
+
* <FormControl as={Input} type="email" />
|
|
308
|
+
* <FormDescription>We never share it.</FormDescription>
|
|
309
|
+
* <FormMessage />
|
|
310
|
+
* </FormItem>
|
|
311
|
+
* </FormField>
|
|
312
|
+
* );
|
|
313
|
+
* }
|
|
314
|
+
* @see {@link FormItem}
|
|
315
|
+
* @see {@link useFormField}
|
|
316
|
+
* @since 1.0.0
|
|
317
|
+
*/
|
|
318
|
+
function FormField(props: FormFieldProps): JSX.Element {
|
|
319
|
+
const root = formRootContext.useOptional();
|
|
320
|
+
const state = (): FormFieldState | undefined => props.field ?? root?.form().fieldAt(props.name);
|
|
321
|
+
return (
|
|
322
|
+
<formFieldContext.Provider
|
|
323
|
+
value={{
|
|
324
|
+
name: () => props.name,
|
|
325
|
+
value: () => read(state()?.value),
|
|
326
|
+
rawError: () => read(state()?.error),
|
|
327
|
+
touched: () => read(state()?.touched),
|
|
328
|
+
submitted: () => read(state()?.submitted),
|
|
329
|
+
disabled: () => read(state()?.disabled),
|
|
330
|
+
onInput: (event) => state()?.onInput?.(event),
|
|
331
|
+
onBlur: (event) => state()?.onBlur?.(event),
|
|
332
|
+
onFocus: (event) => state()?.onFocus?.(event),
|
|
333
|
+
onChange: (event) => state()?.onChange?.(event),
|
|
334
|
+
setValue: (value) => state()?.setValue?.(value),
|
|
335
|
+
ref: (element) => state()?.ref?.(element),
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
{props.children}
|
|
339
|
+
</formFieldContext.Provider>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The ids and state a form part needs, as returned by {@link useFormField}. Members that change
|
|
345
|
+
* are accessors; call them inside JSX or an effect.
|
|
346
|
+
* @see {@link useFormField}
|
|
347
|
+
* @since 1.0.0
|
|
348
|
+
*/
|
|
349
|
+
export interface FormFieldInfo {
|
|
350
|
+
/** The id of this item; the control's `id` is `formItemId`. */
|
|
351
|
+
readonly id: string;
|
|
352
|
+
/** The field's name. */
|
|
353
|
+
readonly name: Accessor<string>;
|
|
354
|
+
/** The id of the control (the label's `htmlFor` points at it). */
|
|
355
|
+
readonly formItemId: string;
|
|
356
|
+
/** The id of the {@link FormDescription}. */
|
|
357
|
+
readonly formDescriptionId: string;
|
|
358
|
+
/** The id of the {@link FormMessage}. */
|
|
359
|
+
readonly formMessageId: string;
|
|
360
|
+
/** The field's value, as given. */
|
|
361
|
+
readonly value: Accessor<unknown>;
|
|
362
|
+
/** The error to show: the first message, once the field is touched or the form was submitted. */
|
|
363
|
+
readonly error: Accessor<string | undefined>;
|
|
364
|
+
/** Whether an error is showing. */
|
|
365
|
+
readonly invalid: Accessor<boolean>;
|
|
366
|
+
/** The field's `touched` state; `undefined` when the field has none. */
|
|
367
|
+
readonly touched: Accessor<boolean | undefined>;
|
|
368
|
+
/** The field's `submitted` state; `undefined` when the field has none. */
|
|
369
|
+
readonly submitted: Accessor<boolean | undefined>;
|
|
370
|
+
/** The `input` handler to give the control. */
|
|
371
|
+
readonly onInput: (event: Event) => void;
|
|
372
|
+
/** The `blur` handler to give the control. */
|
|
373
|
+
readonly onBlur: (event: Event) => void;
|
|
374
|
+
/** The `focus` handler to give the control. */
|
|
375
|
+
readonly onFocus: (event: Event) => void;
|
|
376
|
+
/** The `change` handler, for parts that listen for `change` events. */
|
|
377
|
+
readonly onChange: (event: Event) => void;
|
|
378
|
+
/** Writes a value, for controls that fire no `input` event. Does nothing if the field has no `setValue`. */
|
|
379
|
+
readonly setValue: (value: unknown) => void;
|
|
380
|
+
/** Registers the control element with the form library, so it can move focus there. */
|
|
381
|
+
readonly ref: (element: Element | null) => void;
|
|
382
|
+
/** Whether the field is disabled. */
|
|
383
|
+
readonly disabled: Accessor<boolean | undefined>;
|
|
384
|
+
/** The `aria-describedby` for the control: the description and the message, when present. */
|
|
385
|
+
readonly describedBy: Accessor<string | undefined>;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Reads the field and the item the calling component sits in, to build your own form parts. Call it
|
|
390
|
+
* inside a {@link FormField} and a {@link FormItem}.
|
|
391
|
+
*
|
|
392
|
+
* @returns The ids, the name, the value, the error and the handlers of the field.
|
|
393
|
+
* @throws Error when called outside a {@link FormField} or a {@link FormItem}.
|
|
394
|
+
* @example
|
|
395
|
+
* import { FormField, FormItem, useFormField } from "@/components/ui/form";
|
|
396
|
+
*
|
|
397
|
+
* function FormHint() {
|
|
398
|
+
* const { invalid } = useFormField();
|
|
399
|
+
* return <span>{invalid() ? "Fix this field" : "Looks good"}</span>;
|
|
400
|
+
* }
|
|
401
|
+
*
|
|
402
|
+
* export function Hinted() {
|
|
403
|
+
* return (
|
|
404
|
+
* <FormField name="email">
|
|
405
|
+
* <FormItem>
|
|
406
|
+
* <FormHint />
|
|
407
|
+
* </FormItem>
|
|
408
|
+
* </FormField>
|
|
409
|
+
* );
|
|
410
|
+
* }
|
|
411
|
+
* @see {@link FormField}
|
|
412
|
+
* @since 1.0.0
|
|
413
|
+
*/
|
|
414
|
+
function useFormField(): FormFieldInfo {
|
|
415
|
+
const field = formFieldContext.use("useFormField");
|
|
416
|
+
const item = formItemContext.use("useFormField");
|
|
417
|
+
// An error hides only while the field says it was not touched and the form was not submitted.
|
|
418
|
+
const error = (): string | undefined =>
|
|
419
|
+
field.touched() === false && field.submitted() !== true
|
|
420
|
+
? undefined
|
|
421
|
+
: firstMessage(field.rawError());
|
|
422
|
+
return {
|
|
423
|
+
id: item.id,
|
|
424
|
+
name: field.name,
|
|
425
|
+
formItemId: `${item.id}-form-item`,
|
|
426
|
+
formDescriptionId: `${item.id}-form-item-description`,
|
|
427
|
+
formMessageId: `${item.id}-form-item-message`,
|
|
428
|
+
value: field.value,
|
|
429
|
+
error,
|
|
430
|
+
invalid: () => error() !== undefined,
|
|
431
|
+
touched: field.touched,
|
|
432
|
+
submitted: field.submitted,
|
|
433
|
+
onInput: field.onInput,
|
|
434
|
+
onBlur: field.onBlur,
|
|
435
|
+
onFocus: field.onFocus,
|
|
436
|
+
onChange: field.onChange,
|
|
437
|
+
setValue: field.setValue,
|
|
438
|
+
ref: field.ref,
|
|
439
|
+
disabled: field.disabled,
|
|
440
|
+
describedBy: () => {
|
|
441
|
+
const ids = [
|
|
442
|
+
item.description.present() ? `${item.id}-form-item-description` : undefined,
|
|
443
|
+
item.message.present() ? `${item.id}-form-item-message` : undefined,
|
|
444
|
+
].filter((id) => id !== undefined);
|
|
445
|
+
return ids.length === 0 ? undefined : ids.join(" ");
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Groups one field's label, control, description and message in a grid, and generates the ids that tie
|
|
452
|
+
* them together. Put it inside a {@link FormField}.
|
|
453
|
+
*
|
|
454
|
+
* @param props The item props: the attributes of the rendered `div` and `class`.
|
|
455
|
+
* @returns The item.
|
|
456
|
+
* @example
|
|
457
|
+
* import { Input } from "@/components/ui/input";
|
|
458
|
+
* import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
|
459
|
+
*
|
|
460
|
+
* export function NameItem() {
|
|
461
|
+
* return (
|
|
462
|
+
* <FormField name="name">
|
|
463
|
+
* <FormItem>
|
|
464
|
+
* <FormLabel>Name</FormLabel>
|
|
465
|
+
* <FormControl as={Input} />
|
|
466
|
+
* </FormItem>
|
|
467
|
+
* </FormField>
|
|
468
|
+
* );
|
|
469
|
+
* }
|
|
470
|
+
* @see {@link FormField}
|
|
471
|
+
* @since 1.0.0
|
|
472
|
+
*/
|
|
473
|
+
function FormItem(props: ComponentProps<"div">): JSX.Element {
|
|
474
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
475
|
+
const id = useBaseId()();
|
|
476
|
+
const description = usePartRegistration();
|
|
477
|
+
const message = usePartRegistration();
|
|
478
|
+
const root = formRootContext.useOptional();
|
|
479
|
+
const field = formFieldContext.useOptional();
|
|
480
|
+
let label: Element | undefined;
|
|
481
|
+
// A field tells its form its name, its control and its label, so the error summary can link to it.
|
|
482
|
+
onMount(() => {
|
|
483
|
+
if (root === undefined || field === undefined) return undefined;
|
|
484
|
+
return root.register({
|
|
485
|
+
name: field.name,
|
|
486
|
+
controlId: `${id}-form-item`,
|
|
487
|
+
label: () => label?.textContent?.trim() || undefined,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
return (
|
|
491
|
+
<formItemContext.Provider
|
|
492
|
+
value={{
|
|
493
|
+
id,
|
|
494
|
+
description,
|
|
495
|
+
message,
|
|
496
|
+
setLabel: (element) => {
|
|
497
|
+
label = element;
|
|
498
|
+
},
|
|
499
|
+
}}
|
|
500
|
+
>
|
|
501
|
+
<div data-slot="form-item" class={cn("grid gap-2", local.class)} {...rest} />
|
|
502
|
+
</formItemContext.Provider>
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The label of the control. It points at the control (`htmlFor` is set for you) and turns destructive
|
|
508
|
+
* while the field shows an error (`data-error="true"`).
|
|
509
|
+
*
|
|
510
|
+
* @param props The label props: see the `Label` component, and `class`.
|
|
511
|
+
* @returns The label.
|
|
512
|
+
* @throws Error when rendered outside a {@link FormField} or a {@link FormItem}.
|
|
513
|
+
* @example
|
|
514
|
+
* import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
|
515
|
+
* import { Input } from "@/components/ui/input";
|
|
516
|
+
*
|
|
517
|
+
* export function EmailItem() {
|
|
518
|
+
* return (
|
|
519
|
+
* <FormField name="email">
|
|
520
|
+
* <FormItem>
|
|
521
|
+
* <FormLabel>Email</FormLabel>
|
|
522
|
+
* <FormControl as={Input} type="email" />
|
|
523
|
+
* </FormItem>
|
|
524
|
+
* </FormField>
|
|
525
|
+
* );
|
|
526
|
+
* }
|
|
527
|
+
* @see {@link FormControl}
|
|
528
|
+
* @since 1.0.0
|
|
529
|
+
*/
|
|
530
|
+
function FormLabel<T extends PolymorphicTarget = "label">(props: LabelProps<T>): JSX.Element {
|
|
531
|
+
const field = useFormField();
|
|
532
|
+
const item = formItemContext.use("FormLabel");
|
|
533
|
+
const [local, rest] = splitProps(props as unknown as { readonly class?: ClassValue }, ["class"]);
|
|
534
|
+
// The label element, kept for the error summary; the caller's own ref still runs.
|
|
535
|
+
const merged = mergeElementProps(
|
|
536
|
+
{ ref: (element: Element | null) => item.setLabel(element ?? undefined) },
|
|
537
|
+
rest,
|
|
538
|
+
);
|
|
539
|
+
return (
|
|
540
|
+
<Label
|
|
541
|
+
data-slot="form-label"
|
|
542
|
+
data-error={field.invalid() ? "true" : undefined}
|
|
543
|
+
htmlFor={field.formItemId}
|
|
544
|
+
class={cn("data-[error=true]:text-destructive", local.class)}
|
|
545
|
+
{...merged}
|
|
546
|
+
/>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* What {@link FormControl} hands to `render` and to the element it renders. Every member is
|
|
552
|
+
* always set by {@link FormControl}, so none of them has a default.
|
|
553
|
+
* @since 1.0.0
|
|
554
|
+
* @see {@link FormControl}
|
|
555
|
+
*/
|
|
556
|
+
export interface FormControlRenderProps {
|
|
557
|
+
/**
|
|
558
|
+
* The control's id, which the label points at.
|
|
559
|
+
* @default none (always set to the item's generated id)
|
|
560
|
+
*/
|
|
561
|
+
readonly id: string;
|
|
562
|
+
/**
|
|
563
|
+
* The field's name.
|
|
564
|
+
* @default none (always set to the `name` of the {@link FormField})
|
|
565
|
+
*/
|
|
566
|
+
readonly name: string;
|
|
567
|
+
/**
|
|
568
|
+
* The description and message ids, when those parts are rendered.
|
|
569
|
+
* @default none
|
|
570
|
+
*/
|
|
571
|
+
readonly "aria-describedby": string | undefined;
|
|
572
|
+
/**
|
|
573
|
+
* `true` while the field shows an error.
|
|
574
|
+
* @default none
|
|
575
|
+
*/
|
|
576
|
+
readonly "aria-invalid": true | undefined;
|
|
577
|
+
/**
|
|
578
|
+
* The field's value, only while it is a string or a number (otherwise `undefined`).
|
|
579
|
+
* @default none
|
|
580
|
+
*/
|
|
581
|
+
readonly value: string | number | undefined;
|
|
582
|
+
/**
|
|
583
|
+
* The `input` handler of the field.
|
|
584
|
+
* @default none (always set; it calls the field's `onInput` when there is one)
|
|
585
|
+
*/
|
|
586
|
+
readonly onInput: (event: Event) => void;
|
|
587
|
+
/**
|
|
588
|
+
* The `blur` handler of the field.
|
|
589
|
+
* @default none (always set; it calls the field's `onBlur` when there is one)
|
|
590
|
+
*/
|
|
591
|
+
readonly onBlur: (event: Event) => void;
|
|
592
|
+
/**
|
|
593
|
+
* The `focus` handler of the field.
|
|
594
|
+
* @default none (always set; it calls the field's `onFocus` when there is one)
|
|
595
|
+
*/
|
|
596
|
+
readonly onFocus: (event: Event) => void;
|
|
597
|
+
/**
|
|
598
|
+
* Registers the control element with the form library, so it can move focus to it. Pass it as the
|
|
599
|
+
* `ref` of your element.
|
|
600
|
+
* @default none (always set; it calls the field's `ref` when there is one)
|
|
601
|
+
*/
|
|
602
|
+
readonly ref: (element: Element | null) => void;
|
|
603
|
+
/**
|
|
604
|
+
* `true` while the field is disabled.
|
|
605
|
+
* @default none
|
|
606
|
+
*/
|
|
607
|
+
readonly disabled: true | undefined;
|
|
608
|
+
/**
|
|
609
|
+
* Writes the field's value, for controls that do not fire `input` events (a select, a checkbox).
|
|
610
|
+
* Does nothing when the field has no `setValue`.
|
|
611
|
+
* @default none (always set)
|
|
612
|
+
*/
|
|
613
|
+
readonly setValue: (value: unknown) => void;
|
|
614
|
+
/**
|
|
615
|
+
* The whole field, as {@link useFormField} returns it: read `field.value()` for a value that is not a
|
|
616
|
+
* string or a number, such as a checkbox's boolean.
|
|
617
|
+
* @default none (always set)
|
|
618
|
+
*/
|
|
619
|
+
readonly field: FormFieldInfo;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Props of {@link FormControl}: the attributes of the rendered element (an `input` unless `as` says
|
|
624
|
+
* otherwise) and `render`.
|
|
625
|
+
* @see {@link FormControl}
|
|
626
|
+
* @since 1.0.0
|
|
627
|
+
*/
|
|
628
|
+
export type FormControlProps<T extends PolymorphicTarget = "input"> = PolymorphicProps<
|
|
629
|
+
"input",
|
|
630
|
+
{
|
|
631
|
+
/**
|
|
632
|
+
* Renders the control yourself. It receives the wiring props (see {@link FormControlRenderProps}),
|
|
633
|
+
* and you place them on your element.
|
|
634
|
+
* @default none
|
|
635
|
+
*/
|
|
636
|
+
readonly render?: ((control: FormControlRenderProps) => JSX.Element) | undefined;
|
|
637
|
+
},
|
|
638
|
+
T
|
|
639
|
+
>;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Wires one control to its field. Pick the element with `as`: an `input` by default, or a component
|
|
643
|
+
* such as the shadcn `Input` or `Textarea`. Or pass `render` to place the wiring props yourself, for a
|
|
644
|
+
* control that is not an `input`. Either way the control gets:
|
|
645
|
+
*
|
|
646
|
+
* - `id` (what the label points at) and `name`,
|
|
647
|
+
* - `aria-describedby` (the description and the message while they are rendered),
|
|
648
|
+
* - `aria-invalid` (while an error shows),
|
|
649
|
+
* - `value` (when the field's value is a string or a number), `onInput`, `onBlur` and `onFocus`,
|
|
650
|
+
* - `disabled` (while the field is disabled) and `ref` (so the form library can focus it).
|
|
651
|
+
*
|
|
652
|
+
* `render` also receives `setValue` and `field`, for controls that do not use `input` events.
|
|
653
|
+
*
|
|
654
|
+
* With `as`, your own props on the element win over these, and your handlers run first and can call
|
|
655
|
+
* `preventDefault()`. Overriding `id` detaches the label, which still points at the generated id.
|
|
656
|
+
*
|
|
657
|
+
* shadcn/ui differences: there is no `asChild`. Use `as`, or `render`, to choose the element.
|
|
658
|
+
*
|
|
659
|
+
* @param props The control props: see {@link FormControlProps}.
|
|
660
|
+
* @returns The control, or what `render` returns.
|
|
661
|
+
* @example
|
|
662
|
+
* import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
|
663
|
+
* import { Input } from "@/components/ui/input";
|
|
664
|
+
*
|
|
665
|
+
* export function Placeholder() {
|
|
666
|
+
* return (
|
|
667
|
+
* <FormField name="email">
|
|
668
|
+
* <FormItem>
|
|
669
|
+
* <FormLabel>Email</FormLabel>
|
|
670
|
+
* <FormControl as={Input} type="email" placeholder="you@example.com" />
|
|
671
|
+
* </FormItem>
|
|
672
|
+
* </FormField>
|
|
673
|
+
* );
|
|
674
|
+
* }
|
|
675
|
+
* @see {@link FormControlRenderProps}
|
|
676
|
+
* @since 1.0.0
|
|
677
|
+
*/
|
|
678
|
+
function FormControl<T extends PolymorphicTarget = "input">(
|
|
679
|
+
props: FormControlProps<T>,
|
|
680
|
+
): JSX.Element {
|
|
681
|
+
const field = useFormField();
|
|
682
|
+
const [local, rest] = splitProps(
|
|
683
|
+
props as unknown as {
|
|
684
|
+
readonly as?: PolymorphicTarget;
|
|
685
|
+
readonly render?: (control: FormControlRenderProps) => JSX.Element;
|
|
686
|
+
readonly children?: JSX.Element;
|
|
687
|
+
},
|
|
688
|
+
["as", "render", "children"],
|
|
689
|
+
);
|
|
690
|
+
const control = mergeElementProps(
|
|
691
|
+
{
|
|
692
|
+
get id() {
|
|
693
|
+
return field.formItemId;
|
|
694
|
+
},
|
|
695
|
+
get name() {
|
|
696
|
+
return field.name();
|
|
697
|
+
},
|
|
698
|
+
get "aria-describedby"() {
|
|
699
|
+
return field.describedBy();
|
|
700
|
+
},
|
|
701
|
+
get "aria-invalid"() {
|
|
702
|
+
return field.invalid() ? true : undefined;
|
|
703
|
+
},
|
|
704
|
+
get value() {
|
|
705
|
+
const value = field.value();
|
|
706
|
+
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
|
707
|
+
},
|
|
708
|
+
get disabled() {
|
|
709
|
+
return field.disabled() === true ? true : undefined;
|
|
710
|
+
},
|
|
711
|
+
onInput: field.onInput,
|
|
712
|
+
onBlur: field.onBlur,
|
|
713
|
+
onFocus: field.onFocus,
|
|
714
|
+
ref: field.ref,
|
|
715
|
+
},
|
|
716
|
+
rest,
|
|
717
|
+
);
|
|
718
|
+
const renderProps = mergeElementProps(control, { setValue: field.setValue, field });
|
|
719
|
+
return (
|
|
720
|
+
<Show
|
|
721
|
+
when={local.render}
|
|
722
|
+
fallback={
|
|
723
|
+
<Polymorphic data-slot="form-control" as={local.as ?? "input"} {...control}>
|
|
724
|
+
{local.children}
|
|
725
|
+
</Polymorphic>
|
|
726
|
+
}
|
|
727
|
+
>
|
|
728
|
+
{(render) => render()(renderProps as unknown as FormControlRenderProps)}
|
|
729
|
+
</Show>
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Helper text under the control. The control's `aria-describedby` names it, so a screen reader reads it
|
|
735
|
+
* with the control.
|
|
736
|
+
*
|
|
737
|
+
* @param props The description props: the attributes of the rendered `p` and `class`.
|
|
738
|
+
* @returns The paragraph.
|
|
739
|
+
* @throws Error when rendered outside a {@link FormField} or a {@link FormItem}.
|
|
740
|
+
* @example
|
|
741
|
+
* import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
|
742
|
+
* import { Input } from "@/components/ui/input";
|
|
743
|
+
*
|
|
744
|
+
* export function UsernameItem() {
|
|
745
|
+
* return (
|
|
746
|
+
* <FormField name="username">
|
|
747
|
+
* <FormItem>
|
|
748
|
+
* <FormLabel>Username</FormLabel>
|
|
749
|
+
* <FormControl as={Input} />
|
|
750
|
+
* <FormDescription>This is your public display name.</FormDescription>
|
|
751
|
+
* </FormItem>
|
|
752
|
+
* </FormField>
|
|
753
|
+
* );
|
|
754
|
+
* }
|
|
755
|
+
* @see {@link FormMessage}
|
|
756
|
+
* @since 1.0.0
|
|
757
|
+
*/
|
|
758
|
+
function FormDescription(props: ComponentProps<"p">): JSX.Element {
|
|
759
|
+
const field = useFormField();
|
|
760
|
+
const item = formItemContext.use("FormDescription");
|
|
761
|
+
item.description.register();
|
|
762
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
763
|
+
return (
|
|
764
|
+
<p
|
|
765
|
+
data-slot="form-description"
|
|
766
|
+
id={field.formDescriptionId}
|
|
767
|
+
class={cn("text-sm text-muted-foreground", local.class)}
|
|
768
|
+
{...rest}
|
|
769
|
+
/>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Whether a message body has anything to show. */
|
|
774
|
+
function hasContent(body: unknown): boolean {
|
|
775
|
+
return body !== undefined && body !== null && body !== false && body !== "";
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** The element of {@link FormMessage}; it exists only while there is a message. */
|
|
779
|
+
function MessageText(props: ComponentProps<"p"> & { readonly body: JSX.Element }): JSX.Element {
|
|
780
|
+
const field = useFormField();
|
|
781
|
+
const item = formItemContext.use("FormMessage");
|
|
782
|
+
const root = formRootContext.useOptional();
|
|
783
|
+
item.message.register();
|
|
784
|
+
const [local, rest] = splitProps(props, ["class", "body"]);
|
|
785
|
+
return (
|
|
786
|
+
<p
|
|
787
|
+
data-slot="form-message"
|
|
788
|
+
id={field.formMessageId}
|
|
789
|
+
role={field.invalid() && root?.summary.present() !== true ? "alert" : undefined}
|
|
790
|
+
class={cn("text-sm text-destructive", local.class)}
|
|
791
|
+
{...rest}
|
|
792
|
+
>
|
|
793
|
+
{local.body}
|
|
794
|
+
</p>
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The message of the field: its first error, once the field is touched or the form was submitted. While
|
|
800
|
+
* there is no error it shows its children, and it renders nothing when it has neither. An error has
|
|
801
|
+
* `role="alert"`, so assistive technology announces it when it appears (unless a {@link FormErrorSummary}
|
|
802
|
+
* in the same {@link Form} announces the errors instead), and the control's `aria-describedby` names it.
|
|
803
|
+
*
|
|
804
|
+
* @param props The message props: the attributes of the rendered `p`, and `children` (shown without an
|
|
805
|
+
* error).
|
|
806
|
+
* @returns The message, or nothing while there is nothing to show.
|
|
807
|
+
* @throws Error when rendered outside a {@link FormField} or a {@link FormItem}.
|
|
808
|
+
* @example
|
|
809
|
+
* import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
|
810
|
+
* import { Input } from "@/components/ui/input";
|
|
811
|
+
*
|
|
812
|
+
* export function EmailMessage() {
|
|
813
|
+
* return (
|
|
814
|
+
* <FormField name="email">
|
|
815
|
+
* <FormItem>
|
|
816
|
+
* <FormLabel>Email</FormLabel>
|
|
817
|
+
* <FormControl as={Input} type="email" />
|
|
818
|
+
* <FormMessage />
|
|
819
|
+
* </FormItem>
|
|
820
|
+
* </FormField>
|
|
821
|
+
* );
|
|
822
|
+
* }
|
|
823
|
+
* @see {@link FormDescription}
|
|
824
|
+
* @since 1.0.0
|
|
825
|
+
*/
|
|
826
|
+
function FormMessage(props: ComponentProps<"p">): JSX.Element {
|
|
827
|
+
const field = useFormField();
|
|
828
|
+
const [local, rest] = splitProps(props, ["children"]);
|
|
829
|
+
const body = (): JSX.Element => (field.error() ?? local.children) as JSX.Element;
|
|
830
|
+
return (
|
|
831
|
+
<Show when={hasContent(body())}>
|
|
832
|
+
<MessageText body={body()} {...rest} />
|
|
833
|
+
</Show>
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Props of {@link Form}: the attributes of a `form`, plus the form from your library.
|
|
839
|
+
* @see {@link Form}
|
|
840
|
+
* @since 1.0.0
|
|
841
|
+
*/
|
|
842
|
+
export type FormProps = ComponentProps<"form"> & {
|
|
843
|
+
/**
|
|
844
|
+
* The form from your form library; the object `useForm` returns in the ShadowJS forms package fits. Its
|
|
845
|
+
* `handleSubmit` runs on submit, and every {@link FormField} inside takes its state from it by name.
|
|
846
|
+
* @default none (required)
|
|
847
|
+
*/
|
|
848
|
+
readonly form: FormApiLike;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The root of a form: a `form` element wired to a form library, the counterpart of shadcn's `Form`
|
|
853
|
+
* (the react-hook-form `FormProvider`). It turns off the browser's own validation, runs the library's
|
|
854
|
+
* `handleSubmit` on submit, and lets every {@link FormField} inside take its state from the form by name,
|
|
855
|
+
* so you pass no `field`. A {@link FormErrorSummary} inside lists the problems of a failed submit.
|
|
856
|
+
*
|
|
857
|
+
* Your own `onSubmit` runs first and may call `preventDefault()` to take over. Without this root, a
|
|
858
|
+
* {@link FormField} still works with a `field` you pass it.
|
|
859
|
+
*
|
|
860
|
+
* Keyboard and ARIA: a native `form`: Enter in a field and a submit button submit it. The `aria-busy`
|
|
861
|
+
* state of the library shows while a submit runs. After a failed submit the library moves focus to the first
|
|
862
|
+
* invalid control (or the summary does).
|
|
863
|
+
*
|
|
864
|
+
* shadcn/ui differences: there is no provider to spread the form into. `form` is the library object, and
|
|
865
|
+
* `class` replaces `className`.
|
|
866
|
+
*
|
|
867
|
+
* @param props The form props: see {@link FormProps}.
|
|
868
|
+
* @returns The `form` element.
|
|
869
|
+
* @example
|
|
870
|
+
* import { Button } from "@/components/ui/button";
|
|
871
|
+
* import {
|
|
872
|
+
* Form,
|
|
873
|
+
* FormControl,
|
|
874
|
+
* type FormApiLike,
|
|
875
|
+
* FormErrorSummary,
|
|
876
|
+
* FormField,
|
|
877
|
+
* FormItem,
|
|
878
|
+
* FormLabel,
|
|
879
|
+
* FormMessage,
|
|
880
|
+
* } from "@/components/ui/form";
|
|
881
|
+
* import { Input } from "@/components/ui/input";
|
|
882
|
+
*
|
|
883
|
+
* export function Signup(props: { form: FormApiLike }) {
|
|
884
|
+
* return (
|
|
885
|
+
* <Form form={props.form} class="grid gap-4">
|
|
886
|
+
* <FormErrorSummary />
|
|
887
|
+
* <FormField name="email">
|
|
888
|
+
* <FormItem>
|
|
889
|
+
* <FormLabel>Email</FormLabel>
|
|
890
|
+
* <FormControl as={Input} type="email" />
|
|
891
|
+
* <FormMessage />
|
|
892
|
+
* </FormItem>
|
|
893
|
+
* </FormField>
|
|
894
|
+
* <Button type="submit">Sign up</Button>
|
|
895
|
+
* </Form>
|
|
896
|
+
* );
|
|
897
|
+
* }
|
|
898
|
+
* @see {@link FormField}
|
|
899
|
+
* @see {@link FormErrorSummary}
|
|
900
|
+
* @since 1.0.0
|
|
901
|
+
*/
|
|
902
|
+
function Form(props: FormProps): JSX.Element {
|
|
903
|
+
const [local, rest] = splitProps(props, ["form", "onSubmit", "class", "children"]);
|
|
904
|
+
const [revision, setRevision] = useSignal(0);
|
|
905
|
+
const registrations = new Set<FormRegistration>();
|
|
906
|
+
const summary = usePartRegistration();
|
|
907
|
+
const attributes = () => local.form.props?.();
|
|
908
|
+
const submit = (event: Event): void => {
|
|
909
|
+
// The caller's handler runs first; `preventDefault()` there keeps the library out of it.
|
|
910
|
+
const own = untrack(() => local.onSubmit) as ((event: Event) => void) | undefined;
|
|
911
|
+
own?.(event);
|
|
912
|
+
if (event.defaultPrevented) return;
|
|
913
|
+
event.preventDefault();
|
|
914
|
+
void local.form.handleSubmit(event);
|
|
915
|
+
};
|
|
916
|
+
return (
|
|
917
|
+
<formRootContext.Provider
|
|
918
|
+
value={{
|
|
919
|
+
form: () => local.form,
|
|
920
|
+
register: (registration) => {
|
|
921
|
+
registrations.add(registration);
|
|
922
|
+
setRevision((value) => value + 1);
|
|
923
|
+
return () => {
|
|
924
|
+
registrations.delete(registration);
|
|
925
|
+
setRevision((value) => value + 1);
|
|
926
|
+
};
|
|
927
|
+
},
|
|
928
|
+
find: (name) => [...registrations].find((entry) => entry.name() === name),
|
|
929
|
+
revision,
|
|
930
|
+
summary,
|
|
931
|
+
}}
|
|
932
|
+
>
|
|
933
|
+
<form
|
|
934
|
+
data-slot="form"
|
|
935
|
+
id={attributes()?.id}
|
|
936
|
+
noValidate
|
|
937
|
+
aria-busy={attributes()?.["aria-busy"]}
|
|
938
|
+
class={cn(local.class)}
|
|
939
|
+
{...rest}
|
|
940
|
+
onSubmit={submit}
|
|
941
|
+
>
|
|
942
|
+
{local.children}
|
|
943
|
+
</form>
|
|
944
|
+
</formRootContext.Provider>
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Props of {@link FormErrorSummary}: the attributes of the `div` that holds the summary.
|
|
950
|
+
* @see {@link FormErrorSummary}
|
|
951
|
+
* @since 1.0.0
|
|
952
|
+
*/
|
|
953
|
+
export interface FormErrorSummaryProps extends ComponentProps<"div"> {
|
|
954
|
+
/**
|
|
955
|
+
* Moves focus to the summary after a failed submit, so the problems are read at once and a keyboard
|
|
956
|
+
* user can tab to the links. Turn it off to let the form library focus the first invalid control.
|
|
957
|
+
* @default true
|
|
958
|
+
*/
|
|
959
|
+
readonly focusOnSubmit?: boolean | undefined;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* A summary of the problems of a failed submit: a count in a title and a link for each problem that moves
|
|
964
|
+
* focus to its control. It appears after the first submit while the form has problems, follows them as
|
|
965
|
+
* they are fixed, and goes when none is left. Put it at the top of a {@link Form}.
|
|
966
|
+
*
|
|
967
|
+
* Keyboard and ARIA: the summary is a `role="alert"` region named by its title, with `tabindex="-1"`. After
|
|
968
|
+
* each failed submit it takes focus (see `focusOnSubmit`); Tab then reaches the links, and Enter on a link
|
|
969
|
+
* focuses the control of that problem. The field messages stop being alerts while a summary is present, so
|
|
970
|
+
* the problems are announced once. A problem of the whole form is listed without a link. The words are
|
|
971
|
+
* the UI strings `form.errorSummary.title` and `form.errorSummary.item`.
|
|
972
|
+
*
|
|
973
|
+
* shadcn/ui differences: shadcn has no error summary. It follows the pattern of GOV.UK and the WAI
|
|
974
|
+
* forms tutorial.
|
|
975
|
+
*
|
|
976
|
+
* @param props The summary props: see {@link FormErrorSummaryProps}.
|
|
977
|
+
* @returns The summary while there are problems to show, otherwise nothing.
|
|
978
|
+
* @throws Error when rendered outside a {@link Form}.
|
|
979
|
+
* @example
|
|
980
|
+
* import { Form, FormErrorSummary } from "@/components/ui/form";
|
|
981
|
+
*
|
|
982
|
+
* export function Page(props: { form: Parameters<typeof Form>[0]["form"] }) {
|
|
983
|
+
* return (
|
|
984
|
+
* <Form form={props.form}>
|
|
985
|
+
* <FormErrorSummary focusOnSubmit />
|
|
986
|
+
* </Form>
|
|
987
|
+
* );
|
|
988
|
+
* }
|
|
989
|
+
* @see {@link Form}
|
|
990
|
+
* @since 1.0.0
|
|
991
|
+
*/
|
|
992
|
+
function FormErrorSummary(props: FormErrorSummaryProps): JSX.Element {
|
|
993
|
+
const root = formRootContext.use("FormErrorSummary");
|
|
994
|
+
const t = useUiStrings();
|
|
995
|
+
const [local, rest] = splitProps(props, ["class", "focusOnSubmit", "children"]);
|
|
996
|
+
const titleId = partId(useBaseId()(), "error-summary-title");
|
|
997
|
+
const [element, setElement] = useRefSignal<HTMLElement>();
|
|
998
|
+
root.summary.register();
|
|
999
|
+
const entries = useMemo(() => {
|
|
1000
|
+
root.revision();
|
|
1001
|
+
const form = root.form();
|
|
1002
|
+
const seen = new Set<string>();
|
|
1003
|
+
const list: Array<{
|
|
1004
|
+
readonly path: string;
|
|
1005
|
+
readonly message: string;
|
|
1006
|
+
readonly id: string | undefined;
|
|
1007
|
+
readonly label: string | undefined;
|
|
1008
|
+
}> = [];
|
|
1009
|
+
for (const issue of form.issues?.() ?? []) {
|
|
1010
|
+
if (seen.has(issue.path) || issue.message === "") continue;
|
|
1011
|
+
seen.add(issue.path);
|
|
1012
|
+
const found = issue.path === "" ? undefined : root.find(issue.path);
|
|
1013
|
+
list.push({
|
|
1014
|
+
path: issue.path,
|
|
1015
|
+
message: issue.message,
|
|
1016
|
+
id: found?.controlId,
|
|
1017
|
+
label: found?.label(),
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
// Problems are listed in the order of the page, whatever order the library found them in; the
|
|
1021
|
+
// problems of the whole form come last.
|
|
1022
|
+
const position = (id: string | undefined): Node | null =>
|
|
1023
|
+
id === undefined ? null : document.getElementById(id);
|
|
1024
|
+
return list.sort((a, b) => {
|
|
1025
|
+
const first = position(a.id);
|
|
1026
|
+
const second = position(b.id);
|
|
1027
|
+
if (first === null || second === null) return first === second ? 0 : first === null ? 1 : -1;
|
|
1028
|
+
return first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
const visible = (): boolean => (root.form().submitCount?.() ?? 0) > 0 && entries().length > 0;
|
|
1032
|
+
let focusedFor = 0;
|
|
1033
|
+
// After each failed submit the summary takes focus (once it is on the page).
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
const count = root.form().submitCount?.() ?? 0;
|
|
1036
|
+
const node = element();
|
|
1037
|
+
if (node === undefined || count === focusedFor) return;
|
|
1038
|
+
focusedFor = count;
|
|
1039
|
+
if (local.focusOnSubmit !== false) node.focus();
|
|
1040
|
+
});
|
|
1041
|
+
const textOf = (entry: {
|
|
1042
|
+
readonly label: string | undefined;
|
|
1043
|
+
readonly message: string;
|
|
1044
|
+
}): string =>
|
|
1045
|
+
entry.label === undefined
|
|
1046
|
+
? entry.message
|
|
1047
|
+
: t("form.errorSummary.item", "{label}: {message}", {
|
|
1048
|
+
label: entry.label,
|
|
1049
|
+
message: entry.message,
|
|
1050
|
+
});
|
|
1051
|
+
const follow = (event: MouseEvent, path: string, id: string | undefined): void => {
|
|
1052
|
+
event.preventDefault();
|
|
1053
|
+
const moved = root.form().focus?.(path) === true;
|
|
1054
|
+
if (!moved && id !== undefined) document.getElementById(id)?.focus();
|
|
1055
|
+
};
|
|
1056
|
+
return (
|
|
1057
|
+
<Show when={visible()}>
|
|
1058
|
+
<div
|
|
1059
|
+
data-slot="form-error-summary"
|
|
1060
|
+
ref={(node: HTMLElement) => setElement(node)}
|
|
1061
|
+
role="alert"
|
|
1062
|
+
tabindex={-1}
|
|
1063
|
+
aria-labelledby={titleId}
|
|
1064
|
+
class={cn(
|
|
1065
|
+
"grid gap-2 rounded-md border border-destructive/50 bg-destructive/5 p-4 text-sm text-destructive outline-none focus-visible:ring-[3px] focus-visible:ring-destructive/30 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent",
|
|
1066
|
+
local.class,
|
|
1067
|
+
)}
|
|
1068
|
+
{...rest}
|
|
1069
|
+
>
|
|
1070
|
+
<p data-slot="form-error-summary-title" id={titleId} class="font-medium">
|
|
1071
|
+
{t(
|
|
1072
|
+
"form.errorSummary.title",
|
|
1073
|
+
"{count, plural, one {There is # problem with your submission} other {There are # problems with your submission}}",
|
|
1074
|
+
{ count: entries().length },
|
|
1075
|
+
)}
|
|
1076
|
+
</p>
|
|
1077
|
+
<ul class="grid list-disc gap-1 ps-5">
|
|
1078
|
+
<For each={entries()} key={(entry: { path: string }) => entry.path}>
|
|
1079
|
+
{(entry) => (
|
|
1080
|
+
<li>
|
|
1081
|
+
<Show when={entry().id} fallback={() => textOf(entry())}>
|
|
1082
|
+
{(id) => (
|
|
1083
|
+
<a
|
|
1084
|
+
href={`#${id()}`}
|
|
1085
|
+
class="underline underline-offset-4 outline-none hover:no-underline focus-visible:ring-[3px] focus-visible:ring-destructive/30 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent"
|
|
1086
|
+
onClick={(event: MouseEvent) => follow(event, entry().path, id())}
|
|
1087
|
+
>
|
|
1088
|
+
{textOf(entry())}
|
|
1089
|
+
</a>
|
|
1090
|
+
)}
|
|
1091
|
+
</Show>
|
|
1092
|
+
</li>
|
|
1093
|
+
)}
|
|
1094
|
+
</For>
|
|
1095
|
+
</ul>
|
|
1096
|
+
{local.children}
|
|
1097
|
+
</div>
|
|
1098
|
+
</Show>
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
export {
|
|
1103
|
+
Form,
|
|
1104
|
+
FormControl,
|
|
1105
|
+
FormDescription,
|
|
1106
|
+
FormErrorSummary,
|
|
1107
|
+
FormField,
|
|
1108
|
+
FormItem,
|
|
1109
|
+
FormLabel,
|
|
1110
|
+
FormMessage,
|
|
1111
|
+
useFormField,
|
|
1112
|
+
};
|