@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
@@ -0,0 +1,506 @@
1
+ "use client";
2
+
3
+ /**
4
+ * schema-form-store.ts — the per-field external store backing `SchemaForm`
5
+ * (perf review §3.4: "one context carries all values → every keystroke
6
+ * re-renders every field and re-runs isFieldVisible across the spec").
7
+ *
8
+ * One `SchemaFormStore` instance lives for a `SchemaFormProvider`'s whole
9
+ * lifetime (created once via a lazy `useState` initializer in
10
+ * `schema-form.tsx`). It owns `values`/`attempted` and derives
11
+ * `effectiveValues`/`errors` from them, exactly as the previous
12
+ * `useMemo`-based implementation did — the FORMULAS are unchanged (see
13
+ * `computeMergedValues`, ported byte-for-byte from the old `mergedValues`
14
+ * closure). What changes is how a component reads them:
15
+ *
16
+ * - `SchemaFormField` subscribes to its OWN `FieldSnapshot` via
17
+ * `useFieldSnapshot(name)` (a `useSyncExternalStore` selector). The
18
+ * snapshot is cached per field name and a fresh compute that is
19
+ * field-by-field equal to the cached one returns the CACHED object —
20
+ * `useSyncExternalStore`'s own `Object.is` bail-out then skips re-
21
+ * rendering that field when an unrelated field's keystroke fires the
22
+ * store's `emit()`. `SchemaFormField` is also wrapped in `memo()`, so a
23
+ * parent container re-rendering (see below) does not cascade into it
24
+ * either, as long as the `name`/`className`/`...props` it was given
25
+ * didn't change.
26
+ * - Containers that list CHILDREN by name (`SchemaFormFields`,
27
+ * `GroupTabsControl`'s per-branch content, `AdvancedGroupBranch`) read
28
+ * `useVisibleFieldNames(fields)` — a cached `string[]` that only changes
29
+ * reference when the VISIBLE SET actually changes, not on every
30
+ * keystroke — so typing in one field doesn't even cause its siblings'
31
+ * list container to reconcile new elements for them.
32
+ * - `useSchemaFormMeta()` covers the slow-changing, form-wide flags
33
+ * (`disabled`/`submitted`/`submitting`/`loading`/`error`/`attempted`/
34
+ * `spec`) as ONE cached object — stable across ordinary typing, so a
35
+ * component reading only these (e.g. `SchemaFormRoot`) doesn't re-render
36
+ * on every keystroke either.
37
+ * - `useEffectiveValues()` exposes the full merged/stripped values object
38
+ * with the SAME reference-stability contract the old `useMemo` gave
39
+ * `SchemaFormTestAction` (which compares it by `!==` to detect "the user
40
+ * edited while a test was in flight") — unaffected by this refactor.
41
+ *
42
+ * Every derived getter is a plain, synchronous function of the store's
43
+ * current `props`/`internalValues`/`attempted` — none of this maintains
44
+ * parallel state that could drift from `values`.
45
+ */
46
+
47
+ import { useCallback, useSyncExternalStore, type ReactNode } from "react";
48
+ import {
49
+ collectValidatableFields,
50
+ isFieldVisible,
51
+ initialFormValues,
52
+ validateForm,
53
+ type FieldSpec,
54
+ type FormSubmitState,
55
+ type FormValue,
56
+ type FormValues,
57
+ type GroupFieldSpec,
58
+ type GroupItemSpec,
59
+ type NormalizedFormSpec,
60
+ } from "./schema-form-spec";
61
+
62
+ // ─── Pure helpers (spec + values → values), no React ───────────────────────
63
+
64
+ /** The effective value for a field (state value, else its default/empty). */
65
+ function effectiveValue(field: FieldSpec, values: FormValues): FormValue {
66
+ const v = values[field.name];
67
+ if (v !== undefined) return v;
68
+ if (field.type === "group") return field.default ?? field.groups[0]?.key;
69
+ if ("default" in field && field.default !== undefined) return field.default;
70
+ if (field.type === "boolean") return false;
71
+ if (field.type === "multi-enum" || field.type === "list") return [];
72
+ if (field.type === "key-value") return [];
73
+ if (field.type === "file") return [];
74
+ return undefined;
75
+ }
76
+
77
+ /** Remove every field's key (recursively, including nested group branches) from `values`. */
78
+ function stripFields(fields: FieldSpec[], values: FormValues): void {
79
+ for (const field of fields) {
80
+ delete values[field.name];
81
+ if (field.type === "group") {
82
+ for (const group of field.groups) stripFields(group.fields, values);
83
+ }
84
+ }
85
+ }
86
+
87
+ /** The active branch of a `variant: "tabs"` group, given its own (possibly-just-computed) value. */
88
+ function activeTabBranch(field: GroupFieldSpec, activeKey: FormValue): GroupItemSpec | undefined {
89
+ const key = typeof activeKey === "string" ? activeKey : undefined;
90
+ return (
91
+ field.groups.find((g) => g.key === key) ??
92
+ field.groups.find((g) => g.key === field.default) ??
93
+ field.groups[0]
94
+ );
95
+ }
96
+
97
+ /**
98
+ * `values` with every field's default/empty fallback applied and every
99
+ * currently-hidden field's subtree stripped. Ported byte-for-byte from the
100
+ * previous `SchemaFormProvider`'s `mergedValues` closure — see that
101
+ * implementation's history for the full reasoning on why hidden fields are
102
+ * stripped rather than merely skipped.
103
+ */
104
+ function computeMergedValues(fields: FieldSpec[], base: FormValues): FormValues {
105
+ const merged: FormValues = { ...base };
106
+ const fill = (fs: FieldSpec[]) => {
107
+ for (const field of fs) {
108
+ if (!isFieldVisible(field, merged)) {
109
+ delete merged[field.name];
110
+ if (field.type === "group") {
111
+ for (const group of field.groups) stripFields(group.fields, merged);
112
+ }
113
+ continue;
114
+ }
115
+ merged[field.name] = effectiveValue(field, merged);
116
+ if (field.type !== "group") continue;
117
+ if (field.variant === "tabs") {
118
+ const active = activeTabBranch(field, merged[field.name]);
119
+ for (const group of field.groups) {
120
+ if (group === active) fill(group.fields);
121
+ else stripFields(group.fields, merged);
122
+ }
123
+ } else {
124
+ for (const group of field.groups) fill(group.fields);
125
+ }
126
+ }
127
+ };
128
+ fill(fields);
129
+ return merged;
130
+ }
131
+
132
+ /** Does any field in this branch (including nested group branches) currently have an error? */
133
+ function branchHasError(fields: FieldSpec[], errors: Record<string, string | null>): boolean {
134
+ for (const field of fields) {
135
+ if (errors[field.name]) return true;
136
+ if (field.type === "group") {
137
+ for (const group of field.groups) {
138
+ if (branchHasError(group.fields, errors)) return true;
139
+ }
140
+ }
141
+ }
142
+ return false;
143
+ }
144
+
145
+ function buildFieldMap(fields: FieldSpec[]): Map<string, FieldSpec> {
146
+ const map = new Map<string, FieldSpec>();
147
+ const walk = (fs: FieldSpec[]) => {
148
+ for (const field of fs) {
149
+ map.set(field.name, field);
150
+ if (field.type === "group") {
151
+ for (const group of field.groups) walk(group.fields);
152
+ }
153
+ }
154
+ };
155
+ walk(fields);
156
+ return map;
157
+ }
158
+
159
+ function sameNames(a: string[], b: string[]): boolean {
160
+ if (a.length !== b.length) return false;
161
+ for (let i = 0; i < a.length; i++) {
162
+ if (a[i] !== b[i]) return false;
163
+ }
164
+ return true;
165
+ }
166
+
167
+ // ─── Store ──────────────────────────────────────────────────────────────────
168
+
169
+ export interface SchemaFormStoreProps {
170
+ spec: NormalizedFormSpec;
171
+ valuesProp: FormValues | undefined;
172
+ onChange?: (values: FormValues) => void;
173
+ onSubmit?: (state: FormSubmitState) => void;
174
+ disabled: boolean;
175
+ submitted: boolean;
176
+ submitting: boolean;
177
+ loading: boolean;
178
+ error: ReactNode;
179
+ }
180
+
181
+ export interface FieldSnapshot {
182
+ value: FormValue;
183
+ invalid: boolean;
184
+ errorText: string | null;
185
+ visible: boolean;
186
+ disabled: boolean;
187
+ readOnly: boolean;
188
+ }
189
+
190
+ export interface SchemaFormMeta {
191
+ spec: NormalizedFormSpec;
192
+ formId: string;
193
+ headingId: string;
194
+ disabled: boolean;
195
+ submitted: boolean;
196
+ submitting: boolean;
197
+ loading: boolean;
198
+ error: ReactNode;
199
+ attempted: boolean;
200
+ }
201
+
202
+ const EMPTY_ERRORS: Record<string, string | null> = {};
203
+
204
+ /**
205
+ * `effectiveValue` mints a FRESH `[]` for an unset list/multi-enum/key-value/
206
+ * file field on every call. A controlled form whose `values` omit such a field
207
+ * would then never match its cached snapshot, so `getFieldSnapshot` would
208
+ * return a new object on every `useSyncExternalStore` read — an infinite
209
+ * re-render loop ("Maximum update depth exceeded"). Two empty arrays are the
210
+ * same value for rendering purposes.
211
+ */
212
+ function sameFieldValue(a: FormValue, b: FormValue): boolean {
213
+ if (Object.is(a, b)) return true;
214
+ return Array.isArray(a) && Array.isArray(b) && a.length === 0 && b.length === 0;
215
+ }
216
+
217
+ function fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {
218
+ return (
219
+ sameFieldValue(a.value, b.value) &&
220
+ a.invalid === b.invalid &&
221
+ a.errorText === b.errorText &&
222
+ a.visible === b.visible &&
223
+ a.disabled === b.disabled &&
224
+ a.readOnly === b.readOnly
225
+ );
226
+ }
227
+
228
+ function metaEqual(a: SchemaFormMeta, b: SchemaFormMeta): boolean {
229
+ return (
230
+ a.spec === b.spec &&
231
+ a.formId === b.formId &&
232
+ a.headingId === b.headingId &&
233
+ a.disabled === b.disabled &&
234
+ a.submitted === b.submitted &&
235
+ a.submitting === b.submitting &&
236
+ a.loading === b.loading &&
237
+ a.error === b.error &&
238
+ a.attempted === b.attempted
239
+ );
240
+ }
241
+
242
+ /** See the module doc comment for the full design rationale. */
243
+ export class SchemaFormStore {
244
+ private props: SchemaFormStoreProps;
245
+ private internalValues: FormValues;
246
+ private attempted = false;
247
+ private readonly listeners = new Set<() => void>();
248
+ private readonly pendingFocusListeners = new Set<(name: string) => void>();
249
+ readonly formId: string;
250
+ readonly headingId: string;
251
+
252
+ private mergedCache: { base: FormValues; fields: FieldSpec[]; result: FormValues } | null = null;
253
+ private errorsCache: {
254
+ merged: FormValues;
255
+ fields: FieldSpec[];
256
+ attempted: boolean;
257
+ result: Record<string, string | null>;
258
+ } | null = null;
259
+ private metaCache: SchemaFormMeta | null = null;
260
+ private fieldMapCache: { fields: FieldSpec[]; map: Map<string, FieldSpec> } | null = null;
261
+ private readonly fieldSnapshotCache = new Map<string, FieldSnapshot>();
262
+ private readonly namesCache = new WeakMap<
263
+ FieldSpec[],
264
+ { effective: FormValues; names: string[] }
265
+ >();
266
+
267
+ constructor(props: SchemaFormStoreProps, formId: string) {
268
+ this.props = props;
269
+ this.formId = formId;
270
+ this.headingId = `${formId}-title`;
271
+ this.internalValues = initialFormValues(props.spec.fields);
272
+ }
273
+
274
+ subscribe = (listener: () => void): (() => void) => {
275
+ this.listeners.add(listener);
276
+ return () => {
277
+ this.listeners.delete(listener);
278
+ };
279
+ };
280
+
281
+ /** Fires once, with the field's own name, whenever an invalid submit should move focus to it. */
282
+ onPendingFocus(listener: (name: string) => void): () => void {
283
+ this.pendingFocusListeners.add(listener);
284
+ return () => {
285
+ this.pendingFocusListeners.delete(listener);
286
+ };
287
+ }
288
+
289
+ private emit(): void {
290
+ for (const listener of this.listeners) listener();
291
+ }
292
+
293
+ /**
294
+ * Sync the latest render's props/closures. Called unconditionally on every
295
+ * `SchemaFormProvider` render, DURING that render (not an effect) — so a
296
+ * non-memoized descendant re-rendering in this SAME pass (`SchemaFormRoot`,
297
+ * `SchemaFormFields`, …) reads the fresh values via `getMeta`/
298
+ * `getFieldSnapshot`, not last render's. It deliberately does NOT call
299
+ * `emit()` itself — that would call an already-mounted, `memo`-wrapped
300
+ * `SchemaFormField`'s `useSyncExternalStore` listener synchronously WHILE
301
+ * `SchemaFormProvider` (a different component) is still rendering, which
302
+ * React explicitly disallows ("Cannot update a component while rendering a
303
+ * different component"). The caller instead reads the returned `changed`
304
+ * flag and calls `notify()` from a `useLayoutEffect` — i.e. after this
305
+ * whole render has committed — to wake any memoized subscriber that this
306
+ * render's own top-down reconciliation skipped over.
307
+ */
308
+ syncProps(next: SchemaFormStoreProps): boolean {
309
+ const prev = this.props;
310
+ const formNameChanged = next.spec.formName !== prev.spec.formName;
311
+ this.props = next;
312
+ if (next.valuesProp === undefined && formNameChanged) {
313
+ this.internalValues = initialFormValues(next.spec.fields);
314
+ }
315
+ return (
316
+ formNameChanged ||
317
+ prev.spec !== next.spec ||
318
+ prev.valuesProp !== next.valuesProp ||
319
+ prev.disabled !== next.disabled ||
320
+ prev.submitted !== next.submitted ||
321
+ prev.submitting !== next.submitting ||
322
+ prev.loading !== next.loading ||
323
+ prev.error !== next.error
324
+ );
325
+ }
326
+
327
+ /** Wakes every subscriber outside of any component's render phase (see `syncProps`). */
328
+ notify(): void {
329
+ this.emit();
330
+ }
331
+
332
+ private get isControlled(): boolean {
333
+ return this.props.valuesProp !== undefined;
334
+ }
335
+
336
+ private getFieldMap(): Map<string, FieldSpec> {
337
+ const fields = this.props.spec.fields;
338
+ if (this.fieldMapCache && this.fieldMapCache.fields === fields) return this.fieldMapCache.map;
339
+ const map = buildFieldMap(fields);
340
+ this.fieldMapCache = { fields, map };
341
+ return map;
342
+ }
343
+
344
+ getField(name: string): FieldSpec | undefined {
345
+ return this.getFieldMap().get(name);
346
+ }
347
+
348
+ getValues(): FormValues {
349
+ return this.isControlled ? (this.props.valuesProp as FormValues) : this.internalValues;
350
+ }
351
+
352
+ /** The merged/stripped values every visibility check and submit resolves against. Reference-stable when nothing changed (SchemaFormTestAction relies on this to detect a stale in-flight test). */
353
+ getEffectiveValues(): FormValues {
354
+ const base = this.getValues();
355
+ const fields = this.props.spec.fields;
356
+ if (this.mergedCache && this.mergedCache.base === base && this.mergedCache.fields === fields) {
357
+ return this.mergedCache.result;
358
+ }
359
+ const result = computeMergedValues(fields, base);
360
+ this.mergedCache = { base, fields, result };
361
+ return result;
362
+ }
363
+
364
+ /** Empty until the first submit attempt (`EMPTY_ERRORS`, a stable reference); live-updates after. */
365
+ getErrors(): Record<string, string | null> {
366
+ if (!this.attempted) return EMPTY_ERRORS;
367
+ const merged = this.getEffectiveValues();
368
+ const fields = this.props.spec.fields;
369
+ if (
370
+ this.errorsCache &&
371
+ this.errorsCache.merged === merged &&
372
+ this.errorsCache.fields === fields &&
373
+ this.errorsCache.attempted === this.attempted
374
+ ) {
375
+ return this.errorsCache.result;
376
+ }
377
+ const result = validateForm(fields, merged);
378
+ this.errorsCache = { merged, fields, attempted: this.attempted, result };
379
+ return result;
380
+ }
381
+
382
+ /** The slow-changing, form-wide flags — cached so ordinary typing never changes this object's reference. */
383
+ getMeta(): SchemaFormMeta {
384
+ const next: SchemaFormMeta = {
385
+ spec: this.props.spec,
386
+ formId: this.formId,
387
+ headingId: this.headingId,
388
+ disabled: this.props.disabled,
389
+ submitted: this.props.submitted,
390
+ submitting: this.props.submitting,
391
+ loading: this.props.loading,
392
+ error: this.props.error,
393
+ attempted: this.attempted,
394
+ };
395
+ if (this.metaCache && metaEqual(this.metaCache, next)) return this.metaCache;
396
+ this.metaCache = next;
397
+ return next;
398
+ }
399
+
400
+ /** One field's reactive data. Cached per name — returns the SAME object when nothing about this field changed, so `useSyncExternalStore` bails out of re-rendering it. */
401
+ getFieldSnapshot(name: string): FieldSnapshot {
402
+ const field = this.getField(name);
403
+ const merged = this.getEffectiveValues();
404
+ const values = this.getValues();
405
+ const errors = this.getErrors();
406
+ const errorText = errors[name] ?? null;
407
+ const next: FieldSnapshot = {
408
+ value: field ? effectiveValue(field, values) : undefined,
409
+ invalid: Boolean(errorText),
410
+ errorText,
411
+ visible: field ? isFieldVisible(field, merged) : false,
412
+ disabled: this.props.disabled || this.props.submitted || this.props.submitting,
413
+ readOnly: this.props.submitted,
414
+ };
415
+ const prev = this.fieldSnapshotCache.get(name);
416
+ if (prev && fieldSnapshotEqual(prev, next)) return prev;
417
+ this.fieldSnapshotCache.set(name, next);
418
+ return next;
419
+ }
420
+
421
+ /**
422
+ * Names of `fields` that are currently visible (top-level filter of
423
+ * exactly that array — a `group`'s own branches call this again with
424
+ * their OWN `fields`, which is what lets a branch react to a nested
425
+ * `visibleWhen` independent of its ancestor `SchemaFormField`'s own
426
+ * re-render). Cached by array reference, so a caller whose visible SET
427
+ * didn't change gets back the SAME array across an unrelated keystroke.
428
+ */
429
+ getVisibleNames(fields: FieldSpec[]): string[] {
430
+ const effective = this.getEffectiveValues();
431
+ const cached = this.namesCache.get(fields);
432
+ if (cached && cached.effective === effective) return cached.names;
433
+ const names = fields
434
+ .filter((field) => isFieldVisible(field, effective))
435
+ .map((field) => field.name);
436
+ const stable = cached && sameNames(cached.names, names) ? cached.names : names;
437
+ this.namesCache.set(fields, { effective, names: stable });
438
+ return stable;
439
+ }
440
+
441
+ getBranchHasError(fields: FieldSpec[]): boolean {
442
+ return branchHasError(fields, this.getErrors());
443
+ }
444
+
445
+ setValue = (name: string, value: FormValue): void => {
446
+ const base = this.getValues();
447
+ const next: FormValues = { ...base, [name]: value };
448
+ if (!this.isControlled) this.internalValues = next;
449
+ this.props.onChange?.(next);
450
+ this.emit();
451
+ };
452
+
453
+ submit = (): void => {
454
+ this.attempted = true;
455
+ const merged = this.getEffectiveValues();
456
+ const errs = validateForm(this.props.spec.fields, merged);
457
+ const invalidNames = Object.keys(errs).filter((n) => errs[n]);
458
+ if (invalidNames.length > 0) {
459
+ const firstInvalid = collectValidatableFields(this.props.spec.fields, merged).find(
460
+ (f) => errs[f.name],
461
+ );
462
+ this.emit();
463
+ if (firstInvalid) {
464
+ for (const listener of this.pendingFocusListeners) listener(firstInvalid.name);
465
+ }
466
+ return;
467
+ }
468
+ this.emit();
469
+ this.props.onSubmit?.({ formName: this.props.spec.formName, values: merged });
470
+ };
471
+
472
+ reset = (): void => {
473
+ this.attempted = false;
474
+ const seeded = initialFormValues(this.props.spec.fields);
475
+ if (!this.isControlled) this.internalValues = seeded;
476
+ this.props.onChange?.(seeded);
477
+ this.emit();
478
+ };
479
+ }
480
+
481
+ // ─── Hooks ──────────────────────────────────────────────────────────────────
482
+
483
+ export function useSchemaFormMeta(store: SchemaFormStore): SchemaFormMeta {
484
+ const getSnapshot = useCallback(() => store.getMeta(), [store]);
485
+ return useSyncExternalStore(store.subscribe, getSnapshot);
486
+ }
487
+
488
+ export function useFieldSnapshot(store: SchemaFormStore, name: string): FieldSnapshot {
489
+ const getSnapshot = useCallback(() => store.getFieldSnapshot(name), [store, name]);
490
+ return useSyncExternalStore(store.subscribe, getSnapshot);
491
+ }
492
+
493
+ export function useEffectiveValues(store: SchemaFormStore): FormValues {
494
+ const getSnapshot = useCallback(() => store.getEffectiveValues(), [store]);
495
+ return useSyncExternalStore(store.subscribe, getSnapshot);
496
+ }
497
+
498
+ export function useVisibleFieldNames(store: SchemaFormStore, fields: FieldSpec[]): string[] {
499
+ const getSnapshot = useCallback(() => store.getVisibleNames(fields), [store, fields]);
500
+ return useSyncExternalStore(store.subscribe, getSnapshot);
501
+ }
502
+
503
+ export function useBranchHasError(store: SchemaFormStore, fields: FieldSpec[]): boolean {
504
+ const getSnapshot = useCallback(() => store.getBranchHasError(fields), [store, fields]);
505
+ return useSyncExternalStore(store.subscribe, getSnapshot);
506
+ }
@@ -1,5 +1,6 @@
1
+ import { Profiler, type ProfilerOnRenderCallback } from "react";
1
2
  import { describe, expect, it, vi } from "vitest";
2
- import { render, screen, waitFor } from "@testing-library/react";
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
4
  import userEvent from "@testing-library/user-event";
4
5
  import {
5
6
  SchemaForm,
@@ -99,6 +100,13 @@ const defaultedConditionalSpec: FormSpec = {
99
100
  ],
100
101
  };
101
102
 
103
+ /** `SchemaFormProvider.spec` is a `NormalizedFormSpec` — normalize a plain `FormSpec` fixture once, up front, so tests can pass it directly. */
104
+ function normalizeSpecOrThrow(spec: FormSpec) {
105
+ const result = normalizeFormSpec(spec);
106
+ if (!result.ok) throw new Error(`expected spec to normalize: ${result.reason}`);
107
+ return result.spec;
108
+ }
109
+
102
110
  describe("SchemaForm — rendering", () => {
103
111
  it("renders the title, fields, and a submit button", () => {
104
112
  render(<SchemaForm spec={simpleSpec} />);
@@ -390,6 +398,22 @@ describe("SchemaForm — validation (submit) + focus-first-invalid", () => {
390
398
  expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
391
399
  });
392
400
 
401
+ it("keeps a DROPPED wrong-type file visible with its inline error instead of silently dropping it", async () => {
402
+ render(
403
+ <SchemaForm
404
+ spec={{
405
+ formName: "f",
406
+ fields: [{ type: "file", name: "creds", label: "Credentials", accept: ".json" }],
407
+ }}
408
+ />,
409
+ );
410
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
411
+ const badFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
412
+ fireEvent.drop(dropzone, { dataTransfer: { files: [badFile] } });
413
+ expect(await screen.findByText("creds.exe")).toBeInTheDocument();
414
+ expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
415
+ });
416
+
393
417
  it("auto-reveals a collapsed advanced-group branch that holds the first invalid field, and focuses it", async () => {
394
418
  const user = userEvent.setup();
395
419
  const onSubmit = vi.fn();
@@ -496,6 +520,23 @@ describe("SchemaForm — file field is controlled", () => {
496
520
  });
497
521
 
498
522
  describe("SchemaForm — controlled + submitted", () => {
523
+ it("renders a controlled form whose `values` omit its array-valued fields without a render loop", () => {
524
+ // An unset file/list field resolves to a fresh `[]` on every snapshot read;
525
+ // unless that counts as unchanged, `useSyncExternalStore` loops forever.
526
+ render(
527
+ <SchemaForm
528
+ spec={{
529
+ formName: "f",
530
+ fields: [
531
+ { type: "file", name: "creds", label: "Credentials" },
532
+ { type: "list", name: "tags", label: "Tags" },
533
+ ],
534
+ }}
535
+ values={{}}
536
+ />,
537
+ );
538
+ expect(screen.getByText("Credentials")).toBeInTheDocument();
539
+ });
499
540
  it("reflects controlled values and calls onChange", async () => {
500
541
  const user = userEvent.setup();
501
542
  const onChange = vi.fn();
@@ -898,3 +939,83 @@ describe("SchemaFormTestAction", () => {
898
939
  expect(onTest).not.toHaveBeenCalled();
899
940
  });
900
941
  });
942
+
943
+ // Perf review §3.4: "one context carries all values → every keystroke
944
+ // re-renders every field and re-runs isFieldVisible across the spec". Each
945
+ // `SchemaFormField` gets its OWN `<Profiler>` (React's own render-counting
946
+ // primitive — no new dependency) so a "commit" of one field can be told
947
+ // apart from a genuine re-render of another: `Profiler`'s `onRender` fires
948
+ // once per commit for every Profiler whose subtree was part of that commit,
949
+ // but `SchemaFormField` is wrapped in `memo` and reads its own value via a
950
+ // per-field `useSyncExternalStore` snapshot — a keystroke in one field must
951
+ // not change the CACHED snapshot object identity for any other field, so its
952
+ // memo bails and its own nested Profiler never re-fires.
953
+ describe("SchemaForm — per-field subscription (#review 3.4: one context re-renders every field)", () => {
954
+ function renderCounts() {
955
+ const counts: Record<string, number> = {};
956
+ const onRender: ProfilerOnRenderCallback = (id) => {
957
+ counts[id] = (counts[id] ?? 0) + 1;
958
+ };
959
+ return { counts, onRender };
960
+ }
961
+
962
+ it("typing in one field does not re-render a sibling field", async () => {
963
+ const user = userEvent.setup();
964
+ const { counts, onRender } = renderCounts();
965
+ render(
966
+ <SchemaFormProvider spec={normalizeSpecOrThrow(simpleSpec)}>
967
+ <SchemaFormRoot>
968
+ <Profiler id="name" onRender={onRender}>
969
+ <SchemaFormField name="name" />
970
+ </Profiler>
971
+ <Profiler id="email" onRender={onRender}>
972
+ <SchemaFormField name="email" />
973
+ </Profiler>
974
+ </SchemaFormRoot>
975
+ </SchemaFormProvider>,
976
+ );
977
+
978
+ // Both fields commit once on mount.
979
+ expect(counts.name).toBe(1);
980
+ expect(counts.email).toBe(1);
981
+
982
+ await user.type(screen.getByLabelText(/Name/), "Ada");
983
+
984
+ // The typed-in field re-renders once per keystroke; its untouched
985
+ // sibling's `useFieldSnapshot` bails (same cached snapshot reference),
986
+ // so its `memo`-wrapped `SchemaFormField` — and the `Profiler` around
987
+ // it — never fires again.
988
+ expect(counts.name).toBe(1 + "Ada".length);
989
+ expect(counts.email).toBe(1);
990
+ });
991
+
992
+ it("typing in one group branch's field does not re-render a field from a sibling branch of the same group", async () => {
993
+ const user = userEvent.setup();
994
+ const { counts, onRender } = renderCounts();
995
+ render(
996
+ <SchemaFormProvider spec={normalizeSpecOrThrow(groupSpec)}>
997
+ <SchemaFormRoot>
998
+ <Profiler id="apiKey" onRender={onRender}>
999
+ <SchemaFormField name="apiKey" />
1000
+ </Profiler>
1001
+ <Profiler id="clientId" onRender={onRender}>
1002
+ <SchemaFormField name="clientId" />
1003
+ </Profiler>
1004
+ </SchemaFormRoot>
1005
+ </SchemaFormProvider>,
1006
+ );
1007
+
1008
+ expect(counts.apiKey).toBe(1);
1009
+ // `apiKey` and `clientId` live in two different branches of the SAME
1010
+ // `group` field (`store.getField` resolves a nested field by name via
1011
+ // its recursive field map) — the per-field snapshot cache is keyed by
1012
+ // name, not by which branch a field happens to sit in, so this still
1013
+ // isolates them from each other.
1014
+ expect(counts.clientId).toBe(1);
1015
+
1016
+ await user.type(screen.getByLabelText(/API key/), "sk-live");
1017
+
1018
+ expect(counts.apiKey).toBe(1 + "sk-live".length);
1019
+ expect(counts.clientId).toBe(1);
1020
+ });
1021
+ });