@elabs-ai/components-ui 4.1.0 → 4.2.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 (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. package/dist/chunk-JKPPTL63.js.map +0 -1
@@ -46,10 +46,11 @@
46
46
  import {
47
47
  createContext,
48
48
  forwardRef,
49
+ memo,
49
50
  use,
50
- useCallback,
51
51
  useEffect,
52
52
  useId,
53
+ useLayoutEffect,
53
54
  useMemo,
54
55
  useRef,
55
56
  useState,
@@ -86,15 +87,10 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "../tabs";
86
87
 
87
88
  import {
88
89
  checkFileIssue,
89
- collectValidatableFields,
90
90
  fieldLabel,
91
- findFieldByName,
92
- initialFormValues,
93
- isFieldVisible,
94
91
  normalizeFormSpec,
95
92
  optionLabel,
96
93
  optionValue,
97
- validateForm,
98
94
  type FieldSpec,
99
95
  type FormSpec,
100
96
  type FormSubmitState,
@@ -104,49 +100,26 @@ import {
104
100
  type GroupItemSpec,
105
101
  type NormalizedFormSpec,
106
102
  } 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) {
103
+ import {
104
+ SchemaFormStore,
105
+ useBranchHasError,
106
+ useEffectiveValues,
107
+ useFieldSnapshot,
108
+ useSchemaFormMeta,
109
+ useVisibleFieldNames,
110
+ type SchemaFormStoreProps,
111
+ } from "./schema-form-store";
112
+
113
+ // ─── Context (carries only the STABLE store instance see schema-form-store.ts) ──
114
+
115
+ const SchemaFormStoreContext = createContext<SchemaFormStore | null>(null);
116
+
117
+ function useSchemaFormStore(): SchemaFormStore {
118
+ const store = use(SchemaFormStoreContext);
119
+ if (!store) {
147
120
  throw new Error("SchemaForm sub-components must be rendered inside <SchemaFormProvider>.");
148
121
  }
149
- return ctx;
122
+ return store;
150
123
  }
151
124
 
152
125
  /** Stable DOM id for a field's primary control (used for label + focus). */
@@ -160,39 +133,6 @@ function errorId(formId: string, name: string): string {
160
133
  return `${formId}-error-${name}`;
161
134
  }
162
135
 
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
136
  // ─── Provider ───────────────────────────────────────────────────────────────
197
137
 
198
138
  export interface SchemaFormProviderProps {
@@ -237,187 +177,54 @@ export function SchemaFormProvider({
237
177
  children,
238
178
  }: SchemaFormProviderProps) {
239
179
  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
180
 
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
181
+ const storeProps: SchemaFormStoreProps = {
182
+ spec,
183
+ valuesProp,
184
+ onChange,
185
+ onSubmit,
186
+ disabled,
187
+ submitted,
188
+ submitting,
189
+ loading,
190
+ error,
191
+ };
192
+ // Created once (lazy initializer) and then kept in sync on every render via
193
+ // `syncProps` below — see schema-form-store.ts's module doc comment for why
194
+ // the store, not React context, is what field-level readers subscribe to.
195
+ const [store] = useState(() => new SchemaFormStore(storeProps, formId));
196
+ // `syncProps` runs here, IN render, so a non-memoized descendant
197
+ // re-rendering this same pass reads fresh values; the resulting `changed`
198
+ // flag is only ACTED ON (`store.notify()`) from the layout effect below —
199
+ // see `syncProps`'s doc comment for why calling it synchronously here
200
+ // instead would trip React's "setState while rendering a different
201
+ // component" guard against an already-mounted, memoized `SchemaFormField`.
202
+ const changed = store.syncProps(storeProps);
203
+ useLayoutEffect(() => {
204
+ if (changed) store.notify();
205
+ });
206
+
207
+ // The field to focus after an invalid submit. `store.submit()` calls this
208
+ // listener synchronously (in the same click/Enter handler that also flips
209
+ // `attempted`, which is what makes an error-containing `AdvancedGroup`
210
+ // branch open itself — see `AdvancedGroupBranch`), so React batches both
211
+ // updates into ONE commit; the actual `.focus()` call happens from the
212
+ // `useEffect` below, which runs AFTER that commit (and after Radix has
213
+ // mounted the newly-open group) — a synchronous `document.getElementById`
214
+ // right here would run against the PRE-update DOM and could never find a
215
+ // control that only exists once the just-opened disclosure mounts its
216
+ // content. A fresh object on every call (not just the name) guarantees the
337
217
  // effect re-fires even when the SAME field is invalid on consecutive
338
218
  // submit attempts, where a primitive dependency wouldn't change.
339
219
  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
-
220
+ useEffect(() => store.onPendingFocus((name) => setPendingFocus({ name })), [store]);
356
221
  useEffect(() => {
357
222
  if (!pendingFocus || typeof document === "undefined") return;
358
- const el = document.getElementById(controlId(formId, pendingFocus.name));
223
+ const el = document.getElementById(controlId(store.formId, pendingFocus.name));
359
224
  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
- );
225
+ }, [pendingFocus, store]);
419
226
 
420
- return <SchemaFormContext value={value}>{children}</SchemaFormContext>;
227
+ return <SchemaFormStoreContext value={store}>{children}</SchemaFormStoreContext>;
421
228
  }
422
229
 
423
230
  // ─── Field control renderers ──────────────────────────────────────────────────
@@ -734,6 +541,15 @@ function FileControl({
734
541
  file,
735
542
  }));
736
543
  }, [value]);
544
+ // `FileUpload.addFiles` also declines a wrong-type file (`accept`) before it
545
+ // reaches `files` — right for a bare FileUpload, but here that would make the
546
+ // "wrong type" designed state unreachable for a drop (the file would just
547
+ // vanish, exactly the `maxSize` failure mode noted below). `addFiles` commits
548
+ // the accepted list and THEN reports rejections, synchronously, so remember
549
+ // the last committed list and re-admit the `accept` rejections onto it for
550
+ // `checkFileIssue` to render with a visible error.
551
+ const lastCommittedRef = useRef<File[]>([]);
552
+ const maxFiles = field.multiple ? field.maxFiles : 1;
737
553
  return (
738
554
  <FileUpload
739
555
  id={id}
@@ -747,15 +563,20 @@ function FileControl({
747
563
  // unreachable (the file the user picked would just vanish with no
748
564
  // feedback). Enforcement instead happens entirely in `checkFileIssue`
749
565
  // below, which renders the oversized file WITH an error item.
750
- maxFiles={field.multiple ? field.maxFiles : 1}
566
+ maxFiles={maxFiles}
751
567
  disabled={disabled}
752
568
  files={controlledFiles}
753
- onFilesChange={(list) =>
754
- setValue(
755
- field.name,
756
- list.map((u) => u.file),
757
- )
758
- }
569
+ onFilesChange={(list) => {
570
+ const next = list.map((u) => u.file);
571
+ lastCommittedRef.current = next;
572
+ setValue(field.name, next);
573
+ }}
574
+ onFilesRejected={(rejections) => {
575
+ const wrongType = rejections.filter((r) => r.reason === "accept").map((r) => r.file);
576
+ if (wrongType.length === 0) return;
577
+ const merged = [...lastCommittedRef.current, ...wrongType];
578
+ setValue(field.name, maxFiles ? merged.slice(0, maxFiles) : merged);
579
+ }}
759
580
  >
760
581
  <FileUploadDropzone />
761
582
  <FileControlList field={field} />
@@ -774,7 +595,6 @@ function GroupTabsControl({
774
595
  describedBy,
775
596
  setValue,
776
597
  }: FieldControlProps & { field: GroupFieldSpec }) {
777
- const { effectiveValues } = useSchemaFormContext();
778
598
  const active =
779
599
  (typeof value === "string" ? value : undefined) ?? field.default ?? field.groups[0]?.key;
780
600
  return (
@@ -797,28 +617,29 @@ function GroupTabsControl({
797
617
  {group.description && (
798
618
  <p className="text-caption text-muted-foreground">{group.description}</p>
799
619
  )}
800
- {group.fields
801
- .filter((child) => isFieldVisible(child, effectiveValues))
802
- .map((child) => (
803
- <SchemaFormField key={child.name} name={child.name} />
804
- ))}
620
+ <GroupTabsBranch group={group} />
805
621
  </TabsContent>
806
622
  ))}
807
623
  </Tabs>
808
624
  );
809
625
  }
810
626
 
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;
627
+ /**
628
+ * One tab branch's visible fields its own `useVisibleFieldNames`
629
+ * subscription, so a `visibleWhen` inside THIS branch re-evaluates
630
+ * independent of `GroupTabsControl`'s (and its parent `SchemaFormField`'s)
631
+ * own re-render.
632
+ */
633
+ function GroupTabsBranch({ group }: { group: GroupItemSpec }) {
634
+ const store = useSchemaFormStore();
635
+ const visibleNames = useVisibleFieldNames(store, group.fields);
636
+ return (
637
+ <>
638
+ {visibleNames.map((name) => (
639
+ <SchemaFormField key={name} name={name} />
640
+ ))}
641
+ </>
642
+ );
822
643
  }
823
644
 
824
645
  /**
@@ -832,9 +653,11 @@ function branchHasError(fields: FieldSpec[], errors: Record<string, string | nul
832
653
  * disclosure never re-imposes itself over a deliberate user action).
833
654
  */
834
655
  function AdvancedGroupBranch({ group }: { group: GroupItemSpec }) {
835
- const { errors, effectiveValues } = useSchemaFormContext();
656
+ const store = useSchemaFormStore();
657
+ const hasError = useBranchHasError(store, group.fields);
658
+ const visibleNames = useVisibleFieldNames(store, group.fields);
836
659
  const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined);
837
- const open = manualOpen ?? branchHasError(group.fields, errors);
660
+ const open = manualOpen ?? hasError;
838
661
  return (
839
662
  <AdvancedGroup
840
663
  title={group.label}
@@ -842,11 +665,9 @@ function AdvancedGroupBranch({ group }: { group: GroupItemSpec }) {
842
665
  open={open}
843
666
  onOpenChange={setManualOpen}
844
667
  >
845
- {group.fields
846
- .filter((child) => isFieldVisible(child, effectiveValues))
847
- .map((child) => (
848
- <SchemaFormField key={child.name} name={child.name} />
849
- ))}
668
+ {visibleNames.map((name) => (
669
+ <SchemaFormField key={name} name={name} />
670
+ ))}
850
671
  </AdvancedGroup>
851
672
  );
852
673
  }
@@ -884,37 +705,44 @@ export interface SchemaFormFieldProps extends Omit<HTMLAttributes<HTMLDivElement
884
705
  * the same spec would render a hidden field in a custom layout while
885
706
  * validation/submission (which always excludes it) disagree.
886
707
  */
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);
708
+ export const SchemaFormField = memo(
709
+ forwardRef<HTMLDivElement, SchemaFormFieldProps>(function SchemaFormField(
710
+ { name, className, ...props },
711
+ ref,
712
+ ) {
713
+ // Hooks run unconditionally, ahead of the `!field`/`!visible` early
714
+ // returns below (rules-of-hooks) — this is exactly what makes typing in
715
+ // field A skip re-rendering field B: `useFieldSnapshot` bails out (via
716
+ // `useSyncExternalStore`'s `Object.is` check on the cached snapshot) for
717
+ // every OTHER mounted `SchemaFormField`, so only the field whose own
718
+ // snapshot changed re-renders.
719
+ const store = useSchemaFormStore();
720
+ const meta = useSchemaFormMeta(store);
721
+ const snapshot = useFieldSnapshot(store, name);
722
+ const field = store.getField(name);
891
723
  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;
724
+ if (!snapshot.visible) return null;
725
+
726
+ const id = controlId(meta.formId, name);
727
+ const labelId = `${meta.formId}-label-${name}`;
728
+ const invalid = snapshot.invalid;
901
729
  const description = field.description;
902
730
  const hasDesc = Boolean(description);
903
731
  const describedBy =
904
- [hasDesc ? descId(ctx.formId, name) : null, invalid ? errorId(ctx.formId, name) : null]
732
+ [hasDesc ? descId(meta.formId, name) : null, invalid ? errorId(meta.formId, name) : null]
905
733
  .filter(Boolean)
906
734
  .join(" ") || undefined;
907
735
 
908
736
  const controlProps: FieldControlProps = {
909
737
  field,
910
- value,
738
+ value: snapshot.value,
911
739
  invalid,
912
- disabled: controlDisabled,
913
- readOnly,
740
+ disabled: snapshot.disabled,
741
+ readOnly: snapshot.readOnly,
914
742
  id,
915
743
  labelId,
916
744
  describedBy,
917
- setValue: ctx.setValue,
745
+ setValue: store.setValue,
918
746
  };
919
747
 
920
748
  const isBoolean = field.type === "boolean";
@@ -970,18 +798,18 @@ export const SchemaFormField = forwardRef<HTMLDivElement, SchemaFormFieldProps>(
970
798
  )}
971
799
 
972
800
  {hasDesc && (
973
- <p id={descId(ctx.formId, name)} className="text-caption text-muted-foreground">
801
+ <p id={descId(meta.formId, name)} className="text-caption text-muted-foreground">
974
802
  {description}
975
803
  </p>
976
804
  )}
977
805
  {invalid && (
978
- <p id={errorId(ctx.formId, name)} className="text-caption text-destructive-text">
979
- {error}
806
+ <p id={errorId(meta.formId, name)} className="text-caption text-destructive-text">
807
+ {snapshot.errorText}
980
808
  </p>
981
809
  )}
982
810
  </div>
983
811
  );
984
- },
812
+ }),
985
813
  );
986
814
 
987
815
  // ─── Fields (all) ─────────────────────────────────────────────────────────────
@@ -991,7 +819,10 @@ export type SchemaFormFieldsProps = HTMLAttributes<HTMLDivElement>;
991
819
  /** Renders every top-level field in the spec, in order. A skeleton while `loading` with no fields. */
992
820
  export const SchemaFormFields = forwardRef<HTMLDivElement, SchemaFormFieldsProps>(
993
821
  function SchemaFormFields({ className, ...props }, ref) {
994
- const { spec, loading, effectiveValues } = useSchemaFormContext();
822
+ const store = useSchemaFormStore();
823
+ const meta = useSchemaFormMeta(store);
824
+ const visibleNames = useVisibleFieldNames(store, meta.spec.fields);
825
+ const { spec, loading } = meta;
995
826
 
996
827
  if (spec.fields.length === 0 && loading) {
997
828
  return (
@@ -1021,11 +852,9 @@ export const SchemaFormFields = forwardRef<HTMLDivElement, SchemaFormFieldsProps
1021
852
  className={cn("flex flex-col gap-4", className)}
1022
853
  {...props}
1023
854
  >
1024
- {spec.fields
1025
- .filter((field) => isFieldVisible(field, effectiveValues))
1026
- .map((field) => (
1027
- <SchemaFormField key={field.name} name={field.name} />
1028
- ))}
855
+ {visibleNames.map((name) => (
856
+ <SchemaFormField key={name} name={name} />
857
+ ))}
1029
858
  </div>
1030
859
  );
1031
860
  },
@@ -1047,7 +876,8 @@ export type SchemaFormErrorProps = HTMLAttributes<HTMLDivElement>;
1047
876
  /** A terminal, form-level submission error (e.g. "Couldn't save settings"). Renders nothing when absent. */
1048
877
  export const SchemaFormError = forwardRef<HTMLDivElement, SchemaFormErrorProps>(
1049
878
  function SchemaFormError({ className, ...props }, ref) {
1050
- const { error } = useSchemaFormContext();
879
+ const store = useSchemaFormStore();
880
+ const { error } = useSchemaFormMeta(store);
1051
881
  if (!error) return null;
1052
882
  return (
1053
883
  <div
@@ -1099,7 +929,8 @@ export interface SchemaFormSubmitProps extends Omit<HTMLAttributes<HTMLButtonEle
1099
929
  export const SchemaFormSubmit = forwardRef<HTMLButtonElement, SchemaFormSubmitProps>(
1100
930
  function SchemaFormSubmit({ label, className, onClick, ...props }, ref) {
1101
931
  const { t } = useLocale();
1102
- const { spec, submitting, submitted, disabled, loading } = useSchemaFormContext();
932
+ const store = useSchemaFormStore();
933
+ const { spec, submitting, submitted, disabled, loading } = useSchemaFormMeta(store);
1103
934
 
1104
935
  if (submitted) {
1105
936
  return (
@@ -1152,7 +983,7 @@ export const SchemaFormSubmit = forwardRef<HTMLButtonElement, SchemaFormSubmitPr
1152
983
  /**
1153
984
  * `SchemaFormTestAction`'s lifecycle: `idle` → `pending` while `onTest` is in
1154
985
  * flight → `success`/`failure` once it settles. Deliberately local component
1155
- * state, NOT part of `SchemaFormContextValue` — so it can never affect field
986
+ * state, NOT part of the `SchemaFormStore` — so it can never affect field
1156
987
  * validity or gate `submit()` (issue #22 maintainer ruling, 2026-09-01: a
1157
988
  * form/group-level test-action slot, kept separate from field validity and
1158
989
  * never gating submit — not per-field `validateAsync` in the validation
@@ -1201,13 +1032,9 @@ export const SchemaFormTestAction = forwardRef<HTMLDivElement, SchemaFormTestAct
1201
1032
  ref,
1202
1033
  ) {
1203
1034
  const { t } = useLocale();
1204
- const {
1205
- effectiveValues,
1206
- disabled: formDisabled,
1207
- loading,
1208
- submitted,
1209
- submitting,
1210
- } = useSchemaFormContext();
1035
+ const store = useSchemaFormStore();
1036
+ const { disabled: formDisabled, loading, submitted, submitting } = useSchemaFormMeta(store);
1037
+ const effectiveValues = useEffectiveValues(store);
1211
1038
  const [status, setStatus] = useState<SchemaFormTestActionStatus>("idle");
1212
1039
  const [failureMessage, setFailureMessage] = useState<string | null>(null);
1213
1040
 
@@ -1339,7 +1166,8 @@ export type SchemaFormRootProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmi
1339
1166
  export const SchemaFormRoot = forwardRef<HTMLFormElement, SchemaFormRootProps>(
1340
1167
  function SchemaFormRoot({ className, children, ...props }, ref) {
1341
1168
  const { t } = useLocale();
1342
- const { submit, headingId, spec, disabled, submitting, loading } = useSchemaFormContext();
1169
+ const store = useSchemaFormStore();
1170
+ const { headingId, spec, disabled, submitting, loading } = useSchemaFormMeta(store);
1343
1171
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
1344
1172
  e.preventDefault();
1345
1173
  // The submit control's `aria-disabled` is a signal, not a lock — this is
@@ -1347,7 +1175,7 @@ export const SchemaFormRoot = forwardRef<HTMLFormElement, SchemaFormRootProps>(
1347
1175
  // too (see `SchemaFormSubmit`): the fields are still a skeleton, so
1348
1176
  // there is nothing real to validate/submit yet.
1349
1177
  if (disabled || submitting || loading) return;
1350
- submit();
1178
+ store.submit();
1351
1179
  };
1352
1180
  return (
1353
1181
  <form
@@ -1373,7 +1201,8 @@ export type SchemaFormTitleProps = HTMLAttributes<HTMLParagraphElement>;
1373
1201
  /** The form heading. Its id is the `<form>`'s `aria-labelledby` target. */
1374
1202
  export const SchemaFormTitle = forwardRef<HTMLParagraphElement, SchemaFormTitleProps>(
1375
1203
  function SchemaFormTitle({ className, children, ...props }, ref) {
1376
- const { headingId, spec } = useSchemaFormContext();
1204
+ const store = useSchemaFormStore();
1205
+ const { headingId, spec } = useSchemaFormMeta(store);
1377
1206
  const content = children ?? spec.title;
1378
1207
  if (!content) return null;
1379
1208
  return (
@@ -1394,7 +1223,8 @@ export type SchemaFormDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
1394
1223
  /** Supplemental description under the title. */
1395
1224
  export const SchemaFormDescription = forwardRef<HTMLParagraphElement, SchemaFormDescriptionProps>(
1396
1225
  function SchemaFormDescription({ className, children, ...props }, ref) {
1397
- const { spec } = useSchemaFormContext();
1226
+ const store = useSchemaFormStore();
1227
+ const { spec } = useSchemaFormMeta(store);
1398
1228
  const content = children ?? spec.description;
1399
1229
  if (!content) return null;
1400
1230
  return (
@@ -1494,7 +1324,13 @@ export const SchemaForm = forwardRef<HTMLDivElement, SchemaFormProps>(function S
1494
1324
  },
1495
1325
  ref,
1496
1326
  ) {
1497
- const result = normalizeFormSpec(spec);
1327
+ // Memoized on `spec`'s reference: a parent re-rendering with the SAME spec
1328
+ // object (the common case once a spec is loaded) must not re-walk/re-
1329
+ // validate it on every keystroke elsewhere in the app — `SchemaFormStore`
1330
+ // itself already relies on `spec` reference-equality to skip its own
1331
+ // recompute (see `syncProps`), so this keeps that contract meaningful one
1332
+ // level up.
1333
+ const result = useMemo(() => normalizeFormSpec(spec), [spec]);
1498
1334
  if (!result.ok) {
1499
1335
  return (
1500
1336
  <SchemaFormFallback ref={ref} message={result.reason} className={className} {...props} />