@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,340 @@
1
+ import {
2
+ cloneElement,
3
+ forwardRef,
4
+ useCallback,
5
+ useId,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useState,
9
+ type ComponentPropsWithoutRef,
10
+ type ElementRef,
11
+ type HTMLAttributes,
12
+ type ReactElement,
13
+ } from "react";
14
+ import { Slot } from "@radix-ui/react-slot";
15
+ import { cn } from "../../lib/cn";
16
+ import { hasRenderableContent } from "../../lib/has-renderable-content";
17
+ import { Label } from "../label";
18
+ import { FieldContext, useFieldContext, type FieldContextValue } from "./field-context";
19
+
20
+ // ── FieldRoot ─────────────────────────────────────────────────────────────
21
+
22
+ export interface FieldRootProps extends HTMLAttributes<HTMLDivElement> {
23
+ /**
24
+ * Whether the field is currently invalid. Drives `aria-invalid` on every
25
+ * `FieldControl` and error styling on `FieldLabel` — independent of
26
+ * whether a `FieldError` with content is actually mounted, so a caller can
27
+ * mark the control invalid before its message has settled.
28
+ */
29
+ invalid?: boolean;
30
+ /** Whether the field is required. Drives `aria-required` on every `FieldControl`. */
31
+ required?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Compound-anatomy field: `FieldRoot` owns id generation and `aria-describedby`
36
+ * composition; `FieldLabel`/`FieldControl`/`FieldDescription`/`FieldError` read
37
+ * that state from context and can be composed in ANY order/layout — including
38
+ * more than one `FieldControl` in one row (e.g. first/last name) or a
39
+ * `FieldDescription` placed before the control. Mirrors the same accessibility
40
+ * wiring `FieldRow` (`../field-row`) already validated (id/`aria-describedby`/
41
+ * `aria-invalid`/`role="alert"`), adapted to a shared lifted-state context so
42
+ * it holds across independently-composed parts; `FieldRow` remains the
43
+ * convenience wrapper for the common single-control case and is unaffected by
44
+ * this addition (#43).
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * <FieldRoot invalid={!!error} required>
49
+ * <FieldLabel>{label}</FieldLabel>
50
+ * <FieldControl><Input /></FieldControl>
51
+ * <FieldDescription>{helpText}</FieldDescription>
52
+ * <FieldError>{error}</FieldError>
53
+ * </FieldRoot>
54
+ * ```
55
+ */
56
+ export const FieldRoot = forwardRef<HTMLDivElement, FieldRootProps>(function FieldRoot(
57
+ { invalid = false, required = false, className, children, ...props },
58
+ ref,
59
+ ) {
60
+ // Each `FieldDescription`/`FieldError` INSTANCE generates and registers its
61
+ // own id (see below) — two lists, not one shared slot per part type, so
62
+ // more than one of either part under one `FieldRoot` gets distinct ids
63
+ // instead of colliding, and unmounting one instance only ever removes that
64
+ // instance's own id from its own list (never a sibling's).
65
+ const [descriptionIds, setDescriptionIds] = useState<readonly string[]>([]);
66
+ const registerDescription = useCallback((descId: string) => {
67
+ setDescriptionIds((prev) => (prev.includes(descId) ? prev : [...prev, descId]));
68
+ }, []);
69
+ const unregisterDescription = useCallback((descId: string) => {
70
+ setDescriptionIds((prev) => (prev.includes(descId) ? prev.filter((x) => x !== descId) : prev));
71
+ }, []);
72
+
73
+ const [errorIds, setErrorIds] = useState<readonly string[]>([]);
74
+ const registerError = useCallback((errId: string) => {
75
+ setErrorIds((prev) => (prev.includes(errId) ? prev : [...prev, errId]));
76
+ }, []);
77
+ const unregisterError = useCallback((errId: string) => {
78
+ setErrorIds((prev) => (prev.includes(errId) ? prev.filter((x) => x !== errId) : prev));
79
+ }, []);
80
+
81
+ // Fixed semantic order — every description before every error — regardless
82
+ // of DOM order or registration order, so "description placed before the
83
+ // control" reorders the VISIBLE layout without reordering the announced
84
+ // description, and a second description/error is appended rather than
85
+ // replacing the first.
86
+ const describedBy = useMemo(() => {
87
+ const ids = [...descriptionIds, ...errorIds];
88
+ return ids.length > 0 ? ids.join(" ") : undefined;
89
+ }, [descriptionIds, errorIds]);
90
+
91
+ // `FieldLabel`'s `htmlFor` can only point to ONE control, so it binds to
92
+ // whichever `FieldControl` registers FIRST (JSX/mount order) — the common
93
+ // single-control case gets this for free; a multi-control row (e.g.
94
+ // first/last name) still labels the first field, and every additional
95
+ // control needs its own explicit `id` the same way any two form controls
96
+ // would (no new coordination prop invented for this).
97
+ const [controlOrder, setControlOrder] = useState<readonly string[]>([]);
98
+ const registerControl = useCallback((controlId: string) => {
99
+ setControlOrder((prev) => (prev.includes(controlId) ? prev : [...prev, controlId]));
100
+ }, []);
101
+ const unregisterControl = useCallback((controlId: string) => {
102
+ setControlOrder((prev) =>
103
+ prev.includes(controlId) ? prev.filter((x) => x !== controlId) : prev,
104
+ );
105
+ }, []);
106
+ const labelFor = controlOrder[0];
107
+
108
+ const value = useMemo<FieldContextValue>(
109
+ () => ({
110
+ labelFor,
111
+ registerControl,
112
+ unregisterControl,
113
+ invalid,
114
+ required,
115
+ describedBy,
116
+ registerDescription,
117
+ unregisterDescription,
118
+ registerError,
119
+ unregisterError,
120
+ }),
121
+ [
122
+ labelFor,
123
+ registerControl,
124
+ unregisterControl,
125
+ invalid,
126
+ required,
127
+ describedBy,
128
+ registerDescription,
129
+ unregisterDescription,
130
+ registerError,
131
+ unregisterError,
132
+ ],
133
+ );
134
+
135
+ return (
136
+ <FieldContext.Provider value={value}>
137
+ <div ref={ref} data-slot="field" className={cn("space-y-2", className)} {...props}>
138
+ {children}
139
+ </div>
140
+ </FieldContext.Provider>
141
+ );
142
+ });
143
+
144
+ // ── FieldLabel ────────────────────────────────────────────────────────────
145
+
146
+ export const FieldLabel = forwardRef<
147
+ ElementRef<typeof Label>,
148
+ ComponentPropsWithoutRef<typeof Label>
149
+ >(function FieldLabel({ className, ...props }, ref) {
150
+ const { labelFor, invalid } = useFieldContext("FieldLabel");
151
+ return (
152
+ <Label
153
+ ref={ref}
154
+ htmlFor={labelFor}
155
+ data-slot="field-label"
156
+ className={cn(
157
+ "transition-colors duration-fast ease-standard",
158
+ invalid && "text-destructive-text",
159
+ className,
160
+ )}
161
+ {...props}
162
+ />
163
+ );
164
+ });
165
+
166
+ // ── FieldControl ──────────────────────────────────────────────────────────
167
+
168
+ export interface FieldControlProps {
169
+ /**
170
+ * The single field control (`Input`, `Textarea`, `Select`, …). Receives
171
+ * `id`/`aria-describedby`/`aria-invalid`/`aria-required` via a Radix
172
+ * `Slot` — must be a single element that forwards those props to a real
173
+ * form control. Compose more than one `FieldControl` inside one
174
+ * `FieldRoot` for a multi-control row (each gets its own independent id;
175
+ * give every control but the first its own explicit `id`, the same way
176
+ * you would for any two form controls that must not collide). `FieldLabel`
177
+ * can only associate with ONE control (the first to mount), so give every
178
+ * OTHER control in the row its own `aria-label` — a shared visual label
179
+ * plus a placeholder is not a real accessible name for the rest.
180
+ */
181
+ children: ReactElement<{
182
+ id?: string;
183
+ "aria-describedby"?: string;
184
+ }>;
185
+ }
186
+
187
+ export const FieldControl = forwardRef<ElementRef<typeof Slot>, FieldControlProps>(
188
+ function FieldControl({ children }, ref) {
189
+ const { invalid, required, describedBy, registerControl, unregisterControl } =
190
+ useFieldContext("FieldControl");
191
+ // Independent per-instance fallback id — guarantees uniqueness with no
192
+ // coordination needed when more than one `FieldControl` shares a
193
+ // `FieldRoot` (e.g. a first/last-name row).
194
+ const generatedId = useId();
195
+
196
+ // Same "child wins, but COMPOSE rather than discard" merge FieldRow uses:
197
+ // Radix `Slot` merges child props OVER slot props, so a control that
198
+ // already carries its own `id`/`aria-describedby` must not silently lose
199
+ // the label association / description-error wiring.
200
+ const control = children;
201
+ const childProps = control.props;
202
+ const id = childProps.id ?? generatedId;
203
+ const composedDescribedBy =
204
+ [childProps["aria-describedby"] ?? null, describedBy ?? null].filter(Boolean).join(" ") ||
205
+ undefined;
206
+
207
+ useLayoutEffect(() => {
208
+ registerControl(id);
209
+ return () => unregisterControl(id);
210
+ }, [id, registerControl, unregisterControl]);
211
+
212
+ return (
213
+ <Slot
214
+ ref={ref}
215
+ id={id}
216
+ aria-describedby={composedDescribedBy}
217
+ aria-invalid={invalid}
218
+ aria-required={required || undefined}
219
+ data-slot="field-control"
220
+ >
221
+ {cloneElement(control, { id, "aria-describedby": composedDescribedBy })}
222
+ </Slot>
223
+ );
224
+ },
225
+ );
226
+
227
+ // ── FieldDescription ──────────────────────────────────────────────────────
228
+
229
+ /**
230
+ * Optional field help text. Renders nothing — and registers no
231
+ * `aria-describedby` reference — when `children` is falsy (`false`/`0`/`""`/
232
+ * `null`/`undefined`), an empty array, or an array containing only falsy
233
+ * values (`{list.map(...)}` on an empty list; `[a && "x", b && "y"]` with
234
+ * both false) — matching `FieldRow`'s `description ? … : null` convention:
235
+ * `{hint && <FieldDescription>{hint}</FieldDescription>}` is the supported
236
+ * way to express "no description".
237
+ *
238
+ * **Known limit:** a child that is itself a COMPONENT that renders nothing
239
+ * (returns `null`/an empty fragment) is not knowable from the element
240
+ * before render, so it still produces an empty paragraph that
241
+ * `aria-describedby` points at. `FieldRow` and the wider React ecosystem
242
+ * share this limit — pass a falsy child instead of a component that may
243
+ * render nothing.
244
+ */
245
+ export const FieldDescription = forwardRef<
246
+ HTMLParagraphElement,
247
+ HTMLAttributes<HTMLParagraphElement>
248
+ >(function FieldDescription({ className, children, id, ...props }, ref) {
249
+ const { registerDescription, unregisterDescription } = useFieldContext("FieldDescription");
250
+ // Own id per INSTANCE — two `FieldDescription`s under one `FieldRoot` (a hint
251
+ // above the control and a hint below it) must not collide on one shared id.
252
+ const instanceId = useId();
253
+ const generatedId = `${instanceId}-description`;
254
+ // Caller-supplied `id` wins — same "child wins" resolution `FieldControl`
255
+ // already uses (`childProps.id ?? generatedId`). Resolving ONE effective id
256
+ // up front, and destructuring `id` out of `...props` above, means the value
257
+ // registered into `aria-describedby` and the value actually rendered on the
258
+ // `<p>` can never diverge (PR #97 review finding 3).
259
+ const descriptionId = id ?? generatedId;
260
+ // Same falsy-content convention `FieldRow` uses (`description ? … : null`)
261
+ // — see `hasRenderableContent` (`../../lib/has-renderable-content`) for
262
+ // the full rationale, shared with `FieldError` below and `FieldRow`.
263
+ const hasContent = hasRenderableContent(children);
264
+
265
+ useLayoutEffect(() => {
266
+ if (!hasContent) return undefined;
267
+ registerDescription(descriptionId);
268
+ return () => unregisterDescription(descriptionId);
269
+ }, [hasContent, descriptionId, registerDescription, unregisterDescription]);
270
+
271
+ if (!hasContent) return null;
272
+
273
+ return (
274
+ <p
275
+ ref={ref}
276
+ id={descriptionId}
277
+ data-slot="field-description"
278
+ className={cn("text-body text-muted-foreground", className)}
279
+ {...props}
280
+ >
281
+ {children}
282
+ </p>
283
+ );
284
+ });
285
+
286
+ // ── FieldError ────────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Validation error text, announced via `role="alert"`. Renders nothing —
290
+ * and registers no `aria-describedby` reference — when `children` is falsy
291
+ * (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
292
+ * containing only falsy values (`{errors.map(...)}` on an empty array is
293
+ * exactly how a real form renders "no errors") — matching `FieldRow`'s
294
+ * `error ? … : null` convention: `{error && <FieldError>{error}</FieldError>}`
295
+ * is the supported way to express "no error".
296
+ *
297
+ * **Known limit:** a child that is itself a COMPONENT that renders nothing
298
+ * (returns `null`/an empty fragment) is not knowable from the element
299
+ * before render, so it still produces an empty `role="alert"` element that
300
+ * `aria-describedby` points at (a screen reader announces an empty alert).
301
+ * `FieldRow` and the wider React ecosystem share this limit — pass a falsy
302
+ * child instead of a component that may render nothing.
303
+ */
304
+ export const FieldError = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
305
+ function FieldError({ className, children, id, ...props }, ref) {
306
+ const { registerError, unregisterError } = useFieldContext("FieldError");
307
+ // Own id per INSTANCE — same reasoning as `FieldDescription`.
308
+ const instanceId = useId();
309
+ const generatedId = `${instanceId}-error`;
310
+ // Caller-supplied `id` wins — same resolution as `FieldDescription`/
311
+ // `FieldControl` (PR #97 review finding 3: this had the identical
312
+ // ordering bug as `FieldDescription`).
313
+ const errorId = id ?? generatedId;
314
+ // Same falsy-content convention as `FieldDescription`/`FieldRow`'s
315
+ // `error ? … : null` — see `hasRenderableContent`
316
+ // (`../../lib/has-renderable-content`) for the full rationale.
317
+ const hasContent = hasRenderableContent(children);
318
+
319
+ useLayoutEffect(() => {
320
+ if (!hasContent) return undefined;
321
+ registerError(errorId);
322
+ return () => unregisterError(errorId);
323
+ }, [hasContent, errorId, registerError, unregisterError]);
324
+
325
+ if (!hasContent) return null;
326
+
327
+ return (
328
+ <p
329
+ ref={ref}
330
+ id={errorId}
331
+ role="alert"
332
+ data-slot="field-error"
333
+ className={cn("text-body font-medium text-destructive-text", className)}
334
+ {...props}
335
+ >
336
+ {children}
337
+ </p>
338
+ );
339
+ },
340
+ );
@@ -0,0 +1,9 @@
1
+ export {
2
+ FieldRoot,
3
+ FieldLabel,
4
+ FieldControl,
5
+ FieldDescription,
6
+ FieldError,
7
+ type FieldRootProps,
8
+ type FieldControlProps,
9
+ } from "./field";
@@ -1,3 +1,4 @@
1
+ import { useState } from "react";
1
2
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
3
  import { expect } from "storybook/test";
3
4
  import { Input } from "../input";
@@ -12,7 +13,7 @@ const meta = {
12
13
  docs: {
13
14
  description: {
14
15
  component:
15
- "Label/description/error/aria-describedby wiring for a single field OUTSIDE a react-hook-form context. See Forms/Form for the RHF-bound equivalent.",
16
+ "Label/description/error/aria-describedby wiring for a single field OUTSIDE a react-hook-form context. See Forms/Form for the RHF-bound equivalent.\n\n**Runtime-agnostic (#26).** `FieldRow` never imports `react-hook-form` — it renders entirely from plain `label`/`description`/`error` props, so it works just as readily with a `useState`-controlled field, Formik, Final Form, TanStack Form or a bespoke reducer. `react-hook-form`/`@hookform/resolvers` are optional peers of `@elabs-ai/components-ui`, so a consumer who only uses `FieldRow` never installs or bundles either — the RHF-bound `Form` family lives on the separate `@elabs-ai/components-ui/form` subpath. See the “Driven by external state” story below for a worked, non-RHF example (including the error state), and `docs/CONSUMING.md` for the Formik and plain-`useState` snippets. Reach for the RHF-bound `Form`/`FormField` family (Forms/Form) instead once the field already lives inside a `react-hook-form` `<FormProvider>` — `FieldRow` covers the gap outside it, it does not replace that family.",
16
17
  },
17
18
  },
18
19
  },
@@ -112,3 +113,86 @@ export const WithTextarea: Story = {
112
113
  expect(canvas.getByRole("textbox")).toHaveFocus();
113
114
  },
114
115
  };
116
+
117
+ // A small local email-shape check — deliberately NOT `event.target.checkValidity()`.
118
+ // Verified against real `<input type="email">` constraint validation (#26
119
+ // fix-round-1 finding 2): the native HTML5 email format does NOT require a
120
+ // dot in the domain, so `checkValidity()` treats "jane@invalid" as VALID —
121
+ // it would not reproduce the contradiction this demo exists to guard
122
+ // against. A `local-part@domain.tld` regex is the smallest check that
123
+ // actually agrees with "looks like a real email", so it can't clear the
124
+ // error for "@", "jane@" or "jane@invalid" the way the old
125
+ // `value.includes("@")` predicate did.
126
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
127
+
128
+ /**
129
+ * Stands a plain `useState`-controlled value in for "any runtime" (a bespoke
130
+ * reducer, Formik's `meta.error`, TanStack Form's `field.state.meta.errors`,
131
+ * …) — `FieldRow` only ever reads the `label`/`description`/`error` props it
132
+ * is handed, so the same shape works for all of them. No `FormProvider`/RHF
133
+ * context exists anywhere in this component's tree.
134
+ */
135
+ function EmailField({ initialValue }: { initialValue: string }) {
136
+ const [value, setValue] = useState(initialValue);
137
+ const error = EMAIL_RE.test(value) ? undefined : "Enter a valid email address.";
138
+
139
+ return (
140
+ <div className="w-72">
141
+ <FieldRow label="Email" description="We'll only use this to send receipts." error={error}>
142
+ <Input type="email" value={value} onChange={(event) => setValue(event.target.value)} />
143
+ </FieldRow>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * Issue #26's fourth ask: a worked example of `FieldRow` driven by a form
150
+ * runtime that is NOT react-hook-form — this is the story the docs and PR
151
+ * point readers at as "starts already invalid so the `aria-invalid`/
152
+ * `role="alert"` wiring is visible without interaction". Its play function
153
+ * therefore only ASSERTS the error state; it never resolves it, so the
154
+ * canvas a reader inspects (both in the story view and on the autodocs page,
155
+ * where Storybook runs `play` on mount the same way) stays on the
156
+ * documented error state. See "Driven by external state — validity
157
+ * transitions" below for the invalid→valid interaction instead (#26
158
+ * fix-round-1 finding 1 — the previous version of this story cleared its own
159
+ * error inside `play`, so the state the docs claimed to show never actually
160
+ * survived a render).
161
+ */
162
+ export const RuntimeAgnostic: Story = {
163
+ name: "Driven by external state (no react-hook-form)",
164
+ render: () => <EmailField initialValue="not-an-email" />,
165
+ play: async ({ canvas }) => {
166
+ const input = canvas.getByRole("textbox");
167
+ expect(input).toHaveAttribute("aria-invalid", "true");
168
+ const alert = canvas.getByRole("alert");
169
+ expect(alert).toHaveTextContent("Enter a valid email address.");
170
+ expect(input.getAttribute("aria-describedby")?.split(" ")).toContain(alert.id);
171
+ },
172
+ };
173
+
174
+ /**
175
+ * The invalid→valid half of the lifecycle, kept in its own story so the
176
+ * documented error example above (`RuntimeAgnostic`) never settles into the
177
+ * success state on render (#26 fix-round-1 finding 1). Also locks finding
178
+ * 2: "jane@invalid" has an `@` but no valid domain and must NOT clear the
179
+ * error — `value.includes("@")` used to accept it.
180
+ */
181
+ export const RuntimeAgnosticValidation: Story = {
182
+ name: "Driven by external state — validity transitions",
183
+ render: () => <EmailField initialValue="not-an-email" />,
184
+ play: async ({ canvas, userEvent }) => {
185
+ const input = canvas.getByRole("textbox");
186
+ expect(input).toHaveAttribute("aria-invalid", "true");
187
+
188
+ await userEvent.clear(input);
189
+ await userEvent.type(input, "jane@invalid");
190
+ expect(input).toHaveAttribute("aria-invalid", "true");
191
+ expect(canvas.getByRole("alert")).toHaveTextContent("Enter a valid email address.");
192
+
193
+ await userEvent.clear(input);
194
+ await userEvent.type(input, "jane@example.com");
195
+ expect(input).toHaveAttribute("aria-invalid", "false");
196
+ expect(canvas.queryByRole("alert")).toBeNull();
197
+ },
198
+ };
@@ -185,4 +185,149 @@ describe("FieldRow", () => {
185
185
  for (const id of frDescribedBy) expect(document.getElementById(id)).not.toBeNull();
186
186
  expect(frInput).toHaveAttribute("aria-invalid", "true");
187
187
  });
188
+
189
+ // #26 — the runtime-agnostic contract: FieldRow must wire aria-invalid /
190
+ // aria-describedby / role="alert" from PLAIN external state (any form
191
+ // runtime — a bespoke reducer, Formik's `meta.error`, TanStack Form's
192
+ // `field.state.meta.errors`, …), with NO react-hook-form FormProvider
193
+ // mounted anywhere in the tree. This locks the story's documented example:
194
+ // if FieldRow ever grew an accidental RHF coupling (e.g. reading
195
+ // useFormContext internally), this render would throw before any
196
+ // assertion below ran.
197
+ it("wires aria-invalid + aria-describedby from a field driven entirely by external state, with no form-runtime provider mounted", () => {
198
+ function ExternallyControlledField({ error }: { error?: string }) {
199
+ // Stands in for "any form runtime": FieldRow only ever reads the
200
+ // label/description/error props it is handed, whatever produced them.
201
+ return (
202
+ <FieldRow label="Email" error={error}>
203
+ <Input value="" onChange={() => {}} />
204
+ </FieldRow>
205
+ );
206
+ }
207
+
208
+ const { rerender } = render(<ExternallyControlledField />);
209
+ const input = screen.getByRole("textbox");
210
+ expect(input).toHaveAttribute("aria-invalid", "false");
211
+ expect(input).not.toHaveAttribute("aria-describedby");
212
+ expect(screen.queryByRole("alert")).toBeNull();
213
+
214
+ rerender(<ExternallyControlledField error="Enter a valid email address." />);
215
+ const errorNode = screen.getByRole("alert");
216
+ expect(errorNode).toHaveTextContent("Enter a valid email address.");
217
+ expect(input).toHaveAttribute("aria-invalid", "true");
218
+ expect(input.getAttribute("aria-describedby")).toBe(errorNode.id);
219
+ });
220
+
221
+ // #93 (B) — FieldRow used bare truthiness (`error ? … : null`) to gate the
222
+ // alert, the id contribution and aria-invalid, so an empty/all-falsy ARRAY
223
+ // (every array is truthy in JS) still produced a referenced, empty
224
+ // role="alert" and marked the control invalid with nothing to explain why.
225
+ // `Field*` already handles this shape correctly (field.test.tsx) — this
226
+ // locks the same behavior on FieldRow.
227
+ it("renders no error element, no aria-invalid, and no aria-describedby reference for an empty array or an array of only falsy children", () => {
228
+ const { rerender } = render(
229
+ <FieldRow label="Email" error={[]}>
230
+ <Input />
231
+ </FieldRow>,
232
+ );
233
+ let input = screen.getByRole("textbox");
234
+ expect(screen.queryByRole("alert")).toBeNull();
235
+ expect(input).toHaveAttribute("aria-invalid", "false");
236
+ expect(input).not.toHaveAttribute("aria-describedby");
237
+
238
+ rerender(
239
+ <FieldRow label="Email" error={[false, null]}>
240
+ <Input />
241
+ </FieldRow>,
242
+ );
243
+ input = screen.getByRole("textbox");
244
+ expect(screen.queryByRole("alert")).toBeNull();
245
+ expect(input).toHaveAttribute("aria-invalid", "false");
246
+ expect(input).not.toHaveAttribute("aria-describedby");
247
+ });
248
+
249
+ // Mirrors field.test.tsx's round-4 fix: `Children.toArray` drops
250
+ // null/undefined/booleans but KEEPS "" (and 0), so ["", ""] survives
251
+ // toArray with length 2 and must still be treated as no content.
252
+ it("renders no error element for an array of only empty strings", () => {
253
+ render(
254
+ <FieldRow label="Email" error={["", ""]}>
255
+ <Input />
256
+ </FieldRow>,
257
+ );
258
+ const input = screen.getByRole("textbox");
259
+ expect(screen.queryByRole("alert")).toBeNull();
260
+ expect(input).toHaveAttribute("aria-invalid", "false");
261
+ expect(input).not.toHaveAttribute("aria-describedby");
262
+ });
263
+
264
+ it("renders no description element and no aria-describedby reference for an empty array or an array of only falsy children", () => {
265
+ const { container, rerender } = render(
266
+ <FieldRow label="Bio" description={[]}>
267
+ <Input />
268
+ </FieldRow>,
269
+ );
270
+ let input = screen.getByRole("textbox");
271
+ expect(container.querySelector('[data-slot="field-row-description"]')).toBeNull();
272
+ expect(input).not.toHaveAttribute("aria-describedby");
273
+
274
+ rerender(
275
+ <FieldRow label="Bio" description={[false, null]}>
276
+ <Input />
277
+ </FieldRow>,
278
+ );
279
+ input = screen.getByRole("textbox");
280
+ expect(container.querySelector('[data-slot="field-row-description"]')).toBeNull();
281
+ expect(input).not.toHaveAttribute("aria-describedby");
282
+ });
283
+
284
+ it("renders no description element for an array of only empty strings", () => {
285
+ const { container } = render(
286
+ <FieldRow label="Bio" description={["", ""]}>
287
+ <Input />
288
+ </FieldRow>,
289
+ );
290
+ const input = screen.getByRole("textbox");
291
+ expect(container.querySelector('[data-slot="field-row-description"]')).toBeNull();
292
+ expect(input).not.toHaveAttribute("aria-describedby");
293
+ });
294
+
295
+ // Positive control — the fix above must not suppress a real error: a
296
+ // non-empty `error` still renders the alert, the aria-describedby
297
+ // reference and aria-invalid="true".
298
+ it("still renders the alert, the aria-describedby reference and aria-invalid=true for a non-empty error", () => {
299
+ render(
300
+ <FieldRow label="Email" error="Required.">
301
+ <Input />
302
+ </FieldRow>,
303
+ );
304
+ const input = screen.getByRole("textbox");
305
+ const alert = screen.getByRole("alert");
306
+ expect(alert).toHaveTextContent("Required.");
307
+ expect(input).toHaveAttribute("aria-invalid", "true");
308
+ expect(input.getAttribute("aria-describedby")).toBe(alert.id);
309
+ });
310
+
311
+ // #93 (A) — KNOWN LIMIT, not desired behavior — mirrors field.test.tsx's
312
+ // FieldError pin: a value that is itself a component returning `null` is
313
+ // not knowable from the element before render (a React ecosystem-wide
314
+ // limit), so it still produces an empty `role="alert"` referenced by
315
+ // aria-describedby. This test PINS the current, documented-limit behavior
316
+ // so a future change to it is a deliberate decision, not a silent
317
+ // regression — it is not an endorsement. See the "Known limit" note on
318
+ // `FieldRowProps.error`.
319
+ it("[KNOWN LIMIT] still renders an empty, referenced alert when error is a component that renders null", () => {
320
+ function RendersNull() {
321
+ return null;
322
+ }
323
+ render(
324
+ <FieldRow label="Email" error={<RendersNull />}>
325
+ <Input />
326
+ </FieldRow>,
327
+ );
328
+ const input = screen.getByRole("textbox");
329
+ const alert = screen.getByRole("alert");
330
+ expect(alert).toBeEmptyDOMElement();
331
+ expect(input.getAttribute("aria-describedby")).toBe(alert.id);
332
+ });
188
333
  });