@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.
- package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- 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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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,
|
|
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 <
|
|
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={
|
|
566
|
+
maxFiles={maxFiles}
|
|
751
567
|
disabled={disabled}
|
|
752
568
|
files={controlledFiles}
|
|
753
|
-
onFilesChange={(list) =>
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
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
|
-
/**
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
{
|
|
846
|
-
|
|
847
|
-
|
|
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 =
|
|
888
|
-
function SchemaFormField(
|
|
889
|
-
|
|
890
|
-
|
|
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 (!
|
|
893
|
-
|
|
894
|
-
const id = controlId(
|
|
895
|
-
const labelId = `${
|
|
896
|
-
const
|
|
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(
|
|
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:
|
|
913
|
-
readOnly,
|
|
740
|
+
disabled: snapshot.disabled,
|
|
741
|
+
readOnly: snapshot.readOnly,
|
|
914
742
|
id,
|
|
915
743
|
labelId,
|
|
916
744
|
describedBy,
|
|
917
|
-
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(
|
|
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(
|
|
979
|
-
{
|
|
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
|
|
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
|
-
{
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
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
|
|
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 `
|
|
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
|
-
|
|
1206
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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} />
|