@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,624 @@
1
+ import { type Accessor, splitProps } from "@shadow-js/core";
2
+ import {
3
+ createPartContext,
4
+ Label as LabelPrimitive,
5
+ partId,
6
+ useBaseId,
7
+ usePartRegistration,
8
+ } from "@shadow-js/primitives";
9
+ import { type ComponentProps, For, type JSX, Show } from "@shadow-js/web";
10
+ import { cva, type VariantProps } from "class-variance-authority";
11
+ import { cn } from "@/lib/utils";
12
+
13
+ /** What a field shares with its label, description, error and control. @internal */
14
+ interface FieldContextValue {
15
+ readonly controlId: () => string;
16
+ readonly description: ReturnType<typeof usePartRegistration>;
17
+ readonly error: ReturnType<typeof usePartRegistration>;
18
+ readonly invalid: () => boolean;
19
+ readonly disabled: () => boolean;
20
+ readonly descriptionId: () => string;
21
+ readonly errorId: () => string;
22
+ }
23
+
24
+ const fieldContext = createPartContext<FieldContextValue>("Field");
25
+
26
+ const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", {
27
+ variants: {
28
+ orientation: {
29
+ vertical: "flex-col [&>*]:w-full",
30
+ horizontal: "flex-row items-center [&>[data-slot=field-label]]:flex-auto",
31
+ responsive: "flex-col sm:flex-row sm:items-center [&>[data-slot=field-label]]:sm:flex-auto",
32
+ },
33
+ },
34
+ defaultVariants: { orientation: "vertical" },
35
+ });
36
+
37
+ /**
38
+ * Props of {@link Field}: the attributes of the rendered `div`, `orientation` (`"vertical"`,
39
+ * `"horizontal"` or `"responsive"`), and the state of the entry.
40
+ * @see {@link Field}
41
+ * @since 1.0.0
42
+ */
43
+ export type FieldProps = ComponentProps<"div"> &
44
+ VariantProps<typeof fieldVariants> & {
45
+ /**
46
+ * Marks the entry invalid: the label and the error take the destructive color, and the control
47
+ * gets `aria-invalid` from {@link useField}.
48
+ * @default false
49
+ */
50
+ readonly invalid?: boolean | undefined;
51
+ /**
52
+ * Disables the entry's label and description, and the control through {@link useField}.
53
+ * @default false
54
+ */
55
+ readonly disabled?: boolean | undefined;
56
+ /**
57
+ * The id of the control. The label points at it. Generated when omitted.
58
+ * @default generated
59
+ */
60
+ readonly controlId?: string | undefined;
61
+ };
62
+
63
+ type Loose = { readonly class?: unknown; readonly [prop: string]: unknown };
64
+
65
+ /**
66
+ * A form entry: the layout of a label, a control, a description and an error. The label points at the
67
+ * control, and the control describes itself with the description and the error while they are shown.
68
+ * That wiring is done by the control, which reads {@link useField}.
69
+ *
70
+ * Keyboard and ARIA: the field is a `div` with `role="group"` and no keys of its own. The label is a
71
+ * `label` whose `for` is the control's id. The control gets its `id`, `aria-describedby` (the
72
+ * description and the error, only while they are rendered), `aria-invalid` and `disabled` from
73
+ * {@link useField}. The control is named by its label: the label's `for` is the control's id. The error
74
+ * has `role="alert"`, so a screen reader announces it when it appears.
75
+ *
76
+ * shadcn/ui differences: the prop is `class`, not `className`, and every part has a `data-slot`. The control
77
+ * gets its ids and state from {@link useField}, which returns values to set on the control.
78
+ *
79
+ * @param props The field props: see {@link FieldProps}.
80
+ * @returns A `div` with `role="group"`.
81
+ * @example
82
+ * import { Field, FieldDescription, FieldLabel, useField } from "@/components/ui/field";
83
+ * import { Input } from "@/components/ui/input";
84
+ *
85
+ * function EmailControl() {
86
+ * const field = useField();
87
+ * return (
88
+ * <Input
89
+ * id={field.id()}
90
+ * type="email"
91
+ * aria-describedby={field.describedBy()}
92
+ * aria-invalid={field.invalid() ? true : undefined}
93
+ * disabled={field.disabled()}
94
+ * />
95
+ * );
96
+ * }
97
+ *
98
+ * export function EmailField() {
99
+ * return (
100
+ * <Field>
101
+ * <FieldLabel>Email</FieldLabel>
102
+ * <EmailControl />
103
+ * <FieldDescription>We never share it.</FieldDescription>
104
+ * </Field>
105
+ * );
106
+ * }
107
+ * @see {@link useField}
108
+ * @see {@link FieldLabel}
109
+ * @since 1.0.0
110
+ */
111
+ function Field(props: FieldProps): JSX.Element {
112
+ const [local, rest] = splitProps(props as unknown as Loose & FieldProps, [
113
+ "class",
114
+ "orientation",
115
+ "invalid",
116
+ "disabled",
117
+ "controlId",
118
+ ]);
119
+ const base = useBaseId(() => local.controlId as string | undefined);
120
+ const description = usePartRegistration();
121
+ const error = usePartRegistration();
122
+ const context: FieldContextValue = {
123
+ // The application's controlId is the control's id as given; the generated one derives from the base.
124
+ controlId: () => (local.controlId as string | undefined) ?? partId(base(), "control"),
125
+ description,
126
+ error,
127
+ invalid: () => local.invalid === true,
128
+ disabled: () => local.disabled === true,
129
+ descriptionId: () => partId(base(), "description"),
130
+ errorId: () => partId(base(), "error"),
131
+ };
132
+ return (
133
+ <fieldContext.Provider value={context}>
134
+ <div
135
+ role="group"
136
+ data-slot="field"
137
+ data-invalid={local.invalid === true ? "true" : undefined}
138
+ data-disabled={local.disabled === true ? "true" : undefined}
139
+ data-orientation={(local.orientation as string | undefined) ?? "vertical"}
140
+ class={cn(
141
+ fieldVariants({
142
+ orientation: local.orientation as "vertical" | "horizontal" | "responsive" | undefined,
143
+ }),
144
+ local.class as string | undefined,
145
+ )}
146
+ {...rest}
147
+ />
148
+ </fieldContext.Provider>
149
+ );
150
+ }
151
+
152
+ /**
153
+ * The id and state of the control of the enclosing {@link Field}, as accessors. Read them in the
154
+ * control's JSX so the attributes stay current. `describedBy` lists the description and the error only
155
+ * while they are rendered. Call it inside a `Field`, in the component that renders the control.
156
+ *
157
+ * @returns The accessors: `id`, `describedBy`, `invalid` and `disabled`.
158
+ * @throws Error when called outside a {@link Field}.
159
+ * @example
160
+ * import { Field, FieldLabel, useField } from "@/components/ui/field";
161
+ * import { Input } from "@/components/ui/input";
162
+ *
163
+ * function Control() {
164
+ * const field = useField();
165
+ * return (
166
+ * <Input
167
+ * id={field.id()}
168
+ * aria-describedby={field.describedBy()}
169
+ * aria-invalid={field.invalid() ? true : undefined}
170
+ * />
171
+ * );
172
+ * }
173
+ *
174
+ * export function NameField() {
175
+ * return (
176
+ * <Field>
177
+ * <FieldLabel>Name</FieldLabel>
178
+ * <Control />
179
+ * </Field>
180
+ * );
181
+ * }
182
+ * @see {@link Field}
183
+ * @since 1.0.0
184
+ */
185
+ function useField(): {
186
+ readonly id: () => string;
187
+ readonly describedBy: () => string | undefined;
188
+ readonly invalid: () => boolean;
189
+ readonly disabled: () => boolean;
190
+ } {
191
+ const context = fieldContext.use("useField");
192
+ return {
193
+ id: () => context.controlId(),
194
+ describedBy: () => {
195
+ const ids = [
196
+ context.description.present() ? context.descriptionId() : undefined,
197
+ context.error.present() ? context.errorId() : undefined,
198
+ ].filter((id): id is string => id !== undefined);
199
+ return ids.length === 0 ? undefined : ids.join(" ");
200
+ },
201
+ invalid: () => context.invalid(),
202
+ disabled: () => context.disabled(),
203
+ };
204
+ }
205
+
206
+ /**
207
+ * A group of related fields under a {@link FieldLegend}. It is a native `fieldset`, so the browser and
208
+ * assistive technology treat its fields as one group.
209
+ *
210
+ * @param props The set props: the attributes of the rendered `fieldset` and `class`.
211
+ * @returns The fieldset.
212
+ * @example
213
+ * import { Field, FieldGroup, FieldLabel, FieldLegend, FieldSet } from "@/components/ui/field";
214
+ * import { Input } from "@/components/ui/input";
215
+ *
216
+ * export function Address() {
217
+ * return (
218
+ * <FieldSet>
219
+ * <FieldLegend>Address</FieldLegend>
220
+ * <FieldGroup>
221
+ * <Field controlId="street">
222
+ * <FieldLabel>Street</FieldLabel>
223
+ * <Input id="street" />
224
+ * </Field>
225
+ * </FieldGroup>
226
+ * </FieldSet>
227
+ * );
228
+ * }
229
+ * @see {@link FieldLegend}
230
+ * @see {@link FieldGroup}
231
+ * @since 1.0.0
232
+ */
233
+ function FieldSet(props: ComponentProps<"fieldset">): JSX.Element {
234
+ const [local, rest] = splitProps(props, ["class"]);
235
+ return (
236
+ <fieldset
237
+ data-slot="field-set"
238
+ class={cn("flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3", local.class)}
239
+ {...rest}
240
+ />
241
+ );
242
+ }
243
+
244
+ /**
245
+ * The legend of a {@link FieldSet}, which names the set. `variant="label"` styles it like a small label,
246
+ * and `variant="legend"` (the default) like a heading.
247
+ *
248
+ * @param props The legend props: the attributes of the rendered `legend`, and `variant`.
249
+ * @returns The legend.
250
+ * @example
251
+ * import { FieldLegend, FieldSet } from "@/components/ui/field";
252
+ *
253
+ * export function Notifications() {
254
+ * return (
255
+ * <FieldSet>
256
+ * <FieldLegend variant="label">Notifications</FieldLegend>
257
+ * </FieldSet>
258
+ * );
259
+ * }
260
+ * @see {@link FieldSet}
261
+ * @since 1.0.0
262
+ */
263
+ function FieldLegend(
264
+ props: ComponentProps<"legend"> & { readonly variant?: "legend" | "label" | undefined },
265
+ ): JSX.Element {
266
+ const [local, rest] = splitProps(
267
+ props as unknown as Loose & { readonly variant?: "legend" | "label" },
268
+ ["class", "variant"],
269
+ );
270
+ return (
271
+ <legend
272
+ data-slot="field-legend"
273
+ data-variant={(local.variant as string | undefined) ?? "legend"}
274
+ class={cn(
275
+ "mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
276
+ local.class as string | undefined,
277
+ )}
278
+ {...rest}
279
+ />
280
+ );
281
+ }
282
+
283
+ /**
284
+ * A column of fields with space between them. It has no role.
285
+ *
286
+ * @param props The group props: the attributes of the rendered `div` and `class`.
287
+ * @returns The group.
288
+ * @example
289
+ * import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
290
+ * import { Input } from "@/components/ui/input";
291
+ *
292
+ * export function Contact() {
293
+ * return (
294
+ * <FieldGroup>
295
+ * <Field controlId="email">
296
+ * <FieldLabel>Email</FieldLabel>
297
+ * <Input id="email" type="email" />
298
+ * </Field>
299
+ * </FieldGroup>
300
+ * );
301
+ * }
302
+ * @see {@link Field}
303
+ * @since 1.0.0
304
+ */
305
+ function FieldGroup(props: ComponentProps<"div">): JSX.Element {
306
+ const [local, rest] = splitProps(props, ["class"]);
307
+ return (
308
+ <div
309
+ data-slot="field-group"
310
+ class={cn("group/field-group @container/field-group flex w-full flex-col gap-7", local.class)}
311
+ {...rest}
312
+ />
313
+ );
314
+ }
315
+
316
+ /**
317
+ * The text column of a horizontal field: the title, label and description that sit beside the control.
318
+ * It has no role.
319
+ *
320
+ * @param props The content props: the attributes of the rendered `div` and `class`.
321
+ * @returns The text column.
322
+ * @example
323
+ * import { Field, FieldContent, FieldDescription, FieldTitle } from "@/components/ui/field";
324
+ * import { Switch } from "@/components/ui/switch";
325
+ *
326
+ * export function Digest() {
327
+ * return (
328
+ * <Field orientation="horizontal">
329
+ * <FieldContent>
330
+ * <FieldTitle>Weekly digest</FieldTitle>
331
+ * <FieldDescription>One email a week.</FieldDescription>
332
+ * </FieldContent>
333
+ * <Switch aria-label="Weekly digest" />
334
+ * </Field>
335
+ * );
336
+ * }
337
+ * @see {@link Field}
338
+ * @since 1.0.0
339
+ */
340
+ function FieldContent(props: ComponentProps<"div">): JSX.Element {
341
+ const [local, rest] = splitProps(props, ["class"]);
342
+ return (
343
+ <div
344
+ data-slot="field-content"
345
+ class={cn("flex flex-1 flex-col gap-1.5 leading-snug", local.class)}
346
+ {...rest}
347
+ />
348
+ );
349
+ }
350
+
351
+ /**
352
+ * The label of the field's control. It points at the control (its `for` is set from the field, so it
353
+ * overrides a `htmlFor` you pass), and it reads as disabled and invalid with the field.
354
+ *
355
+ * @param props The label props: the attributes of the rendered `label` and `class`.
356
+ * @returns The label.
357
+ * @throws Error when rendered outside a {@link Field}.
358
+ * @example
359
+ * import { Field, FieldLabel } from "@/components/ui/field";
360
+ * import { Input } from "@/components/ui/input";
361
+ *
362
+ * export function NameField() {
363
+ * return (
364
+ * <Field controlId="name">
365
+ * <FieldLabel>Name</FieldLabel>
366
+ * <Input id="name" />
367
+ * </Field>
368
+ * );
369
+ * }
370
+ * @see {@link Field}
371
+ * @see {@link useField}
372
+ * @since 1.0.0
373
+ */
374
+ function FieldLabel(props: ComponentProps<"label">): JSX.Element {
375
+ const context = fieldContext.use("FieldLabel");
376
+ const [local, rest] = splitProps(props as unknown as Loose, ["class"]);
377
+ return (
378
+ <LabelPrimitive
379
+ data-slot="field-label"
380
+ class={cn(
381
+ "flex w-fit items-center gap-2 text-sm leading-snug font-medium select-none group-data-[disabled=true]/field:opacity-50 peer-disabled:opacity-50 group-data-[invalid=true]/field:text-destructive has-[>[data-slot=field]]:w-full",
382
+ local.class as string | undefined,
383
+ )}
384
+ {...rest}
385
+ htmlFor={context.controlId()}
386
+ />
387
+ );
388
+ }
389
+
390
+ /**
391
+ * The title of a horizontal field: a medium-weight line of text for the whole entry. It is a `div`, so
392
+ * it names nothing by itself; use a {@link FieldLabel} for the control's name.
393
+ *
394
+ * @param props The title props: the attributes of the rendered `div` and `class`.
395
+ * @returns The title.
396
+ * @example
397
+ * import { FieldContent, FieldTitle } from "@/components/ui/field";
398
+ *
399
+ * export const Billing = (
400
+ * <FieldContent>
401
+ * <FieldTitle>Billing</FieldTitle>
402
+ * </FieldContent>
403
+ * );
404
+ * @see {@link FieldContent}
405
+ * @since 1.0.0
406
+ */
407
+ function FieldTitle(props: ComponentProps<"div">): JSX.Element {
408
+ const [local, rest] = splitProps(props, ["class"]);
409
+ return (
410
+ <div
411
+ data-slot="field-title"
412
+ class={cn("flex w-fit items-center gap-2 text-sm leading-snug font-medium", local.class)}
413
+ {...rest}
414
+ />
415
+ );
416
+ }
417
+
418
+ /**
419
+ * A helper text for the control. It has an id, and the control's `aria-describedby` (from
420
+ * {@link useField}) names it while it is rendered, so a screen reader reads it with the control.
421
+ *
422
+ * @param props The description props: the attributes of the rendered `p` and `class`.
423
+ * @returns The paragraph.
424
+ * @throws Error when rendered outside a {@link Field}.
425
+ * @example
426
+ * import { Field, FieldDescription, FieldLabel, useField } from "@/components/ui/field";
427
+ * import { Input } from "@/components/ui/input";
428
+ *
429
+ * function PasswordControl() {
430
+ * const field = useField();
431
+ * return <Input id={field.id()} type="password" aria-describedby={field.describedBy()} />;
432
+ * }
433
+ *
434
+ * export function PasswordField() {
435
+ * return (
436
+ * <Field>
437
+ * <FieldLabel>Password</FieldLabel>
438
+ * <PasswordControl />
439
+ * <FieldDescription>Use at least 8 characters.</FieldDescription>
440
+ * </Field>
441
+ * );
442
+ * }
443
+ * @see {@link useField}
444
+ * @since 1.0.0
445
+ */
446
+ function FieldDescription(props: ComponentProps<"p">): JSX.Element {
447
+ const context = fieldContext.use("FieldDescription");
448
+ const [local, rest] = splitProps(props, ["class"]);
449
+ context.description.register();
450
+ return (
451
+ <p
452
+ data-slot="field-description"
453
+ id={context.descriptionId()}
454
+ class={cn(
455
+ "text-sm leading-normal font-normal text-muted-foreground group-has-[>input]/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
456
+ local.class,
457
+ )}
458
+ {...rest}
459
+ />
460
+ );
461
+ }
462
+
463
+ /** One error of a validator: any object with a `message`, such as a Standard Schema issue. */
464
+ type FieldErrorEntry = { readonly message?: string | undefined } | undefined;
465
+
466
+ /**
467
+ * Props of {@link FieldError}: the attributes of the rendered `div`, `class`, the `children` (the
468
+ * message, which wins when given), and `errors`, the messages of a validator.
469
+ * @see {@link FieldError}
470
+ * @since 1.0.0
471
+ */
472
+ export type FieldErrorProps = ComponentProps<"div"> & {
473
+ /**
474
+ * The errors of a validator. Each entry with a `message` is shown once: one message is text, several
475
+ * are a list. An empty list hides the error. Ignored when `children` is given.
476
+ * @default undefined
477
+ */
478
+ readonly errors?: ReadonlyArray<FieldErrorEntry> | undefined;
479
+ };
480
+
481
+ /**
482
+ * The error of a field. It has `role="alert"`, so it is announced when it appears, and the control's
483
+ * `aria-describedby` (from {@link useField}) names it while it has content. Without children and
484
+ * without messages it is hidden, and the control does not describe itself with it.
485
+ *
486
+ * Keyboard and ARIA: no keys of its own. The `alert` role makes a screen reader announce the message
487
+ * when it appears. With several messages, they are a `ul` of `li` items, so each one is read on its own.
488
+ *
489
+ * @param props The error props: see {@link FieldErrorProps}.
490
+ * @returns The error message, hidden while it has no content.
491
+ * @throws Error when rendered outside a {@link Field}.
492
+ * @example
493
+ * import { Field, FieldError, FieldLabel, useField } from "@/components/ui/field";
494
+ * import { Input } from "@/components/ui/input";
495
+ *
496
+ * function EmailControl() {
497
+ * const field = useField();
498
+ * return <Input id={field.id()} aria-describedby={field.describedBy()} aria-invalid={field.invalid() ? true : undefined} />;
499
+ * }
500
+ *
501
+ * export function EmailError() {
502
+ * return (
503
+ * <Field invalid>
504
+ * <FieldLabel>Email</FieldLabel>
505
+ * <EmailControl />
506
+ * <FieldError>Enter a valid email.</FieldError>
507
+ * </Field>
508
+ * );
509
+ * }
510
+ * @example
511
+ * // The messages of a validator: one line each, once each.
512
+ * <FieldError errors={[{ message: "Too short." }, { message: "No digits." }]} />
513
+ * @see {@link useField}
514
+ * @see {@link FieldErrorProps}
515
+ * @since 1.0.0
516
+ */
517
+ function FieldError(props: FieldErrorProps): JSX.Element {
518
+ const context = fieldContext.use("FieldError");
519
+ const [local, rest] = splitProps(props as unknown as Loose & FieldErrorProps, [
520
+ "class",
521
+ "children",
522
+ "errors",
523
+ ]);
524
+ const hasChildren = (): boolean =>
525
+ local.children !== undefined && local.children !== null && local.children !== false;
526
+ // The distinct messages of `errors`, in order. A repeated message is shown once.
527
+ const messages = (): string[] => {
528
+ const seen = new Set<string>();
529
+ const out: string[] = [];
530
+ for (const entry of (local.errors as ReadonlyArray<FieldErrorEntry> | undefined) ?? []) {
531
+ const message = entry?.message;
532
+ if (message === undefined || message === "" || seen.has(message)) continue;
533
+ seen.add(message);
534
+ out.push(message);
535
+ }
536
+ return out;
537
+ };
538
+ // An error with no content is not an error: it is hidden, and the control does not describe
539
+ // itself with it. The registration sits in a branch, so it follows the content as it appears
540
+ // and clears, not only the content the error had at mount.
541
+ const hasContent = (): boolean => hasChildren() || messages().length > 0;
542
+ return (
543
+ <div
544
+ role="alert"
545
+ data-slot="field-error"
546
+ id={context.errorId()}
547
+ hidden={hasContent() ? undefined : true}
548
+ class={cn("text-sm font-normal text-destructive", local.class as string | undefined)}
549
+ {...rest}
550
+ >
551
+ <Show when={hasContent()}>
552
+ {() => {
553
+ context.error.register();
554
+ return null;
555
+ }}
556
+ </Show>
557
+ <Show
558
+ when={hasChildren()}
559
+ fallback={
560
+ <Show when={messages().length > 1} fallback={messages()[0]}>
561
+ <ul class="ms-4 flex list-disc flex-col gap-1">
562
+ <For each={messages()} key={(message: string) => message}>
563
+ {(message: Accessor<string>) => <li>{message()}</li>}
564
+ </For>
565
+ </ul>
566
+ </Show>
567
+ }
568
+ >
569
+ {local.children}
570
+ </Show>
571
+ </div>
572
+ );
573
+ }
574
+
575
+ /**
576
+ * A divider between parts of a field group, with optional text in its middle, such as "or". It is
577
+ * decorative and has no role.
578
+ *
579
+ * @param props The separator props: the attributes of the rendered `div`, and `children` (the text).
580
+ * @returns The separator.
581
+ * @example
582
+ * import { FieldGroup, FieldSeparator } from "@/components/ui/field";
583
+ *
584
+ * export function Divider() {
585
+ * return (
586
+ * <FieldGroup>
587
+ * <FieldSeparator>or</FieldSeparator>
588
+ * </FieldGroup>
589
+ * );
590
+ * }
591
+ * @see {@link FieldGroup}
592
+ * @since 1.0.0
593
+ */
594
+ function FieldSeparator(props: ComponentProps<"div">): JSX.Element {
595
+ const [local, rest] = splitProps(props, ["class", "children"]);
596
+ return (
597
+ <div
598
+ data-slot="field-separator"
599
+ class={cn("relative -my-2 h-5 text-sm", local.class)}
600
+ {...rest}
601
+ >
602
+ <div class="absolute inset-0 top-1/2 border-t" />
603
+ {local.children !== undefined && (
604
+ <span class="relative mx-auto block w-fit bg-background px-2 text-muted-foreground">
605
+ {local.children}
606
+ </span>
607
+ )}
608
+ </div>
609
+ );
610
+ }
611
+
612
+ export {
613
+ Field,
614
+ FieldContent,
615
+ FieldDescription,
616
+ FieldError,
617
+ FieldGroup,
618
+ FieldLabel,
619
+ FieldLegend,
620
+ FieldSeparator,
621
+ FieldSet,
622
+ FieldTitle,
623
+ useField,
624
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "Field",
3
+ "description": "Form field layout: a label, a control, a description and an error, wired to one control with the ids a form needs."
4
+ }