@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.
Files changed (190) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -2
  4. package/dist/cli.mjs +2293 -0
  5. package/dist/dev/cli/schema.d.ts +73 -0
  6. package/dist/dev/index.d.ts +6 -0
  7. package/dist/dev/index.js +5 -0
  8. package/dist/dev/locales/ar.d.ts +141 -0
  9. package/dist/dev/locales/ar.js +128 -0
  10. package/dist/dev/locales/ar.js.map +1 -0
  11. package/dist/dev/locales/en.d.ts +141 -0
  12. package/dist/dev/locales/en.js +128 -0
  13. package/dist/dev/locales/en.js.map +1 -0
  14. package/dist/dev/registry/utils/utils.d.ts +29 -0
  15. package/dist/dev/registry/utils/utils.js +27 -0
  16. package/dist/dev/registry/utils/utils.js.map +1 -0
  17. package/dist/dev/theme/theme-logic.d.ts +187 -0
  18. package/dist/dev/theme/theme-logic.js +184 -0
  19. package/dist/dev/theme/theme-logic.js.map +1 -0
  20. package/dist/dev/theme/theme-provider.d.ts +90 -0
  21. package/dist/dev/theme/theme-provider.js +150 -0
  22. package/dist/dev/theme/theme-provider.js.map +1 -0
  23. package/dist/prod/index.js +1 -0
  24. package/dist/prod/locales/ar.js +2 -0
  25. package/dist/prod/locales/ar.js.map +1 -0
  26. package/dist/prod/locales/en.js +2 -0
  27. package/dist/prod/locales/en.js.map +1 -0
  28. package/dist/prod/registry/utils/utils.js +2 -0
  29. package/dist/prod/registry/utils/utils.js.map +1 -0
  30. package/dist/prod/theme/theme-logic.js +2 -0
  31. package/dist/prod/theme/theme-logic.js.map +1 -0
  32. package/dist/prod/theme/theme-provider.js +2 -0
  33. package/dist/prod/theme/theme-provider.js.map +1 -0
  34. package/package.json +102 -4
  35. package/registry/accordion/accordion.tsx +210 -0
  36. package/registry/accordion/item.json +4 -0
  37. package/registry/alert/alert.tsx +200 -0
  38. package/registry/alert/item.json +4 -0
  39. package/registry/alert-dialog/alert-dialog.tsx +530 -0
  40. package/registry/alert-dialog/item.json +4 -0
  41. package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
  42. package/registry/aspect-ratio/item.json +4 -0
  43. package/registry/attachment/attachment.tsx +459 -0
  44. package/registry/attachment/item.json +4 -0
  45. package/registry/avatar/avatar.tsx +385 -0
  46. package/registry/avatar/item.json +4 -0
  47. package/registry/badge/badge.tsx +108 -0
  48. package/registry/badge/item.json +4 -0
  49. package/registry/breadcrumb/breadcrumb.tsx +354 -0
  50. package/registry/breadcrumb/item.json +4 -0
  51. package/registry/bubble/bubble.tsx +260 -0
  52. package/registry/bubble/item.json +4 -0
  53. package/registry/button/button.tsx +125 -0
  54. package/registry/button/item.json +4 -0
  55. package/registry/button-group/button-group.tsx +189 -0
  56. package/registry/button-group/item.json +4 -0
  57. package/registry/calendar/calendar.tsx +348 -0
  58. package/registry/calendar/item.json +4 -0
  59. package/registry/card/card.tsx +311 -0
  60. package/registry/card/item.json +4 -0
  61. package/registry/carousel/carousel.tsx +851 -0
  62. package/registry/carousel/item.json +4 -0
  63. package/registry/chart/chart.tsx +1956 -0
  64. package/registry/chart/item.json +4 -0
  65. package/registry/chart-scale/chart-scale.ts +670 -0
  66. package/registry/chart-scale/item.json +5 -0
  67. package/registry/checkbox/checkbox.tsx +79 -0
  68. package/registry/checkbox/item.json +4 -0
  69. package/registry/code-block/code-block.tsx +470 -0
  70. package/registry/code-block/item.json +4 -0
  71. package/registry/collapsible/collapsible.tsx +117 -0
  72. package/registry/collapsible/item.json +4 -0
  73. package/registry/combobox/combobox.tsx +977 -0
  74. package/registry/combobox/item.json +4 -0
  75. package/registry/command/command.tsx +757 -0
  76. package/registry/command/item.json +4 -0
  77. package/registry/context-menu/context-menu.tsx +697 -0
  78. package/registry/context-menu/item.json +4 -0
  79. package/registry/data-table/data-table.tsx +437 -0
  80. package/registry/data-table/item.json +4 -0
  81. package/registry/date-picker/date-picker.tsx +349 -0
  82. package/registry/date-picker/item.json +4 -0
  83. package/registry/dialog/dialog.tsx +412 -0
  84. package/registry/dialog/item.json +4 -0
  85. package/registry/direction/direction.tsx +88 -0
  86. package/registry/direction/item.json +4 -0
  87. package/registry/drawer/drawer.tsx +787 -0
  88. package/registry/drawer/item.json +4 -0
  89. package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
  90. package/registry/dropdown-menu/item.json +4 -0
  91. package/registry/empty/empty.tsx +240 -0
  92. package/registry/empty/item.json +4 -0
  93. package/registry/field/field.tsx +624 -0
  94. package/registry/field/item.json +4 -0
  95. package/registry/form/form.tsx +1112 -0
  96. package/registry/form/item.json +4 -0
  97. package/registry/hover-card/hover-card.tsx +144 -0
  98. package/registry/hover-card/item.json +4 -0
  99. package/registry/icons/icons.tsx +1691 -0
  100. package/registry/icons/item.json +4 -0
  101. package/registry/input/input.tsx +43 -0
  102. package/registry/input/item.json +4 -0
  103. package/registry/input-group/input-group.tsx +315 -0
  104. package/registry/input-group/item.json +4 -0
  105. package/registry/input-otp/input-otp.tsx +329 -0
  106. package/registry/input-otp/item.json +4 -0
  107. package/registry/item/item.json +4 -0
  108. package/registry/item/item.tsx +439 -0
  109. package/registry/kbd/item.json +4 -0
  110. package/registry/kbd/kbd.tsx +65 -0
  111. package/registry/label/item.json +4 -0
  112. package/registry/label/label.tsx +60 -0
  113. package/registry/marker/item.json +4 -0
  114. package/registry/marker/marker.tsx +175 -0
  115. package/registry/menubar/item.json +4 -0
  116. package/registry/menubar/menubar.tsx +694 -0
  117. package/registry/message/item.json +4 -0
  118. package/registry/message/message.tsx +280 -0
  119. package/registry/message-scroller/item.json +4 -0
  120. package/registry/message-scroller/message-scroller.tsx +262 -0
  121. package/registry/native-select/item.json +4 -0
  122. package/registry/native-select/native-select.tsx +113 -0
  123. package/registry/navigation-menu/item.json +4 -0
  124. package/registry/navigation-menu/navigation-menu.tsx +362 -0
  125. package/registry/pagination/item.json +4 -0
  126. package/registry/pagination/pagination.tsx +460 -0
  127. package/registry/password-input/item.json +5 -0
  128. package/registry/password-input/password-input.tsx +494 -0
  129. package/registry/popover/item.json +4 -0
  130. package/registry/popover/popover.tsx +284 -0
  131. package/registry/progress/item.json +4 -0
  132. package/registry/progress/progress.tsx +190 -0
  133. package/registry/questionnaire/item.json +4 -0
  134. package/registry/questionnaire/questionnaire.tsx +569 -0
  135. package/registry/radio-group/item.json +4 -0
  136. package/registry/radio-group/radio-group.tsx +101 -0
  137. package/registry/resizable/item.json +4 -0
  138. package/registry/resizable/resizable.tsx +455 -0
  139. package/registry/scroll-area/item.json +4 -0
  140. package/registry/scroll-area/scroll-area.tsx +149 -0
  141. package/registry/select/item.json +4 -0
  142. package/registry/select/select.tsx +571 -0
  143. package/registry/separator/item.json +4 -0
  144. package/registry/separator/separator.tsx +52 -0
  145. package/registry/sheet/item.json +4 -0
  146. package/registry/sheet/sheet.tsx +476 -0
  147. package/registry/sidebar/item.json +4 -0
  148. package/registry/sidebar/sidebar.tsx +1870 -0
  149. package/registry/skeleton/item.json +4 -0
  150. package/registry/skeleton/skeleton.tsx +47 -0
  151. package/registry/slider/item.json +4 -0
  152. package/registry/slider/slider.tsx +149 -0
  153. package/registry/spinner/item.json +4 -0
  154. package/registry/spinner/spinner.tsx +51 -0
  155. package/registry/switch/item.json +4 -0
  156. package/registry/switch/switch.tsx +93 -0
  157. package/registry/table/item.json +4 -0
  158. package/registry/table/table.tsx +373 -0
  159. package/registry/tabs/item.json +4 -0
  160. package/registry/tabs/tabs.tsx +209 -0
  161. package/registry/textarea/item.json +4 -0
  162. package/registry/textarea/textarea.tsx +51 -0
  163. package/registry/toast/item.json +4 -0
  164. package/registry/toast/toast.tsx +949 -0
  165. package/registry/toggle/item.json +4 -0
  166. package/registry/toggle/toggle.tsx +110 -0
  167. package/registry/toggle-group/item.json +4 -0
  168. package/registry/toggle-group/toggle-group.tsx +189 -0
  169. package/registry/tooltip/item.json +4 -0
  170. package/registry/tooltip/tooltip.tsx +169 -0
  171. package/registry/trace/item.json +4 -0
  172. package/registry/trace/trace.tsx +356 -0
  173. package/registry/ui-strings/item.json +5 -0
  174. package/registry/ui-strings/ui-strings.tsx +454 -0
  175. package/registry/use-highlighted-code/item.json +5 -0
  176. package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
  177. package/registry/use-mobile/item.json +5 -0
  178. package/registry/use-mobile/use-mobile.ts +46 -0
  179. package/registry/utils/item.json +5 -0
  180. package/registry/utils/utils.ts +38 -0
  181. package/registry.json +3274 -0
  182. package/styles/animations.css +321 -0
  183. package/styles/theme-core.css +55 -0
  184. package/styles/theme.css +12 -0
  185. package/styles/themes/gray.css +69 -0
  186. package/styles/themes/neutral.css +69 -0
  187. package/styles/themes/slate.css +69 -0
  188. package/styles/themes/stone.css +69 -0
  189. package/styles/themes/zinc.css +69 -0
  190. 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
+ };