@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,1545 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SchemaForm — a spec-driven configuration-form renderer (issue #22).
5
+ *
6
+ * A product describes a form (connector settings, environment variables, an
7
+ * auth method picker) as data; SchemaForm renders it; the app receives
8
+ * structured `{ formName, values }` on submit. This is the GENERAL, app-UI
9
+ * sibling of `@elabs-ai/components-ai`'s chat-scoped `MessageForm` — see
10
+ * `schema-form-spec.ts` for why the two stay separate schemas rather than one
11
+ * generalized union.
12
+ *
13
+ * Design bar (mirrors MessageForm/AutoChart/ChangeReview):
14
+ * - Spec-driven, zod-validated. The spec author never chooses look.
15
+ * - Never throws on bad input. A malformed spec → `SchemaFormFallback`.
16
+ * - Compound + lifted state, controlled AND uncontrolled: `SchemaFormProvider`
17
+ * owns the values; the parts read a context.
18
+ * - Tokens only; keyboard-operable; inline errors; focus the first error on
19
+ * submit; a submitted form renders inert with its values visible.
20
+ * - Submit control is NEVER natively `disabled` while transiently blocked
21
+ * (submitting) — `aria-disabled` + a click/submit handler guard, so a
22
+ * keyboard user is never dropped from the focus order right after they
23
+ * used it (interaction-guidelines.md). An explicit, caller-set `disabled`
24
+ * (the whole form is read-only) stays native — that is a deliberate,
25
+ * durable removal from the tab order, not a transient auto-flip.
26
+ *
27
+ * Composes `@elabs-ai/components-ui` inputs (`ListEditor`, `KeyValueEditor`,
28
+ * `FileUpload`, `AdvancedGroup`, `Tabs`) — it does NOT re-invent field
29
+ * primitives.
30
+ *
31
+ * Compound structure (named exports, the Card/CardHeader convention):
32
+ * <SchemaFormProvider> — lifted state (values + errors + actions)
33
+ * <SchemaFormRoot> — the <form> element (never nest inside another form)
34
+ * <SchemaFormFields> — every field, or place <SchemaFormField> yourself
35
+ * <SchemaFormTestAction> — OPT-IN: a form/group-level "Test connection"
36
+ * affordance, independent of field validity,
37
+ * never gating submit (not in the default
38
+ * `SchemaForm` composition — place it yourself)
39
+ * <SchemaFormSubmit> — submit button / submitting spinner / submitted note
40
+ *
41
+ * `fromJsonSchema()` (`from-json-schema.ts`) is a separate, narrow adapter
42
+ * that maps a documented JSON Schema subset onto this module's `FieldSpec`
43
+ * vocabulary — see that file's doc comment for exactly what it supports.
44
+ */
45
+
46
+ import {
47
+ createContext,
48
+ forwardRef,
49
+ use,
50
+ useCallback,
51
+ useEffect,
52
+ useId,
53
+ useMemo,
54
+ useRef,
55
+ useState,
56
+ type FormEvent,
57
+ type HTMLAttributes,
58
+ type MouseEvent,
59
+ type ReactNode,
60
+ } from "react";
61
+ import { Check } from "lucide-react";
62
+ import { cn } from "../../lib/cn";
63
+ import { useLocale } from "../locale-provider";
64
+ import { Badge } from "../badge";
65
+ import { Button } from "../button";
66
+ import { Checkbox } from "../checkbox";
67
+ import { Input } from "../input";
68
+ import { Label } from "../label";
69
+ import { NumberInput } from "../number-input";
70
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select";
71
+ import { Skeleton } from "../skeleton";
72
+ import { Spinner } from "../spinner";
73
+ import { StatusBadge } from "../status-badge";
74
+ import { Textarea } from "../textarea";
75
+ import { ListEditor } from "../list-editor";
76
+ import { KeyValueEditor, type KeyValueRow } from "../key-value-editor";
77
+ import {
78
+ FileUpload,
79
+ FileUploadDropzone,
80
+ FileUploadList,
81
+ FileUploadItem,
82
+ useFileUpload,
83
+ } from "../file-upload";
84
+ import { AdvancedGroup } from "../advanced-group";
85
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "../tabs";
86
+
87
+ import {
88
+ checkFileIssue,
89
+ collectValidatableFields,
90
+ fieldLabel,
91
+ findFieldByName,
92
+ initialFormValues,
93
+ isFieldVisible,
94
+ normalizeFormSpec,
95
+ optionLabel,
96
+ optionValue,
97
+ validateForm,
98
+ type FieldSpec,
99
+ type FormSpec,
100
+ type FormSubmitState,
101
+ type FormValue,
102
+ type FormValues,
103
+ type GroupFieldSpec,
104
+ type GroupItemSpec,
105
+ type NormalizedFormSpec,
106
+ } from "./schema-form-spec";
107
+
108
+ // ─── Context ────────────────────────────────────────────────────────────────
109
+
110
+ interface SchemaFormContextValue {
111
+ spec: NormalizedFormSpec;
112
+ values: FormValues;
113
+ /**
114
+ * `values` with every field's default/empty fallback applied (via
115
+ * `effectiveValue`) and every currently-hidden field's subtree stripped —
116
+ * the SAME object `mergedValues`/submit/validation resolve against. Every
117
+ * `visibleWhen` check (the render loops AND `SchemaFormField` itself) must
118
+ * read THIS, never raw `values`: a controlled form can omit a controller
119
+ * field that carries a spec default, in which case the rendered control
120
+ * already shows the default (`effectiveValue`) while raw `values` is still
121
+ * `undefined` — checking raw `values` there hides a field validation then
122
+ * requires, with no visible control left to fix it.
123
+ */
124
+ effectiveValues: FormValues;
125
+ /** Per-field error text, only populated after a submit attempt. */
126
+ errors: Record<string, string | null>;
127
+ setValue: (name: string, value: FormValue) => void;
128
+ submit: () => void;
129
+ reset: () => void;
130
+ /** True once submit has been attempted (drives error visibility). */
131
+ attempted: boolean;
132
+ submitted: boolean;
133
+ submitting: boolean;
134
+ disabled: boolean;
135
+ loading: boolean;
136
+ /** A terminal, form-level submission error (e.g. "Couldn't save settings"). */
137
+ error: ReactNode;
138
+ formId: string;
139
+ headingId: string;
140
+ }
141
+
142
+ const SchemaFormContext = createContext<SchemaFormContextValue | null>(null);
143
+
144
+ function useSchemaFormContext(): SchemaFormContextValue {
145
+ const ctx = use(SchemaFormContext);
146
+ if (!ctx) {
147
+ throw new Error("SchemaForm sub-components must be rendered inside <SchemaFormProvider>.");
148
+ }
149
+ return ctx;
150
+ }
151
+
152
+ /** Stable DOM id for a field's primary control (used for label + focus). */
153
+ function controlId(formId: string, name: string): string {
154
+ return `${formId}-field-${name}`;
155
+ }
156
+ function descId(formId: string, name: string): string {
157
+ return `${formId}-desc-${name}`;
158
+ }
159
+ function errorId(formId: string, name: string): string {
160
+ return `${formId}-error-${name}`;
161
+ }
162
+
163
+ /** The effective value for a field (state value, else its default/empty). */
164
+ function effectiveValue(field: FieldSpec, values: FormValues): FormValue {
165
+ const v = values[field.name];
166
+ if (v !== undefined) return v;
167
+ if (field.type === "group") return field.default ?? field.groups[0]?.key;
168
+ if ("default" in field && field.default !== undefined) return field.default;
169
+ if (field.type === "boolean") return false;
170
+ if (field.type === "multi-enum" || field.type === "list") return [];
171
+ if (field.type === "key-value") return [];
172
+ if (field.type === "file") return [];
173
+ return undefined;
174
+ }
175
+
176
+ /** Remove every field's key (recursively, including nested group branches) from `values`. */
177
+ function stripFields(fields: FieldSpec[], values: FormValues): void {
178
+ for (const field of fields) {
179
+ delete values[field.name];
180
+ if (field.type === "group") {
181
+ for (const group of field.groups) stripFields(group.fields, values);
182
+ }
183
+ }
184
+ }
185
+
186
+ /** The active branch of a `variant: "tabs"` group, given its own (possibly-just-computed) value. */
187
+ function activeTabBranch(field: GroupFieldSpec, activeKey: FormValue): GroupItemSpec | undefined {
188
+ const key = typeof activeKey === "string" ? activeKey : undefined;
189
+ return (
190
+ field.groups.find((g) => g.key === key) ??
191
+ field.groups.find((g) => g.key === field.default) ??
192
+ field.groups[0]
193
+ );
194
+ }
195
+
196
+ // ─── Provider ───────────────────────────────────────────────────────────────
197
+
198
+ export interface SchemaFormProviderProps {
199
+ /** A validated/normalized spec (a plain `FormSpec` also satisfies this). */
200
+ spec: NormalizedFormSpec;
201
+ /**
202
+ * Controlled values. When provided the component is controlled and
203
+ * `onChange` is the only way to update state.
204
+ */
205
+ values?: FormValues;
206
+ /** Called with the next full values object on any field change. */
207
+ onChange?: (values: FormValues) => void;
208
+ /** Called with `{ formName, values }` when a valid form is submitted. */
209
+ onSubmit?: (state: FormSubmitState) => void;
210
+ /** Disable every control (form is read-only). A deliberate, durable state. */
211
+ disabled?: boolean;
212
+ /** Terminal submitted state: controls are inert, values visible, no submit. */
213
+ submitted?: boolean;
214
+ /** In-flight submit: controls transiently blocked, submit shows a spinner. */
215
+ submitting?: boolean;
216
+ /** No fields to render yet (e.g. the spec is still being fetched) → skeleton. */
217
+ loading?: boolean;
218
+ /** A terminal, form-level submission error rendered above the submit control. */
219
+ error?: ReactNode;
220
+ children: ReactNode;
221
+ }
222
+
223
+ /**
224
+ * Lifts the form values. Controlled (pass `values`) or uncontrolled.
225
+ * Derives `isControlled = values !== undefined` and never flips modes.
226
+ */
227
+ export function SchemaFormProvider({
228
+ spec,
229
+ values: valuesProp,
230
+ onChange,
231
+ onSubmit,
232
+ disabled = false,
233
+ submitted = false,
234
+ submitting = false,
235
+ loading = false,
236
+ error = null,
237
+ children,
238
+ }: SchemaFormProviderProps) {
239
+ const formId = useId();
240
+ const headingId = `${formId}-title`;
241
+
242
+ const isControlled = valuesProp !== undefined;
243
+ const [internalValues, setInternalValues] = useState<FormValues>(() =>
244
+ initialFormValues(spec.fields),
245
+ );
246
+ const [attempted, setAttempted] = useState(false);
247
+
248
+ // Re-seed the UNCONTROLLED values when the caller swaps in a genuinely
249
+ // different spec (a different `formName`) — otherwise a parent that
250
+ // fetches a new spec into the same mounted <SchemaForm> would keep
251
+ // showing the PREVIOUS spec's typed-in values (and even submit them
252
+ // under fields the new spec never declared). Keyed on `formName`, not
253
+ // object identity: `SchemaForm`'s wrapper calls `normalizeFormSpec(spec)`
254
+ // fresh on every render (unmemoized), so an identity/reference check
255
+ // would reset on every keystroke. Controlled forms are unaffected — the
256
+ // caller already owns `values` and decides when to reset them. This is
257
+ // the "adjust state during render" pattern (not a useEffect) so the reset
258
+ // is visible in the SAME render as the new spec, with no stale-values frame.
259
+ const lastFormNameRef = useRef(spec.formName);
260
+ if (!isControlled && lastFormNameRef.current !== spec.formName) {
261
+ lastFormNameRef.current = spec.formName;
262
+ setInternalValues(initialFormValues(spec.fields));
263
+ }
264
+
265
+ const resolvedValues = isControlled ? (valuesProp as FormValues) : internalValues;
266
+
267
+ const setValue = useCallback(
268
+ (name: string, value: FormValue) => {
269
+ const base = isControlled ? (valuesProp as FormValues) : internalValues;
270
+ const next: FormValues = { ...base, [name]: value };
271
+ if (!isControlled) setInternalValues(next);
272
+ onChange?.(next);
273
+ },
274
+ [isControlled, valuesProp, internalValues, onChange],
275
+ );
276
+
277
+ const mergedValues = useCallback(
278
+ (base: FormValues) => {
279
+ const merged: FormValues = { ...base };
280
+ const fill = (fields: FieldSpec[]) => {
281
+ for (const field of fields) {
282
+ // A field (or, for a hidden `group`, its whole subtree) whose
283
+ // `visibleWhen` does not currently hold must not participate in
284
+ // submission — strip it rather than merely skip re-computing it,
285
+ // because `merged` starts as a spread of `base` and so already
286
+ // carries any stale value the field held from BEFORE its
287
+ // controller made it hidden (e.g. a secret typed in while an
288
+ // "oauth" branch was active, still present after switching to
289
+ // "apikey"). Mirrors `collectValidatableFields`'s own
290
+ // visibility check so what's validated/submitted never disagrees
291
+ // with what's shown.
292
+ if (!isFieldVisible(field, merged)) {
293
+ delete merged[field.name];
294
+ if (field.type === "group") {
295
+ for (const group of field.groups) stripFields(group.fields, merged);
296
+ }
297
+ continue;
298
+ }
299
+ merged[field.name] = effectiveValue(field, merged);
300
+ if (field.type !== "group") continue;
301
+ if (field.variant === "tabs") {
302
+ // Only the ACTIVE branch's fields participate — mirrors
303
+ // `collectValidatableFields`'s own active-branch resolution, so
304
+ // what gets validated and what gets submitted agree. The
305
+ // inactive branch(es) are stripped from `merged` rather than
306
+ // left as-is: a value typed into "API key" before switching to
307
+ // "OAuth" must not still ride along (and be treated as valid)
308
+ // once OAuth is what actually submits.
309
+ const active = activeTabBranch(field, merged[field.name]);
310
+ for (const group of field.groups) {
311
+ if (group === active) fill(group.fields);
312
+ else stripFields(group.fields, merged);
313
+ }
314
+ } else {
315
+ // `variant: "advanced"` — nothing is mutually exclusive; every
316
+ // branch is always live.
317
+ for (const group of field.groups) fill(group.fields);
318
+ }
319
+ }
320
+ };
321
+ fill(spec.fields);
322
+ return merged;
323
+ },
324
+ [spec.fields],
325
+ );
326
+
327
+ // The field to focus after an invalid submit, applied from a `useEffect`
328
+ // below rather than synchronously here. `setAttempted(true)` (which is
329
+ // what makes an error-containing `AdvancedGroup` branch open itself — see
330
+ // `AdvancedGroupBranch`) and this focus request are dispatched in the same
331
+ // synchronous call, so React batches them into ONE commit; a synchronous
332
+ // `document.getElementById(...).focus()` right here would run against the
333
+ // PRE-update DOM and can never find a control that only exists once the
334
+ // just-opened disclosure mounts its content. Deferring to an effect lets
335
+ // it run after React has committed (and Radix has mounted) the newly-open
336
+ // group. A fresh object on every call (not just the name) guarantees the
337
+ // effect re-fires even when the SAME field is invalid on consecutive
338
+ // submit attempts, where a primitive dependency wouldn't change.
339
+ const [pendingFocus, setPendingFocus] = useState<{ name: string } | null>(null);
340
+
341
+ const submit = useCallback(() => {
342
+ setAttempted(true);
343
+ const merged = mergedValues(resolvedValues);
344
+
345
+ const errs = validateForm(spec.fields, merged);
346
+ const invalidNames = Object.keys(errs).filter((name) => errs[name]);
347
+ if (invalidNames.length > 0) {
348
+ // Focus the first field with an error (a11y: don't leave the user hunting).
349
+ const firstInvalid = collectValidatableFields(spec.fields, merged).find((f) => errs[f.name]);
350
+ if (firstInvalid) setPendingFocus({ name: firstInvalid.name });
351
+ return;
352
+ }
353
+ onSubmit?.({ formName: spec.formName, values: merged });
354
+ }, [resolvedValues, mergedValues, spec.fields, spec.formName, onSubmit]);
355
+
356
+ useEffect(() => {
357
+ if (!pendingFocus || typeof document === "undefined") return;
358
+ const el = document.getElementById(controlId(formId, pendingFocus.name));
359
+ el?.focus();
360
+ }, [pendingFocus, formId]);
361
+
362
+ const reset = useCallback(() => {
363
+ setAttempted(false);
364
+ const seeded = initialFormValues(spec.fields);
365
+ if (!isControlled) setInternalValues(seeded);
366
+ onChange?.(seeded);
367
+ }, [isControlled, spec.fields, onChange]);
368
+
369
+ // Errors only surface after a submit attempt; then they live-update on change.
370
+ const errors = useMemo<Record<string, string | null>>(() => {
371
+ if (!attempted) return {};
372
+ return validateForm(spec.fields, mergedValues(resolvedValues));
373
+ }, [attempted, resolvedValues, mergedValues, spec.fields]);
374
+
375
+ // Same object `submit`/`errors` already resolve visibility against (see
376
+ // `SchemaFormContextValue.effectiveValues`) — computed once per render so
377
+ // every render-time `isFieldVisible` check agrees with validation/submit.
378
+ const effectiveValues = useMemo(
379
+ () => mergedValues(resolvedValues),
380
+ [mergedValues, resolvedValues],
381
+ );
382
+
383
+ const value = useMemo<SchemaFormContextValue>(
384
+ () => ({
385
+ spec,
386
+ values: resolvedValues,
387
+ effectiveValues,
388
+ errors,
389
+ setValue,
390
+ submit,
391
+ reset,
392
+ attempted,
393
+ submitted,
394
+ submitting,
395
+ disabled,
396
+ loading,
397
+ error,
398
+ formId,
399
+ headingId,
400
+ }),
401
+ [
402
+ spec,
403
+ resolvedValues,
404
+ effectiveValues,
405
+ errors,
406
+ setValue,
407
+ submit,
408
+ reset,
409
+ attempted,
410
+ submitted,
411
+ submitting,
412
+ disabled,
413
+ loading,
414
+ error,
415
+ formId,
416
+ headingId,
417
+ ],
418
+ );
419
+
420
+ return <SchemaFormContext value={value}>{children}</SchemaFormContext>;
421
+ }
422
+
423
+ // ─── Field control renderers ──────────────────────────────────────────────────
424
+
425
+ interface FieldControlProps {
426
+ field: FieldSpec;
427
+ value: FormValue;
428
+ invalid: boolean;
429
+ disabled: boolean;
430
+ readOnly: boolean;
431
+ id: string;
432
+ /** Id of the field's visible label (used by grouped controls' aria-labelledby). */
433
+ labelId: string;
434
+ describedBy: string | undefined;
435
+ setValue: (name: string, value: FormValue) => void;
436
+ }
437
+
438
+ function StringControl({
439
+ field,
440
+ value,
441
+ invalid,
442
+ disabled,
443
+ readOnly,
444
+ id,
445
+ describedBy,
446
+ setValue,
447
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "string" }> }) {
448
+ const text = value === undefined ? "" : String(value);
449
+ const commonAria = {
450
+ id,
451
+ "aria-invalid": invalid || undefined,
452
+ "aria-describedby": describedBy,
453
+ "aria-required": field.required || undefined,
454
+ required: field.required,
455
+ } as const;
456
+
457
+ if (field.multiline) {
458
+ return (
459
+ <Textarea
460
+ {...commonAria}
461
+ name={field.name}
462
+ value={text}
463
+ disabled={disabled}
464
+ readOnly={readOnly}
465
+ maxLength={field.maxLength}
466
+ onChange={(e) => setValue(field.name, e.target.value)}
467
+ />
468
+ );
469
+ }
470
+
471
+ const inputType =
472
+ field.format === "email"
473
+ ? "email"
474
+ : field.format === "uri"
475
+ ? "url"
476
+ : field.format === "date"
477
+ ? "date"
478
+ : field.format === "date-time"
479
+ ? "datetime-local"
480
+ : "text";
481
+ const spellCheck = field.format === "email" || field.format === "uri" ? false : undefined;
482
+ const inputMode = field.format === "email" ? "email" : field.format === "uri" ? "url" : undefined;
483
+
484
+ return (
485
+ <Input
486
+ {...commonAria}
487
+ type={inputType}
488
+ name={field.name}
489
+ value={text}
490
+ disabled={disabled}
491
+ readOnly={readOnly}
492
+ minLength={field.minLength}
493
+ maxLength={field.maxLength}
494
+ spellCheck={spellCheck}
495
+ inputMode={inputMode}
496
+ onChange={(e) => setValue(field.name, e.target.value)}
497
+ />
498
+ );
499
+ }
500
+
501
+ function NumberControl({
502
+ field,
503
+ value,
504
+ invalid,
505
+ disabled,
506
+ readOnly,
507
+ id,
508
+ describedBy,
509
+ setValue,
510
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "number" | "integer" }> }) {
511
+ const num = typeof value === "number" ? value : null;
512
+ return (
513
+ <NumberInput
514
+ id={id}
515
+ name={field.name}
516
+ value={num}
517
+ min={field.min}
518
+ max={field.max}
519
+ step={field.type === "integer" ? 1 : undefined}
520
+ disabled={disabled}
521
+ readOnly={readOnly}
522
+ aria-invalid={invalid || undefined}
523
+ aria-describedby={describedBy}
524
+ aria-required={field.required || undefined}
525
+ onValueChange={(next) => setValue(field.name, next ?? undefined)}
526
+ />
527
+ );
528
+ }
529
+
530
+ function BooleanControl({
531
+ field,
532
+ value,
533
+ disabled,
534
+ id,
535
+ describedBy,
536
+ setValue,
537
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "boolean" }> }) {
538
+ return (
539
+ <div className="flex items-center gap-2">
540
+ <Checkbox
541
+ id={id}
542
+ name={field.name}
543
+ checked={value === true}
544
+ disabled={disabled}
545
+ aria-describedby={describedBy}
546
+ aria-required={field.required || undefined}
547
+ onCheckedChange={(checked) => setValue(field.name, checked === true)}
548
+ />
549
+ <Label htmlFor={id} className="flex items-center gap-1 font-normal">
550
+ {fieldLabel(field)}
551
+ {field.required && (
552
+ <span aria-hidden="true" className="text-destructive-text">
553
+ *
554
+ </span>
555
+ )}
556
+ </Label>
557
+ </div>
558
+ );
559
+ }
560
+
561
+ function EnumControl({
562
+ field,
563
+ value,
564
+ invalid,
565
+ disabled,
566
+ id,
567
+ describedBy,
568
+ setValue,
569
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "enum" }> }) {
570
+ const { t } = useLocale();
571
+ const current = typeof value === "string" && value.length > 0 ? value : undefined;
572
+ return (
573
+ <Select value={current} disabled={disabled} onValueChange={(v) => setValue(field.name, v)}>
574
+ <SelectTrigger
575
+ id={id}
576
+ aria-invalid={invalid || undefined}
577
+ aria-describedby={describedBy}
578
+ aria-required={field.required || undefined}
579
+ >
580
+ <SelectValue placeholder={t("ui.schemaForm.selectPlaceholder")} />
581
+ </SelectTrigger>
582
+ <SelectContent>
583
+ {field.options.map((option) => (
584
+ <SelectItem key={optionValue(option)} value={optionValue(option)}>
585
+ {optionLabel(option)}
586
+ </SelectItem>
587
+ ))}
588
+ </SelectContent>
589
+ </Select>
590
+ );
591
+ }
592
+
593
+ function MultiEnumControl({
594
+ field,
595
+ value,
596
+ disabled,
597
+ id,
598
+ labelId,
599
+ describedBy,
600
+ setValue,
601
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "multi-enum" }> }) {
602
+ const { t } = useLocale();
603
+ const selected = Array.isArray(value) ? (value as string[]) : [];
604
+ const toggle = (optValue: string, checked: boolean) => {
605
+ const next = checked ? [...selected, optValue] : selected.filter((v) => v !== optValue);
606
+ setValue(field.name, next);
607
+ };
608
+ return (
609
+ <div
610
+ role="group"
611
+ id={id}
612
+ tabIndex={-1}
613
+ aria-labelledby={labelId}
614
+ aria-describedby={describedBy}
615
+ className="flex flex-col gap-2 focus:outline-none"
616
+ >
617
+ {field.options.map((option) => {
618
+ const optValue = optionValue(option);
619
+ const optionId = `${id}-${optValue}`;
620
+ return (
621
+ <div key={optValue} className="flex items-center gap-2">
622
+ <Checkbox
623
+ id={optionId}
624
+ checked={selected.includes(optValue)}
625
+ disabled={disabled}
626
+ onCheckedChange={(checked) => toggle(optValue, checked === true)}
627
+ />
628
+ <Label htmlFor={optionId} className="font-normal">
629
+ {optionLabel(option)}
630
+ </Label>
631
+ </div>
632
+ );
633
+ })}
634
+ {field.options.length === 0 && (
635
+ <p className="text-body text-muted-foreground">{t("noResults")}</p>
636
+ )}
637
+ </div>
638
+ );
639
+ }
640
+
641
+ function ListControl({
642
+ field,
643
+ value,
644
+ disabled,
645
+ id,
646
+ labelId,
647
+ describedBy,
648
+ setValue,
649
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "list" }> }) {
650
+ const items = Array.isArray(value) ? (value as string[]) : [];
651
+ return (
652
+ <ListEditor
653
+ id={id}
654
+ aria-labelledby={labelId}
655
+ aria-describedby={describedBy}
656
+ value={items}
657
+ max={field.maxItems}
658
+ placeholder={field.itemPlaceholder}
659
+ disabled={disabled}
660
+ onValueChange={(next) => setValue(field.name, next)}
661
+ />
662
+ );
663
+ }
664
+
665
+ function KeyValueControl({
666
+ field,
667
+ value,
668
+ disabled,
669
+ id,
670
+ labelId,
671
+ describedBy,
672
+ setValue,
673
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "key-value" }> }) {
674
+ const rows = Array.isArray(value) ? (value as KeyValueRow[]) : [];
675
+ return (
676
+ <KeyValueEditor
677
+ id={id}
678
+ aria-labelledby={labelId}
679
+ aria-describedby={describedBy}
680
+ value={rows}
681
+ keyPlaceholder={field.keyPlaceholder}
682
+ valuePlaceholder={field.valuePlaceholder}
683
+ disabled={disabled}
684
+ onValueChange={(next) => setValue(field.name, next)}
685
+ />
686
+ );
687
+ }
688
+
689
+ /** Lists the currently-selected files with per-file wrong-type/too-large state. Reads `FileUpload`'s own context. */
690
+ function FileControlList({ field }: { field: Extract<FieldSpec, { type: "file" }> }) {
691
+ const { files } = useFileUpload();
692
+ if (files.length === 0) return null;
693
+ return (
694
+ <FileUploadList>
695
+ {files.map((uploadFile) => {
696
+ const issue = checkFileIssue(uploadFile.file, field);
697
+ return (
698
+ <FileUploadItem
699
+ key={uploadFile.id}
700
+ uploadFile={uploadFile}
701
+ status={issue ? "error" : "success"}
702
+ errorMessage={issue?.message}
703
+ />
704
+ );
705
+ })}
706
+ </FileUploadList>
707
+ );
708
+ }
709
+
710
+ /** A stable per-file id, since `FormValue`'s `File[]` carries no id of its own (unlike `FileUpload`'s own `UploadFile`). */
711
+ function fileIdentity(file: File): string {
712
+ return `${file.name}-${file.size}-${file.lastModified}`;
713
+ }
714
+
715
+ function FileControl({
716
+ field,
717
+ value,
718
+ disabled,
719
+ id,
720
+ labelId,
721
+ describedBy,
722
+ setValue,
723
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "file" }> }) {
724
+ // Bind `FileUpload` to the field's OWN value rather than letting it manage
725
+ // uncontrolled internal state: an externally-seeded `values` prop (a
726
+ // controlled `SchemaForm`, or a spec swap that reseeds `values`) must show
727
+ // up in the picker, and `setValue` must stay the single source of truth —
728
+ // otherwise the picker's internal file list and the form's submitted
729
+ // `values[field.name]` can silently diverge.
730
+ const controlledFiles = useMemo(() => {
731
+ const selected = Array.isArray(value) ? (value as File[]) : [];
732
+ return selected.map((file) => ({
733
+ id: fileIdentity(file),
734
+ file,
735
+ }));
736
+ }, [value]);
737
+ return (
738
+ <FileUpload
739
+ id={id}
740
+ aria-labelledby={labelId}
741
+ aria-describedby={describedBy}
742
+ accept={field.accept}
743
+ multiple={field.multiple}
744
+ // NOT `maxSize={field.maxSize}` — `FileUpload.addFiles` enforces `maxSize`
745
+ // by silently DROPPING an oversized file before it ever reaches `files`
746
+ // state, which would make the "too large" designed state below
747
+ // unreachable (the file the user picked would just vanish with no
748
+ // feedback). Enforcement instead happens entirely in `checkFileIssue`
749
+ // below, which renders the oversized file WITH an error item.
750
+ maxFiles={field.multiple ? field.maxFiles : 1}
751
+ disabled={disabled}
752
+ files={controlledFiles}
753
+ onFilesChange={(list) =>
754
+ setValue(
755
+ field.name,
756
+ list.map((u) => u.file),
757
+ )
758
+ }
759
+ >
760
+ <FileUploadDropzone />
761
+ <FileControlList field={field} />
762
+ </FileUpload>
763
+ );
764
+ }
765
+
766
+ // ─── Group control (tabs / advanced) ───────────────────────────────────────────
767
+
768
+ function GroupTabsControl({
769
+ field,
770
+ value,
771
+ disabled,
772
+ id,
773
+ labelId,
774
+ describedBy,
775
+ setValue,
776
+ }: FieldControlProps & { field: GroupFieldSpec }) {
777
+ const { effectiveValues } = useSchemaFormContext();
778
+ const active =
779
+ (typeof value === "string" ? value : undefined) ?? field.default ?? field.groups[0]?.key;
780
+ return (
781
+ <Tabs
782
+ value={active}
783
+ onValueChange={(next) => setValue(field.name, next)}
784
+ id={id}
785
+ aria-labelledby={labelId}
786
+ aria-describedby={describedBy}
787
+ >
788
+ <TabsList>
789
+ {field.groups.map((group) => (
790
+ <TabsTrigger key={group.key} value={group.key} disabled={disabled}>
791
+ {group.label}
792
+ </TabsTrigger>
793
+ ))}
794
+ </TabsList>
795
+ {field.groups.map((group) => (
796
+ <TabsContent key={group.key} value={group.key} className="flex flex-col gap-4 pt-3">
797
+ {group.description && (
798
+ <p className="text-caption text-muted-foreground">{group.description}</p>
799
+ )}
800
+ {group.fields
801
+ .filter((child) => isFieldVisible(child, effectiveValues))
802
+ .map((child) => (
803
+ <SchemaFormField key={child.name} name={child.name} />
804
+ ))}
805
+ </TabsContent>
806
+ ))}
807
+ </Tabs>
808
+ );
809
+ }
810
+
811
+ /** Does any field in this branch (including nested group branches) currently have an error? */
812
+ function branchHasError(fields: FieldSpec[], errors: Record<string, string | null>): boolean {
813
+ for (const field of fields) {
814
+ if (errors[field.name]) return true;
815
+ if (field.type === "group") {
816
+ for (const group of field.groups) {
817
+ if (branchHasError(group.fields, errors)) return true;
818
+ }
819
+ }
820
+ }
821
+ return false;
822
+ }
823
+
824
+ /**
825
+ * One `variant: "advanced"` branch. Radix's `CollapsibleContent` UNMOUNTS its
826
+ * children while closed, so a required field inside a still-collapsed branch
827
+ * is invisible to both `document.getElementById` (submit's focus step) and
828
+ * the user — reveal the branch automatically the moment it holds a
829
+ * validation error. "Controlled with override": the derived open state
830
+ * (`hasError`) drives the disclosure until the user explicitly toggles it
831
+ * themselves, at which point their choice takes over for good (an ordinary
832
+ * disclosure never re-imposes itself over a deliberate user action).
833
+ */
834
+ function AdvancedGroupBranch({ group }: { group: GroupItemSpec }) {
835
+ const { errors, effectiveValues } = useSchemaFormContext();
836
+ const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined);
837
+ const open = manualOpen ?? branchHasError(group.fields, errors);
838
+ return (
839
+ <AdvancedGroup
840
+ title={group.label}
841
+ summary={group.description}
842
+ open={open}
843
+ onOpenChange={setManualOpen}
844
+ >
845
+ {group.fields
846
+ .filter((child) => isFieldVisible(child, effectiveValues))
847
+ .map((child) => (
848
+ <SchemaFormField key={child.name} name={child.name} />
849
+ ))}
850
+ </AdvancedGroup>
851
+ );
852
+ }
853
+
854
+ function GroupAdvancedControl({ field }: { field: GroupFieldSpec }) {
855
+ return (
856
+ <div className="flex flex-col gap-3">
857
+ {field.groups.map((group) => (
858
+ <AdvancedGroupBranch key={group.key} group={group} />
859
+ ))}
860
+ </div>
861
+ );
862
+ }
863
+
864
+ // ─── Field ────────────────────────────────────────────────────────────────────
865
+
866
+ export interface SchemaFormFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
867
+ /** The field's `name` (its key in the spec + values). Resolved anywhere in the tree, including inside `group` branches. */
868
+ name: string;
869
+ }
870
+
871
+ /**
872
+ * Renders one field by name: label, control, description, inline error.
873
+ * Boolean fields render their own inline label (checkbox + label); `group`
874
+ * fields render their OWN label/description internally (a Tabs strip or a
875
+ * stack of disclosures isn't a single labelled control), so the standalone
876
+ * `<Label>` above is suppressed for both.
877
+ *
878
+ * Enforces its OWN `visibleWhen` (returns `null` when hidden) rather than
879
+ * trusting the caller to have filtered it out first: the documented custom-
880
+ * layout composition (`SchemaFormProvider` + the parts, see `SchemaForm`'s
881
+ * own doc comment) lets a consumer place `<SchemaFormField name="…" />`
882
+ * directly, bypassing `SchemaFormFields`'/`GroupTabsControl`'s/
883
+ * `AdvancedGroupBranch`'s own `isFieldVisible` filters — without this check
884
+ * the same spec would render a hidden field in a custom layout while
885
+ * validation/submission (which always excludes it) disagree.
886
+ */
887
+ export const SchemaFormField = forwardRef<HTMLDivElement, SchemaFormFieldProps>(
888
+ function SchemaFormField({ name, className, ...props }, ref) {
889
+ const ctx = useSchemaFormContext();
890
+ const field = findFieldByName(ctx.spec.fields, name);
891
+ if (!field) return null;
892
+ if (!isFieldVisible(field, ctx.effectiveValues)) return null;
893
+
894
+ const id = controlId(ctx.formId, name);
895
+ const labelId = `${ctx.formId}-label-${name}`;
896
+ const value = effectiveValue(field, ctx.values);
897
+ const error = ctx.errors[name] ?? null;
898
+ const invalid = Boolean(error);
899
+ const controlDisabled = ctx.disabled || ctx.submitted || ctx.submitting;
900
+ const readOnly = ctx.submitted;
901
+ const description = field.description;
902
+ const hasDesc = Boolean(description);
903
+ const describedBy =
904
+ [hasDesc ? descId(ctx.formId, name) : null, invalid ? errorId(ctx.formId, name) : null]
905
+ .filter(Boolean)
906
+ .join(" ") || undefined;
907
+
908
+ const controlProps: FieldControlProps = {
909
+ field,
910
+ value,
911
+ invalid,
912
+ disabled: controlDisabled,
913
+ readOnly,
914
+ id,
915
+ labelId,
916
+ describedBy,
917
+ setValue: ctx.setValue,
918
+ };
919
+
920
+ const isBoolean = field.type === "boolean";
921
+ const isGroup = field.type === "group";
922
+ // A multi-value control renders a labelled REGION, not one focusable
923
+ // element with a native label association (a checkbox group, a list/
924
+ // key-value editor's several rows, a file dropzone, tabs, or a stack of
925
+ // disclosures) — its label must NOT use htmlFor, only aria-labelledby.
926
+ const isRegionField =
927
+ field.type === "multi-enum" ||
928
+ field.type === "list" ||
929
+ field.type === "key-value" ||
930
+ field.type === "file" ||
931
+ isGroup;
932
+
933
+ return (
934
+ <div
935
+ ref={ref}
936
+ data-slot="schema-form-field"
937
+ className={cn("flex flex-col gap-1.5", className)}
938
+ {...props}
939
+ >
940
+ {!isBoolean && (
941
+ <Label
942
+ id={labelId}
943
+ htmlFor={isRegionField ? undefined : id}
944
+ className="flex items-center gap-1"
945
+ >
946
+ {fieldLabel(field)}
947
+ {field.required && (
948
+ <span aria-hidden="true" className="text-destructive-text">
949
+ *
950
+ </span>
951
+ )}
952
+ </Label>
953
+ )}
954
+
955
+ {field.type === "string" && <StringControl {...controlProps} field={field} />}
956
+ {(field.type === "number" || field.type === "integer") && (
957
+ <NumberControl {...controlProps} field={field} />
958
+ )}
959
+ {field.type === "boolean" && <BooleanControl {...controlProps} field={field} />}
960
+ {field.type === "enum" && <EnumControl {...controlProps} field={field} />}
961
+ {field.type === "multi-enum" && <MultiEnumControl {...controlProps} field={field} />}
962
+ {field.type === "list" && <ListControl {...controlProps} field={field} />}
963
+ {field.type === "key-value" && <KeyValueControl {...controlProps} field={field} />}
964
+ {field.type === "file" && <FileControl {...controlProps} field={field} />}
965
+ {field.type === "group" && field.variant === "tabs" && (
966
+ <GroupTabsControl {...controlProps} field={field} />
967
+ )}
968
+ {field.type === "group" && field.variant === "advanced" && (
969
+ <GroupAdvancedControl field={field} />
970
+ )}
971
+
972
+ {hasDesc && (
973
+ <p id={descId(ctx.formId, name)} className="text-caption text-muted-foreground">
974
+ {description}
975
+ </p>
976
+ )}
977
+ {invalid && (
978
+ <p id={errorId(ctx.formId, name)} className="text-caption text-destructive-text">
979
+ {error}
980
+ </p>
981
+ )}
982
+ </div>
983
+ );
984
+ },
985
+ );
986
+
987
+ // ─── Fields (all) ─────────────────────────────────────────────────────────────
988
+
989
+ export type SchemaFormFieldsProps = HTMLAttributes<HTMLDivElement>;
990
+
991
+ /** Renders every top-level field in the spec, in order. A skeleton while `loading` with no fields. */
992
+ export const SchemaFormFields = forwardRef<HTMLDivElement, SchemaFormFieldsProps>(
993
+ function SchemaFormFields({ className, ...props }, ref) {
994
+ const { spec, loading, effectiveValues } = useSchemaFormContext();
995
+
996
+ if (spec.fields.length === 0 && loading) {
997
+ return (
998
+ <div
999
+ ref={ref}
1000
+ data-slot="schema-form-fields"
1001
+ className={cn("flex flex-col gap-4", className)}
1002
+ {...props}
1003
+ >
1004
+ <SchemaFormSkeletonAnnouncement />
1005
+ <div aria-hidden="true" className="flex flex-col gap-4">
1006
+ {[0, 1, 2].map((i) => (
1007
+ <div key={i} className="flex flex-col gap-1.5">
1008
+ <Skeleton className="h-4 w-24" />
1009
+ <Skeleton className="h-9 w-full" />
1010
+ </div>
1011
+ ))}
1012
+ </div>
1013
+ </div>
1014
+ );
1015
+ }
1016
+
1017
+ return (
1018
+ <div
1019
+ ref={ref}
1020
+ data-slot="schema-form-fields"
1021
+ className={cn("flex flex-col gap-4", className)}
1022
+ {...props}
1023
+ >
1024
+ {spec.fields
1025
+ .filter((field) => isFieldVisible(field, effectiveValues))
1026
+ .map((field) => (
1027
+ <SchemaFormField key={field.name} name={field.name} />
1028
+ ))}
1029
+ </div>
1030
+ );
1031
+ },
1032
+ );
1033
+
1034
+ function SchemaFormSkeletonAnnouncement() {
1035
+ const { t } = useLocale();
1036
+ return (
1037
+ <span className="sr-only" role="status" aria-live="polite">
1038
+ {t("loading")}
1039
+ </span>
1040
+ );
1041
+ }
1042
+
1043
+ // ─── Error (form-level, terminal) ───────────────────────────────────────────────
1044
+
1045
+ export type SchemaFormErrorProps = HTMLAttributes<HTMLDivElement>;
1046
+
1047
+ /** A terminal, form-level submission error (e.g. "Couldn't save settings"). Renders nothing when absent. */
1048
+ export const SchemaFormError = forwardRef<HTMLDivElement, SchemaFormErrorProps>(
1049
+ function SchemaFormError({ className, ...props }, ref) {
1050
+ const { error } = useSchemaFormContext();
1051
+ if (!error) return null;
1052
+ return (
1053
+ <div
1054
+ ref={ref}
1055
+ role="alert"
1056
+ // #F2: the `bg-destructive/10` wash alone measures 1.18:1 (light) /
1057
+ // 1.09:1 (dark) against the form ground — the earlier "the wash already
1058
+ // marks the region" reasoning (styling-and-tokens.md's own decision
1059
+ // test: "if I deleted this line, could a sighted user still tell the
1060
+ // two regions apart?") had a measured answer of no, so the wash was the
1061
+ // SOLE structural cue and dropping the border was wrong. Restore the
1062
+ // boundary as an accent RAIL (`border-s-2 border-s-destructive`), not a
1063
+ // full border — the gate permits rails, and a rail is the idiom the
1064
+ // rest of the repo already uses for a destructive/error box that keeps
1065
+ // its wash (`MermaidDiagram`'s error panel, `packages/editor/src/mermaid-diagram/mermaid-diagram.tsx`).
1066
+ // `border-s-destructive` is the FILL rung (styling-and-tokens.md "status
1067
+ // rung"), guaranteed >=3:1 against every surface token — measured here
1068
+ // at 4.70:1 (light) / 4.71:1 (dark) against `--background`.
1069
+ data-slot="schema-form-error"
1070
+ className={cn(
1071
+ "rounded-md border-s-2 border-s-destructive bg-destructive/10 px-3 py-2 text-body text-destructive-text",
1072
+ className,
1073
+ )}
1074
+ {...props}
1075
+ >
1076
+ {error}
1077
+ </div>
1078
+ );
1079
+ },
1080
+ );
1081
+
1082
+ // ─── Submit ───────────────────────────────────────────────────────────────────
1083
+
1084
+ export interface SchemaFormSubmitProps extends Omit<HTMLAttributes<HTMLButtonElement>, "children"> {
1085
+ /** Submit button label (overrides `spec.submitLabel`). @default "Submit" */
1086
+ label?: string;
1087
+ }
1088
+
1089
+ /**
1090
+ * The submit affordance. Enabled until the request starts (validation runs on
1091
+ * click), then transiently blocked. `submitting` uses `aria-disabled` + a
1092
+ * click-handler guard — NEVER the native `disabled` attribute — so a focused
1093
+ * button is never dropped from the tab order right after the user activates
1094
+ * it (see the module doc comment / interaction-guidelines.md). The explicit,
1095
+ * caller-set `disabled` (read-only form) stays native: that is a deliberate,
1096
+ * durable state, not a transient auto-flip. A submitted form shows an inert
1097
+ * "Submitted" note instead of a button.
1098
+ */
1099
+ export const SchemaFormSubmit = forwardRef<HTMLButtonElement, SchemaFormSubmitProps>(
1100
+ function SchemaFormSubmit({ label, className, onClick, ...props }, ref) {
1101
+ const { t } = useLocale();
1102
+ const { spec, submitting, submitted, disabled, loading } = useSchemaFormContext();
1103
+
1104
+ if (submitted) {
1105
+ return (
1106
+ <Badge variant="success" aria-live="polite" className="w-fit">
1107
+ <Check aria-hidden="true" className="size-3" />
1108
+ {t("ui.schemaForm.submitted")}
1109
+ </Badge>
1110
+ );
1111
+ }
1112
+
1113
+ const text = label ?? spec.submitLabel ?? t("ui.schemaForm.submit");
1114
+ // The spec is still loading (`SchemaFormFields` shows a skeleton, not the
1115
+ // real fields yet) — blocked exactly like `submitting`: transient, so
1116
+ // `aria-disabled` + a handler guard, never the native attribute (see the
1117
+ // module doc comment). Submitting while the fields haven't rendered would
1118
+ // validate/submit whatever placeholder `values` happen to exist.
1119
+ const blocked = submitting || loading;
1120
+
1121
+ const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
1122
+ // aria-disabled does not block activation the way the native attribute
1123
+ // does, so the handler (and SchemaFormRoot's onSubmit) has to.
1124
+ if (blocked) {
1125
+ e.preventDefault();
1126
+ return;
1127
+ }
1128
+ onClick?.(e);
1129
+ };
1130
+
1131
+ return (
1132
+ <Button
1133
+ ref={ref}
1134
+ type="submit"
1135
+ data-slot="schema-form-submit"
1136
+ disabled={disabled}
1137
+ aria-disabled={blocked || undefined}
1138
+ aria-busy={submitting || undefined}
1139
+ onClick={handleClick}
1140
+ className={cn(blocked && "cursor-not-allowed", className)}
1141
+ {...props}
1142
+ >
1143
+ {submitting && <Spinner aria-hidden="true" className="text-current" />}
1144
+ {submitting ? t("ui.schemaForm.submitting") : text}
1145
+ </Button>
1146
+ );
1147
+ },
1148
+ );
1149
+
1150
+ // ─── Test action (a form/group-level async action, e.g. "Test connection") ────
1151
+
1152
+ /**
1153
+ * `SchemaFormTestAction`'s lifecycle: `idle` → `pending` while `onTest` is in
1154
+ * flight → `success`/`failure` once it settles. Deliberately local component
1155
+ * state, NOT part of `SchemaFormContextValue` — so it can never affect field
1156
+ * validity or gate `submit()` (issue #22 maintainer ruling, 2026-09-01: a
1157
+ * form/group-level test-action slot, kept separate from field validity and
1158
+ * never gating submit — not per-field `validateAsync` in the validation
1159
+ * engine).
1160
+ */
1161
+ export type SchemaFormTestActionStatus = "idle" | "pending" | "success" | "failure";
1162
+
1163
+ export interface SchemaFormTestActionProps extends Omit<HTMLAttributes<HTMLDivElement>, "onError"> {
1164
+ /**
1165
+ * Runs the test (e.g. calls an API with the fields typed so far). Receives
1166
+ * the form's CURRENT effective values — the same object `validateForm`
1167
+ * would resolve against — but this call is entirely independent of
1168
+ * validation: a field that is currently invalid, or an unrelated required
1169
+ * field left empty, never blocks a test run. Resolve to signal success;
1170
+ * reject (an `Error`, or throw) to signal failure — a rejected `Error`'s
1171
+ * `message` becomes the shown failure reason.
1172
+ */
1173
+ onTest: (values: FormValues) => void | Promise<void>;
1174
+ /** Button label. @default "Test connection" */
1175
+ label?: string;
1176
+ /** Label shown while pending. @default "Testing…" */
1177
+ pendingLabel?: string;
1178
+ /** Label shown on success. @default "Connected" */
1179
+ successLabel?: string;
1180
+ /** Fallback failure label when the rejection carries no message. @default "Test failed" */
1181
+ failureLabel?: string;
1182
+ }
1183
+
1184
+ /**
1185
+ * A form- or group-level "Test connection" affordance: an async action the
1186
+ * user can run to verify the values typed so far (e.g. hit a connector's
1187
+ * `/ping` endpoint) BEFORE submitting. Place it anywhere inside a
1188
+ * `SchemaFormProvider` tree — directly under `SchemaFormRoot` for a
1189
+ * form-level test, or beside a `SchemaFormField` inside a `group` branch (a
1190
+ * `TabsContent`/`AdvancedGroup`) for a per-credential-set test. Not part of
1191
+ * `SchemaForm`'s default composition — an opt-in part a consumer places, the
1192
+ * same way a custom layout composes `SchemaFormField` directly.
1193
+ *
1194
+ * Deliberately NOT wired into `errors`/`validateForm`/`submit()` — its
1195
+ * pending/success/failure state is entirely local, so it can never block or
1196
+ * silently gate the form's own submit control.
1197
+ */
1198
+ export const SchemaFormTestAction = forwardRef<HTMLDivElement, SchemaFormTestActionProps>(
1199
+ function SchemaFormTestAction(
1200
+ { onTest, label, pendingLabel, successLabel, failureLabel, className, ...props },
1201
+ ref,
1202
+ ) {
1203
+ const { t } = useLocale();
1204
+ const {
1205
+ effectiveValues,
1206
+ disabled: formDisabled,
1207
+ loading,
1208
+ submitted,
1209
+ submitting,
1210
+ } = useSchemaFormContext();
1211
+ const [status, setStatus] = useState<SchemaFormTestActionStatus>("idle");
1212
+ const [failureMessage, setFailureMessage] = useState<string | null>(null);
1213
+
1214
+ // Always mirrors the LATEST `effectiveValues` (updated every render) so
1215
+ // an in-flight `onTest` can tell, once it settles, whether the values it
1216
+ // was called with are still current — a plain closure over
1217
+ // `effectiveValues` would only ever see the snapshot from the render
1218
+ // that started the request.
1219
+ const latestEffectiveValuesRef = useRef(effectiveValues);
1220
+ latestEffectiveValuesRef.current = effectiveValues;
1221
+ // The specific values snapshot the CURRENT `status` describes — set at
1222
+ // the moment a test starts, read both by the async completion (to
1223
+ // detect "edited while pending") and by the effect below (to detect
1224
+ // "edited after success/failure").
1225
+ const testedValuesRef = useRef<FormValues | null>(null);
1226
+
1227
+ const pending = status === "pending";
1228
+ // Transient (pending) block uses aria-disabled + a handler guard, never
1229
+ // native `disabled` — same reasoning as SchemaFormSubmit's OWN pending
1230
+ // state: a keyboard user who just activated THIS button must not be
1231
+ // dropped from the tab order right after they used it. `pending` (this
1232
+ // button's own in-flight test) and `loading` (the spec itself still
1233
+ // loading — pre-existing, matches SchemaFormSubmit's `blocked`) are both
1234
+ // "I am mid-task" states, so they stay transient.
1235
+ //
1236
+ // `submitting`/`submitted` are a DIFFERENT kind of state: they describe
1237
+ // the FORM's submit action, not this button's own. This button is a
1238
+ // bystander to that action the same way a `SchemaFormField` is — never
1239
+ // the control the user just activated — so both go native `disabled`
1240
+ // below, exactly like `SchemaFormField`'s `controlDisabled = ctx.disabled
1241
+ // || ctx.submitted || ctx.submitting`. (Contrast `SchemaFormSubmit`,
1242
+ // where `submitting` IS the button's own state and stays transient — the
1243
+ // two controls are not interchangeable here.)
1244
+ const transientlyBlocked = loading || pending;
1245
+ const nativelyBlocked = formDisabled || submitted || submitting;
1246
+
1247
+ // PR #119 review thread 0 (chatgpt-codex-connector): once the tested
1248
+ // values go stale — the user edited a field after this status settled —
1249
+ // discard the now-inaccurate success/failure and return to idle rather
1250
+ // than keep describing values that no longer exist.
1251
+ useEffect(() => {
1252
+ if (
1253
+ (status === "success" || status === "failure") &&
1254
+ testedValuesRef.current !== effectiveValues
1255
+ ) {
1256
+ setStatus("idle");
1257
+ setFailureMessage(null);
1258
+ }
1259
+ }, [effectiveValues, status]);
1260
+
1261
+ const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
1262
+ if (nativelyBlocked || transientlyBlocked) {
1263
+ e.preventDefault();
1264
+ return;
1265
+ }
1266
+ const testedValues = effectiveValues;
1267
+ testedValuesRef.current = testedValues;
1268
+ setStatus("pending");
1269
+ setFailureMessage(null);
1270
+ void (async () => {
1271
+ try {
1272
+ await onTest(testedValues);
1273
+ // The values changed WHILE the request was in flight — discard
1274
+ // this now-stale result and go back to idle rather than report
1275
+ // success/failure for a snapshot the user has already moved on
1276
+ // from (also covers the "resolved after a NEWER click" case,
1277
+ // since that click's own `testedValuesRef.current` write already
1278
+ // moved this promise's `testedValues` out of date).
1279
+ if (latestEffectiveValuesRef.current !== testedValues) {
1280
+ setStatus("idle");
1281
+ return;
1282
+ }
1283
+ setStatus("success");
1284
+ } catch (err) {
1285
+ if (latestEffectiveValuesRef.current !== testedValues) {
1286
+ setStatus("idle");
1287
+ return;
1288
+ }
1289
+ setStatus("failure");
1290
+ setFailureMessage(err instanceof Error ? err.message : null);
1291
+ }
1292
+ })();
1293
+ };
1294
+
1295
+ return (
1296
+ <div
1297
+ ref={ref}
1298
+ data-slot="schema-form-test-action"
1299
+ className={cn("flex flex-wrap items-center gap-2", className)}
1300
+ {...props}
1301
+ >
1302
+ <Button
1303
+ type="button"
1304
+ variant="outline"
1305
+ disabled={nativelyBlocked}
1306
+ aria-disabled={transientlyBlocked || undefined}
1307
+ aria-busy={pending || undefined}
1308
+ onClick={handleClick}
1309
+ className={cn(transientlyBlocked && "cursor-not-allowed")}
1310
+ >
1311
+ {pending && <Spinner aria-hidden="true" className="text-current" />}
1312
+ {pending
1313
+ ? (pendingLabel ?? t("ui.schemaForm.testAction.pending"))
1314
+ : (label ?? t("ui.schemaForm.testAction.label"))}
1315
+ </Button>
1316
+ {status === "success" && (
1317
+ <StatusBadge status="complete" aria-live="polite">
1318
+ {successLabel ?? t("ui.schemaForm.testAction.success")}
1319
+ </StatusBadge>
1320
+ )}
1321
+ {status === "failure" && (
1322
+ <StatusBadge status="failed" role="alert">
1323
+ {failureMessage ?? failureLabel ?? t("ui.schemaForm.testAction.failure")}
1324
+ </StatusBadge>
1325
+ )}
1326
+ </div>
1327
+ );
1328
+ },
1329
+ );
1330
+
1331
+ // ─── Root (the <form> element) ────────────────────────────────────────────────
1332
+
1333
+ export type SchemaFormRootProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit">;
1334
+
1335
+ /**
1336
+ * The `<form>` element wired to the context's `submit`. Never nest inside
1337
+ * another `<form>` — compose it as its own top-level block.
1338
+ */
1339
+ export const SchemaFormRoot = forwardRef<HTMLFormElement, SchemaFormRootProps>(
1340
+ function SchemaFormRoot({ className, children, ...props }, ref) {
1341
+ const { t } = useLocale();
1342
+ const { submit, headingId, spec, disabled, submitting, loading } = useSchemaFormContext();
1343
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
1344
+ e.preventDefault();
1345
+ // The submit control's `aria-disabled` is a signal, not a lock — this is
1346
+ // the actual guard against a double/blocked submit. `loading` blocks it
1347
+ // too (see `SchemaFormSubmit`): the fields are still a skeleton, so
1348
+ // there is nothing real to validate/submit yet.
1349
+ if (disabled || submitting || loading) return;
1350
+ submit();
1351
+ };
1352
+ return (
1353
+ <form
1354
+ ref={ref}
1355
+ data-slot="schema-form-root"
1356
+ noValidate
1357
+ onSubmit={handleSubmit}
1358
+ aria-labelledby={spec.title ? headingId : undefined}
1359
+ aria-label={spec.title ? undefined : spec.formName || t("ui.schemaForm.label")}
1360
+ className={cn("flex w-full flex-col gap-4", className)}
1361
+ {...props}
1362
+ >
1363
+ {children}
1364
+ </form>
1365
+ );
1366
+ },
1367
+ );
1368
+
1369
+ // ─── Title + Description ──────────────────────────────────────────────────────
1370
+
1371
+ export type SchemaFormTitleProps = HTMLAttributes<HTMLParagraphElement>;
1372
+
1373
+ /** The form heading. Its id is the `<form>`'s `aria-labelledby` target. */
1374
+ export const SchemaFormTitle = forwardRef<HTMLParagraphElement, SchemaFormTitleProps>(
1375
+ function SchemaFormTitle({ className, children, ...props }, ref) {
1376
+ const { headingId, spec } = useSchemaFormContext();
1377
+ const content = children ?? spec.title;
1378
+ if (!content) return null;
1379
+ return (
1380
+ <p
1381
+ ref={ref}
1382
+ id={headingId}
1383
+ className={cn("text-subtitle font-semibold text-foreground text-balance", className)}
1384
+ {...props}
1385
+ >
1386
+ {content}
1387
+ </p>
1388
+ );
1389
+ },
1390
+ );
1391
+
1392
+ export type SchemaFormDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
1393
+
1394
+ /** Supplemental description under the title. */
1395
+ export const SchemaFormDescription = forwardRef<HTMLParagraphElement, SchemaFormDescriptionProps>(
1396
+ function SchemaFormDescription({ className, children, ...props }, ref) {
1397
+ const { spec } = useSchemaFormContext();
1398
+ const content = children ?? spec.description;
1399
+ if (!content) return null;
1400
+ return (
1401
+ <p
1402
+ ref={ref}
1403
+ className={cn("text-body text-muted-foreground text-pretty", className)}
1404
+ {...props}
1405
+ >
1406
+ {content}
1407
+ </p>
1408
+ );
1409
+ },
1410
+ );
1411
+
1412
+ // ─── Fallback ─────────────────────────────────────────────────────────────────
1413
+
1414
+ export interface SchemaFormFallbackProps extends HTMLAttributes<HTMLDivElement> {
1415
+ /** Short human reason the form could not render. */
1416
+ message?: string;
1417
+ }
1418
+
1419
+ /**
1420
+ * Shown when the spec is unusable. Mirrors `MessageFormFallback`: a calm,
1421
+ * bordered status box with a short reason — never a thrown error.
1422
+ */
1423
+ export const SchemaFormFallback = forwardRef<HTMLDivElement, SchemaFormFallbackProps>(
1424
+ function SchemaFormFallback(
1425
+ { message = "This form could not be displayed.", className, ...props },
1426
+ ref,
1427
+ ) {
1428
+ return (
1429
+ <div
1430
+ ref={ref}
1431
+ role="status"
1432
+ aria-live="polite"
1433
+ data-slot="schema-form-fallback"
1434
+ className={cn(
1435
+ // Sole structural cue is the border (no fill) — reads in every theme.
1436
+ "flex items-center justify-center rounded-md border border-border-strong px-4 py-6 text-center text-body text-muted-foreground",
1437
+ className,
1438
+ )}
1439
+ {...props}
1440
+ >
1441
+ {message}
1442
+ </div>
1443
+ );
1444
+ },
1445
+ );
1446
+
1447
+ // ─── Root convenience component ────────────────────────────────────────────────
1448
+
1449
+ export interface SchemaFormProps extends Omit<
1450
+ HTMLAttributes<HTMLDivElement>,
1451
+ "onSubmit" | "onChange" | "title"
1452
+ > {
1453
+ /** The serializable form specification. */
1454
+ spec: FormSpec | unknown;
1455
+ /** Controlled values (`{ [fieldName]: value }`). Omit for uncontrolled. */
1456
+ values?: FormValues;
1457
+ /** Called with the next full values object on any change. */
1458
+ onChange?: (values: FormValues) => void;
1459
+ /** Called with `{ formName, values }` on a valid submit. */
1460
+ onSubmit?: (state: FormSubmitState) => void;
1461
+ /** Submit button label (overrides `spec.submitLabel`). */
1462
+ submitLabel?: string;
1463
+ /** Disable every control (the whole form is read-only). */
1464
+ disabled?: boolean;
1465
+ /** Terminal submitted state: inert, values visible, submit replaced. */
1466
+ submitted?: boolean;
1467
+ /** In-flight submit: controls transiently blocked, spinner on submit. */
1468
+ submitting?: boolean;
1469
+ /** No fields to render yet (spec still loading) → skeleton. */
1470
+ loading?: boolean;
1471
+ /** A terminal, form-level submission error rendered above the submit control. */
1472
+ error?: ReactNode;
1473
+ }
1474
+
1475
+ /**
1476
+ * Convenience composition: `Provider → Root(form) → title/description →
1477
+ * Fields → Error → Submit`. For a custom layout, compose `SchemaFormProvider`
1478
+ * + the parts. A malformed spec renders `SchemaFormFallback` (never throws).
1479
+ */
1480
+ export const SchemaForm = forwardRef<HTMLDivElement, SchemaFormProps>(function SchemaForm(
1481
+ {
1482
+ spec,
1483
+ values,
1484
+ onChange,
1485
+ onSubmit,
1486
+ submitLabel,
1487
+ disabled,
1488
+ submitted,
1489
+ submitting,
1490
+ loading,
1491
+ error,
1492
+ className,
1493
+ ...props
1494
+ },
1495
+ ref,
1496
+ ) {
1497
+ const result = normalizeFormSpec(spec);
1498
+ if (!result.ok) {
1499
+ return (
1500
+ <SchemaFormFallback ref={ref} message={result.reason} className={className} {...props} />
1501
+ );
1502
+ }
1503
+
1504
+ const normalized = result.spec;
1505
+ const empty = normalized.fields.length === 0;
1506
+
1507
+ if (empty && !loading) {
1508
+ return (
1509
+ <SchemaFormFallback
1510
+ ref={ref}
1511
+ message="This form has no fields to fill in."
1512
+ className={className}
1513
+ {...props}
1514
+ />
1515
+ );
1516
+ }
1517
+
1518
+ return (
1519
+ <SchemaFormProvider
1520
+ spec={normalized}
1521
+ values={values}
1522
+ onChange={onChange}
1523
+ onSubmit={onSubmit}
1524
+ disabled={disabled}
1525
+ submitted={submitted}
1526
+ submitting={submitting}
1527
+ loading={loading}
1528
+ error={error}
1529
+ >
1530
+ <div ref={ref} data-slot="schema-form" className={cn("w-full", className)} {...props}>
1531
+ <SchemaFormRoot>
1532
+ {(normalized.title || normalized.description) && (
1533
+ <div className="flex flex-col gap-1">
1534
+ <SchemaFormTitle />
1535
+ <SchemaFormDescription />
1536
+ </div>
1537
+ )}
1538
+ <SchemaFormFields />
1539
+ <SchemaFormError />
1540
+ <SchemaFormSubmit label={submitLabel} />
1541
+ </SchemaFormRoot>
1542
+ </div>
1543
+ </SchemaFormProvider>
1544
+ );
1545
+ });