@classytic/formkit 1.4.0 → 1.5.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/CHANGELOG.md +61 -0
- package/README.md +136 -9
- package/dist/index.d.mts +132 -5
- package/dist/index.mjs +513 -162
- package/dist/server.d.mts +81 -3
- package/dist/server.mjs +214 -12
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,65 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { PureComponent, createContext, memo, useContext, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
3
3
|
import { useFieldArray, useForm, useFormContext, useFormState, useWatch } from "react-hook-form";
|
|
4
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
4
|
import { clsx } from "clsx";
|
|
6
5
|
import { twMerge } from "tailwind-merge";
|
|
6
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
7
7
|
|
|
8
|
+
//#region src/utils.ts
|
|
9
|
+
/**
|
|
10
|
+
* Utility function to merge CSS classes with Tailwind CSS conflict resolution.
|
|
11
|
+
*
|
|
12
|
+
* Combines `clsx` for conditional class handling with `tailwind-merge`
|
|
13
|
+
* for proper Tailwind CSS class conflict resolution.
|
|
14
|
+
*
|
|
15
|
+
* @param inputs - Class values to merge (strings, arrays, objects, or conditionals)
|
|
16
|
+
* @returns Merged and deduplicated class string
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* // Basic usage
|
|
21
|
+
* cn("px-2 py-1", "px-4") // "py-1 px-4"
|
|
22
|
+
*
|
|
23
|
+
* // Conditional classes
|
|
24
|
+
* cn("base", isActive && "active", { "disabled": isDisabled })
|
|
25
|
+
*
|
|
26
|
+
* // Arrays
|
|
27
|
+
* cn(["flex", "items-center"], "gap-2")
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
function cn(...inputs) {
|
|
31
|
+
return twMerge(clsx(inputs));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Shallow equality check for arrays and primitives.
|
|
35
|
+
* Used to stabilize useWatch output without JSON.stringify overhead.
|
|
36
|
+
*/
|
|
37
|
+
function shallowEqual(a, b) {
|
|
38
|
+
if (Object.is(a, b)) return true;
|
|
39
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
40
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
41
|
+
if (a.length !== b.length) return false;
|
|
42
|
+
for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
const keysA = Object.keys(a);
|
|
46
|
+
const keysB = Object.keys(b);
|
|
47
|
+
if (keysA.length !== keysB.length) return false;
|
|
48
|
+
for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True outside production builds. Guards `typeof process` so bundler-less ESM
|
|
53
|
+
* environments (no `process` global) don't throw a ReferenceError — they are
|
|
54
|
+
* treated as dev, matching how missing-component reporting already behaved.
|
|
55
|
+
*
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
function isDev() {
|
|
59
|
+
return typeof process === "undefined" || process.env["NODE_ENV"] !== "production";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
8
63
|
//#region src/FormSystemContext.tsx
|
|
9
64
|
const FormSystemContext = createContext(null);
|
|
10
65
|
FormSystemContext.displayName = "FormSystemContext";
|
|
@@ -41,12 +96,17 @@ FormSystemContext.displayName = "FormSystemContext";
|
|
|
41
96
|
* }
|
|
42
97
|
* ```
|
|
43
98
|
*/
|
|
44
|
-
function FormSystemProvider({ components, layouts, children }) {
|
|
99
|
+
function FormSystemProvider({ components, layouts, onMissingComponent, children }) {
|
|
45
100
|
return /* @__PURE__ */ jsx(FormSystemContext, {
|
|
46
101
|
value: useMemo(() => ({
|
|
47
102
|
components: components ?? {},
|
|
48
|
-
layouts: layouts ?? {}
|
|
49
|
-
|
|
103
|
+
layouts: layouts ?? {},
|
|
104
|
+
onMissingComponent
|
|
105
|
+
}), [
|
|
106
|
+
components,
|
|
107
|
+
layouts,
|
|
108
|
+
onMissingComponent
|
|
109
|
+
]),
|
|
50
110
|
children
|
|
51
111
|
});
|
|
52
112
|
}
|
|
@@ -73,11 +133,16 @@ function useFormSystem() {
|
|
|
73
133
|
* 1. Variant-specific component: `components[variant][type]`
|
|
74
134
|
* 2. Type-specific component: `components[type]`
|
|
75
135
|
* 3. Default component: `components["default"]`
|
|
76
|
-
*
|
|
136
|
+
*
|
|
137
|
+
* There is intentionally NO "text" fallback: rendering a text input for an
|
|
138
|
+
* unregistered `date`/`switch`/etc. is a silent wrong-widget bug. A missing
|
|
139
|
+
* registration instead renders a visible placeholder (`MissingFieldComponent`)
|
|
140
|
+
* — in production too — and notifies `onMissingComponent`, so the gap is
|
|
141
|
+
* observable rather than a mystery empty box.
|
|
77
142
|
*
|
|
78
143
|
* @param type - Field type identifier
|
|
79
144
|
* @param variant - Optional variant name
|
|
80
|
-
* @returns Field component or
|
|
145
|
+
* @returns Field component or the visible placeholder
|
|
81
146
|
*
|
|
82
147
|
* @internal
|
|
83
148
|
*/
|
|
@@ -91,13 +156,7 @@ function useFieldComponent(type, variant) {
|
|
|
91
156
|
if (typeComponent && typeof typeComponent === "function") return typeComponent;
|
|
92
157
|
const defaultComponent = components["default"];
|
|
93
158
|
if (defaultComponent && typeof defaultComponent === "function") return defaultComponent;
|
|
94
|
-
|
|
95
|
-
if (textComponent && typeof textComponent === "function") return textComponent;
|
|
96
|
-
if (typeof process === "undefined" || process.env["NODE_ENV"] !== "production") {
|
|
97
|
-
console.warn(`[FormKit] No component found for type "${type}"${variant ? ` (variant: "${variant}")` : ""}. Register a component for this type in your FormSystemProvider.`);
|
|
98
|
-
return MissingFieldComponent;
|
|
99
|
-
}
|
|
100
|
-
return NullComponent;
|
|
159
|
+
return MissingFieldComponent;
|
|
101
160
|
}
|
|
102
161
|
/**
|
|
103
162
|
* Hook to get a layout component by type and optional variant.
|
|
@@ -136,81 +195,40 @@ function DefaultLayout({ children, className }) {
|
|
|
136
195
|
children
|
|
137
196
|
});
|
|
138
197
|
}
|
|
198
|
+
const reportedMissing = /* @__PURE__ */ new Set();
|
|
139
199
|
/**
|
|
140
|
-
*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Development placeholder for missing field components.
|
|
200
|
+
* Visible placeholder for a field whose `type` has no registered component.
|
|
201
|
+
* Rendered in dev AND prod (a silent drop hides bugs). Uses inline styles so it
|
|
202
|
+
* looks the same without the host's Tailwind. Reports via an effect (dev warn +
|
|
203
|
+
* `onMissingComponent`), keeping the render pure.
|
|
147
204
|
*/
|
|
148
205
|
function MissingFieldComponent({ field }) {
|
|
206
|
+
const { onMissingComponent } = useFormSystem();
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (reportedMissing.has(field.type)) return;
|
|
209
|
+
reportedMissing.add(field.type);
|
|
210
|
+
if (isDev()) console.warn(`[FormKit] No component registered for field type "${field.type}". Rendered a placeholder — register it in your FormSystemProvider.`);
|
|
211
|
+
onMissingComponent?.(field.type);
|
|
212
|
+
}, [field.type, onMissingComponent]);
|
|
149
213
|
return /* @__PURE__ */ jsxs("div", {
|
|
214
|
+
role: "status",
|
|
215
|
+
className: "formkit-missing-field",
|
|
150
216
|
style: {
|
|
151
|
-
color: "#dc2626",
|
|
152
217
|
padding: "8px 12px",
|
|
153
|
-
border: "1px dashed
|
|
154
|
-
borderRadius: "
|
|
218
|
+
border: "1px dashed currentColor",
|
|
219
|
+
borderRadius: "6px",
|
|
155
220
|
fontSize: "12px",
|
|
156
|
-
fontFamily: "monospace",
|
|
157
|
-
|
|
221
|
+
fontFamily: "ui-monospace, SFMono-Regular, monospace",
|
|
222
|
+
opacity: .75
|
|
158
223
|
},
|
|
159
224
|
children: [
|
|
160
|
-
"
|
|
225
|
+
"Unsupported field type ",
|
|
161
226
|
/* @__PURE__ */ jsx("strong", { children: field.type }),
|
|
162
|
-
|
|
163
|
-
field.name,
|
|
164
|
-
")"
|
|
227
|
+
field.name ? ` (“${field.name}”)` : ""
|
|
165
228
|
]
|
|
166
229
|
});
|
|
167
230
|
}
|
|
168
231
|
|
|
169
|
-
//#endregion
|
|
170
|
-
//#region src/utils.ts
|
|
171
|
-
/**
|
|
172
|
-
* Utility function to merge CSS classes with Tailwind CSS conflict resolution.
|
|
173
|
-
*
|
|
174
|
-
* Combines `clsx` for conditional class handling with `tailwind-merge`
|
|
175
|
-
* for proper Tailwind CSS class conflict resolution.
|
|
176
|
-
*
|
|
177
|
-
* @param inputs - Class values to merge (strings, arrays, objects, or conditionals)
|
|
178
|
-
* @returns Merged and deduplicated class string
|
|
179
|
-
*
|
|
180
|
-
* @example
|
|
181
|
-
* ```tsx
|
|
182
|
-
* // Basic usage
|
|
183
|
-
* cn("px-2 py-1", "px-4") // "py-1 px-4"
|
|
184
|
-
*
|
|
185
|
-
* // Conditional classes
|
|
186
|
-
* cn("base", isActive && "active", { "disabled": isDisabled })
|
|
187
|
-
*
|
|
188
|
-
* // Arrays
|
|
189
|
-
* cn(["flex", "items-center"], "gap-2")
|
|
190
|
-
* ```
|
|
191
|
-
*/
|
|
192
|
-
function cn(...inputs) {
|
|
193
|
-
return twMerge(clsx(inputs));
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Shallow equality check for arrays and primitives.
|
|
197
|
-
* Used to stabilize useWatch output without JSON.stringify overhead.
|
|
198
|
-
*/
|
|
199
|
-
function shallowEqual(a, b) {
|
|
200
|
-
if (Object.is(a, b)) return true;
|
|
201
|
-
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
202
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
203
|
-
if (a.length !== b.length) return false;
|
|
204
|
-
for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
|
|
205
|
-
return true;
|
|
206
|
-
}
|
|
207
|
-
const keysA = Object.keys(a);
|
|
208
|
-
const keysB = Object.keys(b);
|
|
209
|
-
if (keysA.length !== keysB.length) return false;
|
|
210
|
-
for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) return false;
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
232
|
//#endregion
|
|
215
233
|
//#region src/schema.ts
|
|
216
234
|
/**
|
|
@@ -338,20 +356,221 @@ function defineSection(section) {
|
|
|
338
356
|
function extractDefaultValues(schema) {
|
|
339
357
|
const defaults = {};
|
|
340
358
|
for (const section of schema.sections) {
|
|
341
|
-
const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
|
|
342
359
|
if (!section.fields) continue;
|
|
343
|
-
|
|
344
|
-
const key = `${prefix}${field.name}`;
|
|
345
|
-
if (field.defaultValue !== void 0) defaults[key] = field.defaultValue;
|
|
346
|
-
else if (field.type === "array") defaults[key] = [];
|
|
347
|
-
if (field.itemFields && field.type !== "array") {
|
|
348
|
-
for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${key}.${sub.name}`] = sub.defaultValue;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
360
|
+
collectExplicitDefaults(section.fields, section.nameSpace ?? "", defaults);
|
|
351
361
|
}
|
|
352
362
|
return defaults;
|
|
353
363
|
}
|
|
354
364
|
/**
|
|
365
|
+
* Recursively assign a field list's EXPLICIT defaults (sparse — only fields
|
|
366
|
+
* that declare a `defaultValue`, plus `array` fields seeded to `[]`) into
|
|
367
|
+
* `target` at the given dot-path prefix. Recurses into nested `group` children
|
|
368
|
+
* so deeply-nested defaults aren't dropped; `array` children are left dynamic.
|
|
369
|
+
*/
|
|
370
|
+
function collectExplicitDefaults(fields, prefix, target) {
|
|
371
|
+
for (const field of fields) {
|
|
372
|
+
const name = field.name;
|
|
373
|
+
const key = prefix ? `${prefix}.${name}` : name;
|
|
374
|
+
if (field.defaultValue !== void 0) assignPath(target, key, field.defaultValue);
|
|
375
|
+
else if (field.type === "array") assignPath(target, key, []);
|
|
376
|
+
else if (field.itemFields && field.itemFields.length > 0) collectExplicitDefaults(field.itemFields, key, target);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const VALID_OPERATORS = new Set([
|
|
380
|
+
"===",
|
|
381
|
+
"!==",
|
|
382
|
+
"in",
|
|
383
|
+
"not-in",
|
|
384
|
+
"truthy",
|
|
385
|
+
"falsy"
|
|
386
|
+
]);
|
|
387
|
+
const CONTAINER_TYPES = new Set(["group", "array"]);
|
|
388
|
+
/**
|
|
389
|
+
* Structurally validate a form schema and return a list of issues (empty ⇒ OK).
|
|
390
|
+
* Server-safe (no hooks/DOM), so you can run it when a schema is loaded from a
|
|
391
|
+
* DB, in a test, or in a dev boot check. It validates SHAPE, not your component
|
|
392
|
+
* registry — an unknown field `type` is a registry concern, not a schema error.
|
|
393
|
+
*
|
|
394
|
+
* Checks: missing `name`/`type`, duplicate names (namespace-aware), `itemFields`
|
|
395
|
+
* on a non-container type, containers with no `itemFields`, and unknown DSL
|
|
396
|
+
* condition operators.
|
|
397
|
+
*/
|
|
398
|
+
function validateSchema(schema) {
|
|
399
|
+
const issues = [];
|
|
400
|
+
const seen = /* @__PURE__ */ new Map();
|
|
401
|
+
const checkCondition = (condition, path) => {
|
|
402
|
+
if (!condition || typeof condition === "function") return;
|
|
403
|
+
const asObj = condition;
|
|
404
|
+
const rules = Array.isArray(condition) ? condition : Array.isArray(asObj.rules) ? asObj.rules : [condition];
|
|
405
|
+
for (const rule of rules) {
|
|
406
|
+
const op = rule?.operator;
|
|
407
|
+
if (op && !VALID_OPERATORS.has(op)) issues.push({
|
|
408
|
+
path,
|
|
409
|
+
code: "unknown-operator",
|
|
410
|
+
severity: "error",
|
|
411
|
+
message: `Unknown condition operator "${op}". Valid: ${[...VALID_OPERATORS].join(", ")}.`
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const walkFields = (fields, prefix, locPath, dedup) => {
|
|
416
|
+
fields.forEach((field, i) => {
|
|
417
|
+
const loc = `${locPath}.fields[${i}]`;
|
|
418
|
+
const name = field.name;
|
|
419
|
+
if (!name) issues.push({
|
|
420
|
+
path: loc,
|
|
421
|
+
code: "missing-name",
|
|
422
|
+
severity: "error",
|
|
423
|
+
message: "Field is missing a `name`."
|
|
424
|
+
});
|
|
425
|
+
if (!field.type) issues.push({
|
|
426
|
+
path: loc,
|
|
427
|
+
code: "missing-type",
|
|
428
|
+
severity: "error",
|
|
429
|
+
message: `Field "${name ?? "?"}" is missing a \`type\`.`
|
|
430
|
+
});
|
|
431
|
+
if (name && dedup) {
|
|
432
|
+
const full = prefix ? `${prefix}.${name}` : name;
|
|
433
|
+
const prior = seen.get(full);
|
|
434
|
+
if (prior) issues.push({
|
|
435
|
+
path: loc,
|
|
436
|
+
code: "duplicate-name",
|
|
437
|
+
severity: "error",
|
|
438
|
+
message: `Duplicate field name "${full}" (also at ${prior}).`
|
|
439
|
+
});
|
|
440
|
+
else seen.set(full, loc);
|
|
441
|
+
}
|
|
442
|
+
const hasItems = !!field.itemFields && field.itemFields.length > 0;
|
|
443
|
+
const isContainer = CONTAINER_TYPES.has(field.type);
|
|
444
|
+
if (hasItems && !isContainer) issues.push({
|
|
445
|
+
path: loc,
|
|
446
|
+
code: "itemfields-on-noncontainer",
|
|
447
|
+
severity: "error",
|
|
448
|
+
message: `Field "${name}" has \`itemFields\` but type "${field.type}" is not a container (group/array).`
|
|
449
|
+
});
|
|
450
|
+
if (isContainer && !hasItems) issues.push({
|
|
451
|
+
path: loc,
|
|
452
|
+
code: "empty-container",
|
|
453
|
+
severity: "warning",
|
|
454
|
+
message: `Container field "${name}" (${field.type}) has no \`itemFields\`.`
|
|
455
|
+
});
|
|
456
|
+
checkCondition(field.condition, loc);
|
|
457
|
+
if (hasItems) {
|
|
458
|
+
const childDedup = field.type !== "array" && dedup;
|
|
459
|
+
const childPrefix = field.type === "array" ? "" : name ? prefix ? `${prefix}.${name}` : name : prefix;
|
|
460
|
+
walkFields(field.itemFields, childPrefix, loc, childDedup);
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
schema.sections.forEach((section, i) => {
|
|
465
|
+
const loc = `sections[${i}]`;
|
|
466
|
+
checkCondition(section.condition, loc);
|
|
467
|
+
if (section.fields) walkFields(section.fields, section.nameSpace ?? "", loc, true);
|
|
468
|
+
});
|
|
469
|
+
return issues;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Build a default-value object for a flat list of fields — used to seed a new
|
|
473
|
+
* array item (or a group) from its `itemFields`.
|
|
474
|
+
*
|
|
475
|
+
* Recurses into nested `group` children (→ nested object) and seeds nested
|
|
476
|
+
* `array` children as `[]`, so appending an item never leaves a deep sub-field
|
|
477
|
+
* `undefined`. That matters because a missing deep field can trip a resolver
|
|
478
|
+
* (zod et al.) into a spurious "required" error the moment the row is added.
|
|
479
|
+
* Leaf fields without an explicit `defaultValue` seed to `""` (a controlled
|
|
480
|
+
* empty value RHF is happy with).
|
|
481
|
+
*/
|
|
482
|
+
function buildFieldDefaults(fields) {
|
|
483
|
+
const item = {};
|
|
484
|
+
if (!fields) return item;
|
|
485
|
+
for (const f of fields) {
|
|
486
|
+
const name = f.name;
|
|
487
|
+
if (f.defaultValue !== void 0) item[name] = f.defaultValue;
|
|
488
|
+
else if (f.type === "array") item[name] = [];
|
|
489
|
+
else if (f.itemFields && f.itemFields.length > 0) item[name] = buildFieldDefaults(f.itemFields);
|
|
490
|
+
else item[name] = emptyForType(f.type);
|
|
491
|
+
}
|
|
492
|
+
return item;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Type-appropriate "empty" seed for a leaf field that declares no
|
|
496
|
+
* `defaultValue`. A blanket `""` is wrong for non-text fields — a checkbox
|
|
497
|
+
* seeded to `""` is neither on nor off, and a numeric field coerces `""` to
|
|
498
|
+
* `NaN`. Text-like fields fall through to `""`.
|
|
499
|
+
*/
|
|
500
|
+
function emptyForType(type) {
|
|
501
|
+
switch (type) {
|
|
502
|
+
case "checkbox":
|
|
503
|
+
case "switch":
|
|
504
|
+
case "boolean": return false;
|
|
505
|
+
case "number":
|
|
506
|
+
case "money": return;
|
|
507
|
+
case "multiselect":
|
|
508
|
+
case "tags": return [];
|
|
509
|
+
default: return "";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Assign a possibly dot-notated key into a NESTED object shape:
|
|
514
|
+
* `assignPath(obj, "address.city", "NYC")` → `obj.address.city = "NYC"`.
|
|
515
|
+
*
|
|
516
|
+
* Nested (not flat `{"address.city": ...}`) is required for react-hook-form:
|
|
517
|
+
* its `get(defaultValues, "address.city")` resolves through the object graph,
|
|
518
|
+
* so a flat dotted key would never be found and the field's default would
|
|
519
|
+
* silently not apply. Top-level keys (no dot) are assigned directly.
|
|
520
|
+
*/
|
|
521
|
+
function assignPath(target, path, value) {
|
|
522
|
+
if (!path.includes(".")) {
|
|
523
|
+
target[path] = value;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const parts = path.split(".");
|
|
527
|
+
const last = parts.length - 1;
|
|
528
|
+
let node = target;
|
|
529
|
+
for (let i = 0; i < last; i++) {
|
|
530
|
+
const key = parts[i];
|
|
531
|
+
const next = node[key];
|
|
532
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) node[key] = {};
|
|
533
|
+
node = node[key];
|
|
534
|
+
}
|
|
535
|
+
node[parts[last]] = value;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* True for objects that are safe to spread-merge: plain `{}` records (or
|
|
539
|
+
* `Object.create(null)`). A Date / File / class instance is `typeof "object"`
|
|
540
|
+
* but must be REPLACED wholesale, never spread-merged — `{ ...new Date() }` is
|
|
541
|
+
* `{}`, which would silently destroy the value.
|
|
542
|
+
*/
|
|
543
|
+
function isPlainObject(v) {
|
|
544
|
+
if (typeof v !== "object" || v === null) return false;
|
|
545
|
+
const proto = Object.getPrototypeOf(v);
|
|
546
|
+
return proto === Object.prototype || proto === null;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Deep-merge `override` onto `base` with default-values semantics: nested
|
|
550
|
+
* plain objects merge recursively; arrays, primitives, and class instances
|
|
551
|
+
* (Date, File, …) from `override` replace wholesale.
|
|
552
|
+
*
|
|
553
|
+
* This is the merge `useFormKit` applies between schema-extracted defaults and
|
|
554
|
+
* caller-provided `defaultValues`. Exported so wrappers that re-seed a form at
|
|
555
|
+
* runtime (e.g. an edit sheet swapping entities) can reproduce the exact same
|
|
556
|
+
* merge for `form.reset(...)`:
|
|
557
|
+
*
|
|
558
|
+
* @example
|
|
559
|
+
* ```ts
|
|
560
|
+
* const merged = mergeDefaultValues(extractDefaultValues(schema), entity);
|
|
561
|
+
* form.reset(merged);
|
|
562
|
+
* ```
|
|
563
|
+
*/
|
|
564
|
+
function mergeDefaultValues(base, override) {
|
|
565
|
+
const out = { ...base };
|
|
566
|
+
for (const key of Object.keys(override)) {
|
|
567
|
+
const b = out[key];
|
|
568
|
+
const o = override[key];
|
|
569
|
+
out[key] = isPlainObject(b) && isPlainObject(o) ? mergeDefaultValues(b, o) : o;
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
355
574
|
* Yield to the browser's main thread to keep the UI responsive.
|
|
356
575
|
* Uses `scheduler.yield()` when available (Chromium 115+), falls back to a
|
|
357
576
|
* zero-duration `setTimeout` which still gives the browser a chance to
|
|
@@ -383,16 +602,8 @@ async function extractDefaultValuesAsync(schema) {
|
|
|
383
602
|
const BUDGET_MS = 50;
|
|
384
603
|
let deadline = performance.now() + BUDGET_MS;
|
|
385
604
|
for (const section of schema.sections) {
|
|
386
|
-
const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
|
|
387
605
|
if (!section.fields) continue;
|
|
388
|
-
|
|
389
|
-
const key = `${prefix}${field.name}`;
|
|
390
|
-
if (field.defaultValue !== void 0) defaults[key] = field.defaultValue;
|
|
391
|
-
else if (field.type === "array") defaults[key] = [];
|
|
392
|
-
if (field.itemFields && field.type !== "array") {
|
|
393
|
-
for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${key}.${sub.name}`] = sub.defaultValue;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
606
|
+
collectExplicitDefaults(section.fields, section.nameSpace ?? "", defaults);
|
|
396
607
|
if (performance.now() >= deadline) {
|
|
397
608
|
await yieldToMain();
|
|
398
609
|
deadline = performance.now() + BUDGET_MS;
|
|
@@ -642,6 +853,31 @@ function toFieldId(name) {
|
|
|
642
853
|
return `formkit-field-${name.replace(/[.[\]]/g, "-")}`;
|
|
643
854
|
}
|
|
644
855
|
/**
|
|
856
|
+
* The `data-col-span` hook for a field. `fullWidth` wins (it spans the whole
|
|
857
|
+
* row via `col-span-full`); otherwise a `colSpan >= 2` is surfaced as a data
|
|
858
|
+
* attribute for the consumer's stylesheet to interpret responsively. We emit a
|
|
859
|
+
* data attribute (not an inline `grid-column`) on purpose: an inline span on a
|
|
860
|
+
* single-column mobile grid creates an empty implicit track that steals a `gap`
|
|
861
|
+
* gutter, narrowing every field in that grid. A stylesheet can scope the span
|
|
862
|
+
* to the breakpoints where the extra columns actually exist.
|
|
863
|
+
*/
|
|
864
|
+
function fieldColSpan(field) {
|
|
865
|
+
if (field.fullWidth || !field.colSpan || field.colSpan < 2) return void 0;
|
|
866
|
+
return field.colSpan;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Inline `grid-column: 1 / -1` for `fullWidth` fields. The renderer also sets
|
|
870
|
+
* the `col-span-full` class, but that Tailwind class only exists if the host's
|
|
871
|
+
* content scan reaches it — which it won't for a published dist in
|
|
872
|
+
* `node_modules`. This inline style makes full-width **load-bearing layout**
|
|
873
|
+
* work regardless of the consumer's Tailwind setup. `1 / -1` spans only the
|
|
874
|
+
* existing explicit columns, so it collapses safely to full width on a
|
|
875
|
+
* single-column mobile grid (no implicit track / gap artifact).
|
|
876
|
+
*/
|
|
877
|
+
function fullWidthStyle(field) {
|
|
878
|
+
return field.fullWidth ? { gridColumn: "1 / -1" } : void 0;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
645
881
|
* Get nested value from an object using dot-notation path.
|
|
646
882
|
* Handles array indices: "items.0.name" resolves through arrays correctly.
|
|
647
883
|
*/
|
|
@@ -691,7 +927,13 @@ var FormFieldErrorBoundary = class extends PureComponent {
|
|
|
691
927
|
render() {
|
|
692
928
|
if (this.state.hasError) return /* @__PURE__ */ jsxs("div", {
|
|
693
929
|
role: "alert",
|
|
694
|
-
className: "formkit-field-error-boundary
|
|
930
|
+
className: "formkit-field-error-boundary",
|
|
931
|
+
style: {
|
|
932
|
+
border: "1px solid currentColor",
|
|
933
|
+
borderRadius: "6px",
|
|
934
|
+
padding: "8px 12px",
|
|
935
|
+
fontSize: "13px"
|
|
936
|
+
},
|
|
695
937
|
children: [
|
|
696
938
|
"Failed to render field \"",
|
|
697
939
|
this.props.fieldName,
|
|
@@ -729,6 +971,12 @@ var FormFieldErrorBoundary = class extends PureComponent {
|
|
|
729
971
|
function FormGenerator({ schema, control, disabled = false, variant, className, ref }) {
|
|
730
972
|
const formContext = useFormContext();
|
|
731
973
|
const activeControl = control ?? formContext?.control;
|
|
974
|
+
useEffect(() => {
|
|
975
|
+
if (!isDev()) return;
|
|
976
|
+
if (!schema?.sections) return;
|
|
977
|
+
const issues = validateSchema(schema);
|
|
978
|
+
if (issues.length > 0) console.warn(`[FormKit] Schema has ${issues.length} issue(s):`, issues.map((x) => `${x.severity.toUpperCase()} ${x.path}: ${x.message}`));
|
|
979
|
+
}, [schema]);
|
|
732
980
|
if (!activeControl) {
|
|
733
981
|
console.warn("[FormKit] FormGenerator requires a `control` prop or to be wrapped in a <FormProvider>.");
|
|
734
982
|
return null;
|
|
@@ -762,15 +1010,16 @@ const SectionRenderer = memo(SectionRendererImpl);
|
|
|
762
1010
|
*/
|
|
763
1011
|
function DynamicSectionRenderer(props) {
|
|
764
1012
|
const conditionWatchNames = useMemo(() => extractWatchNames(props.section.condition), [props.section.condition]);
|
|
765
|
-
const watchedRaw =
|
|
1013
|
+
const watchedRaw = useWatch({
|
|
766
1014
|
control: props.control,
|
|
767
|
-
name: conditionWatchNames
|
|
768
|
-
})
|
|
1015
|
+
name: conditionWatchNames.length > 0 ? conditionWatchNames : void 0
|
|
1016
|
+
});
|
|
769
1017
|
const sectionValues = useMemo(() => {
|
|
770
|
-
if (conditionWatchNames.length > 0 && Array.isArray(watchedRaw))
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
1018
|
+
if (conditionWatchNames.length > 0 && Array.isArray(watchedRaw)) {
|
|
1019
|
+
const nested = {};
|
|
1020
|
+
conditionWatchNames.forEach((name, i) => assignPath(nested, name, watchedRaw[i]));
|
|
1021
|
+
return nested;
|
|
1022
|
+
}
|
|
774
1023
|
return watchedRaw;
|
|
775
1024
|
}, [conditionWatchNames, watchedRaw]);
|
|
776
1025
|
if (!evaluateCondition(props.section.condition, sectionValues)) return null;
|
|
@@ -875,6 +1124,8 @@ function RenderedFieldWrapper({ field, control, disabled, variant }) {
|
|
|
875
1124
|
fieldName,
|
|
876
1125
|
children: /* @__PURE__ */ jsx("div", {
|
|
877
1126
|
className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
|
|
1127
|
+
style: fullWidthStyle(field),
|
|
1128
|
+
"data-col-span": fieldColSpan(field),
|
|
878
1129
|
"data-formkit-field": fieldName,
|
|
879
1130
|
"data-field-type": field.type,
|
|
880
1131
|
"data-invalid": shouldShowError ? "" : void 0,
|
|
@@ -916,10 +1167,10 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
916
1167
|
allWatchNamesRef.current = next;
|
|
917
1168
|
return next;
|
|
918
1169
|
}, [explicitWatchNames, ruleWatchNames]);
|
|
919
|
-
const watchedRaw =
|
|
1170
|
+
const watchedRaw = useWatch({
|
|
920
1171
|
control,
|
|
921
|
-
name: allWatchNames
|
|
922
|
-
})
|
|
1172
|
+
name: allWatchNames.length > 0 ? allWatchNames : void 0
|
|
1173
|
+
});
|
|
923
1174
|
const prevWatchedRef = useRef(watchedRaw);
|
|
924
1175
|
const stableWatched = useMemo(() => {
|
|
925
1176
|
if (shallowEqual(prevWatchedRef.current, watchedRaw)) return prevWatchedRef.current;
|
|
@@ -927,10 +1178,11 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
927
1178
|
return watchedRaw;
|
|
928
1179
|
}, [watchedRaw]);
|
|
929
1180
|
const watchedValues = useMemo(() => {
|
|
930
|
-
if (allWatchNames.length > 0 && Array.isArray(stableWatched))
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1181
|
+
if (allWatchNames.length > 0 && Array.isArray(stableWatched)) {
|
|
1182
|
+
const nested = {};
|
|
1183
|
+
allWatchNames.forEach((name, i) => assignPath(nested, name, stableWatched[i]));
|
|
1184
|
+
return nested;
|
|
1185
|
+
}
|
|
934
1186
|
return stableWatched;
|
|
935
1187
|
}, [allWatchNames, stableWatched]);
|
|
936
1188
|
const [, startTransition] = useTransition();
|
|
@@ -938,17 +1190,41 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
938
1190
|
const [options, setOptions] = useState(field.options || []);
|
|
939
1191
|
const [srAnnouncement, setSrAnnouncement] = useState("");
|
|
940
1192
|
const timeoutRef = useRef(null);
|
|
1193
|
+
const optionsCacheRef = useRef(/* @__PURE__ */ new Map());
|
|
1194
|
+
const loadOptionsWarnedRef = useRef(false);
|
|
941
1195
|
useEffect(() => {
|
|
942
1196
|
if (!field.loadOptions) return;
|
|
1197
|
+
if (isDev() && allWatchNames.length === 0 && !loadOptionsWarnedRef.current) {
|
|
1198
|
+
loadOptionsWarnedRef.current = true;
|
|
1199
|
+
console.warn(`[FormKit] Field "${String(field.name)}" uses loadOptions without watchNames, so it subscribes to the entire form and refetches on any change. Add watchNames to scope it.`);
|
|
1200
|
+
}
|
|
943
1201
|
let isActive = true;
|
|
1202
|
+
const controller = new AbortController();
|
|
944
1203
|
const executeLoad = () => {
|
|
945
|
-
const
|
|
1204
|
+
const cacheKey = field.cacheOptions ? JSON.stringify(watchedValues ?? null) : null;
|
|
1205
|
+
if (cacheKey !== null) {
|
|
1206
|
+
const cached = optionsCacheRef.current.get(cacheKey);
|
|
1207
|
+
if (cached) {
|
|
1208
|
+
setOptions(cached);
|
|
1209
|
+
setIsLoading(false);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
const res = field.loadOptions(watchedValues, { signal: controller.signal });
|
|
946
1214
|
if (res instanceof Promise) {
|
|
947
1215
|
setIsLoading(true);
|
|
948
1216
|
startTransition(async () => {
|
|
949
1217
|
try {
|
|
950
1218
|
const newOptions = await res;
|
|
951
1219
|
if (isActive) {
|
|
1220
|
+
if (cacheKey !== null) {
|
|
1221
|
+
const cache = optionsCacheRef.current;
|
|
1222
|
+
cache.set(cacheKey, newOptions);
|
|
1223
|
+
if (cache.size > 50) {
|
|
1224
|
+
const oldest = cache.keys().next().value;
|
|
1225
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
952
1228
|
setOptions(newOptions);
|
|
953
1229
|
setIsLoading(false);
|
|
954
1230
|
setSrAnnouncement(newOptions.length === 0 ? "No options available" : `${newOptions.length} option${newOptions.length === 1 ? "" : "s"} available`);
|
|
@@ -970,6 +1246,7 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
970
1246
|
} else executeLoad();
|
|
971
1247
|
return () => {
|
|
972
1248
|
isActive = false;
|
|
1249
|
+
controller.abort();
|
|
973
1250
|
if (timeoutRef.current) {
|
|
974
1251
|
clearTimeout(timeoutRef.current);
|
|
975
1252
|
timeoutRef.current = null;
|
|
@@ -979,7 +1256,8 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
979
1256
|
watchedValues,
|
|
980
1257
|
field.loadOptions,
|
|
981
1258
|
field.debounceMs,
|
|
982
|
-
field.onLoadError
|
|
1259
|
+
field.onLoadError,
|
|
1260
|
+
field.cacheOptions
|
|
983
1261
|
]);
|
|
984
1262
|
const loadingState = field.loadOptions ? isLoading : void 0;
|
|
985
1263
|
const dynamicField = useMemo(() => ({
|
|
@@ -1040,31 +1318,8 @@ function StaticFieldWrapperImpl({ field, control, disabled, variant, isLoading }
|
|
|
1040
1318
|
isSubmitted
|
|
1041
1319
|
]);
|
|
1042
1320
|
const activeVariant = field.variant ?? variant;
|
|
1043
|
-
|
|
1044
|
-
if (field.type === "array") return /* @__PURE__ */ jsx(ArrayFieldFallback, {
|
|
1045
|
-
field,
|
|
1046
|
-
control,
|
|
1047
|
-
disabled: isDisabled,
|
|
1048
|
-
variant: activeVariant
|
|
1049
|
-
});
|
|
1050
|
-
const prefixedGroupFields = field.itemFields.map((f) => ({
|
|
1051
|
-
...f,
|
|
1052
|
-
name: `${fieldName}.${f.name}`
|
|
1053
|
-
}));
|
|
1054
|
-
return /* @__PURE__ */ jsx("div", {
|
|
1055
|
-
className: cn("formkit-field-group", field.fullWidth && "col-span-full", field.className),
|
|
1056
|
-
"data-formkit-field": fieldName,
|
|
1057
|
-
"data-field-type": field.type,
|
|
1058
|
-
children: /* @__PURE__ */ jsx(GridRenderer, {
|
|
1059
|
-
fields: prefixedGroupFields,
|
|
1060
|
-
control,
|
|
1061
|
-
disabled: isDisabled,
|
|
1062
|
-
variant: activeVariant
|
|
1063
|
-
})
|
|
1064
|
-
});
|
|
1065
|
-
}
|
|
1321
|
+
const hasExplicitComponent = Boolean(components[field.type] || activeVariant && components[activeVariant] && typeof components[activeVariant] === "object" && components[activeVariant][field.type]);
|
|
1066
1322
|
const FieldComponent = useFieldComponent(field.type, activeVariant);
|
|
1067
|
-
if (!FieldComponent && !field.render) return null;
|
|
1068
1323
|
const fieldProps = useMemo(() => ({
|
|
1069
1324
|
...field,
|
|
1070
1325
|
field,
|
|
@@ -1090,10 +1345,39 @@ function StaticFieldWrapperImpl({ field, control, disabled, variant, isLoading }
|
|
|
1090
1345
|
shouldShowError,
|
|
1091
1346
|
isLoading
|
|
1092
1347
|
]);
|
|
1348
|
+
if (field.type === "hidden" && !hasExplicitComponent && !field.render) return null;
|
|
1349
|
+
if (!hasExplicitComponent && field.itemFields && field.itemFields.length > 0) {
|
|
1350
|
+
if (field.type === "array") return /* @__PURE__ */ jsx(ArrayFieldFallback, {
|
|
1351
|
+
field,
|
|
1352
|
+
control,
|
|
1353
|
+
disabled: isDisabled,
|
|
1354
|
+
variant: activeVariant
|
|
1355
|
+
});
|
|
1356
|
+
const prefixedGroupFields = field.itemFields.map((f) => ({
|
|
1357
|
+
...f,
|
|
1358
|
+
name: `${fieldName}.${f.name}`
|
|
1359
|
+
}));
|
|
1360
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1361
|
+
className: cn("formkit-field-group", field.fullWidth && "col-span-full", field.className),
|
|
1362
|
+
style: fullWidthStyle(field),
|
|
1363
|
+
"data-col-span": fieldColSpan(field),
|
|
1364
|
+
"data-formkit-field": fieldName,
|
|
1365
|
+
"data-field-type": field.type,
|
|
1366
|
+
children: /* @__PURE__ */ jsx(GridRenderer, {
|
|
1367
|
+
fields: prefixedGroupFields,
|
|
1368
|
+
control,
|
|
1369
|
+
disabled: isDisabled,
|
|
1370
|
+
variant: activeVariant
|
|
1371
|
+
})
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
if (!FieldComponent && !field.render) return null;
|
|
1093
1375
|
return /* @__PURE__ */ jsx(FormFieldErrorBoundary, {
|
|
1094
1376
|
fieldName,
|
|
1095
1377
|
children: /* @__PURE__ */ jsx("div", {
|
|
1096
1378
|
className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
|
|
1379
|
+
style: fullWidthStyle(field),
|
|
1380
|
+
"data-col-span": fieldColSpan(field),
|
|
1097
1381
|
"data-formkit-field": fieldName,
|
|
1098
1382
|
"data-field-type": field.type,
|
|
1099
1383
|
"data-invalid": shouldShowError ? "" : void 0,
|
|
@@ -1120,20 +1404,30 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
|
|
|
1120
1404
|
return /* @__PURE__ */ jsxs("div", {
|
|
1121
1405
|
role: "group",
|
|
1122
1406
|
"aria-labelledby": labelId,
|
|
1123
|
-
className: cn("formkit-field-array
|
|
1407
|
+
className: cn("formkit-field-array", field.className),
|
|
1408
|
+
style: {
|
|
1409
|
+
display: "flex",
|
|
1410
|
+
flexDirection: "column",
|
|
1411
|
+
gap: "1rem",
|
|
1412
|
+
...fullWidthStyle(field)
|
|
1413
|
+
},
|
|
1414
|
+
"data-col-span": fieldColSpan(field),
|
|
1124
1415
|
"data-formkit-field": field.name,
|
|
1125
1416
|
"data-field-type": "array",
|
|
1126
1417
|
children: [
|
|
1127
|
-
field.label && /* @__PURE__ */ jsx("
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
className: "font-semibold",
|
|
1132
|
-
children: field.label
|
|
1133
|
-
})
|
|
1418
|
+
field.label && /* @__PURE__ */ jsx("span", {
|
|
1419
|
+
id: labelId,
|
|
1420
|
+
style: { fontWeight: 600 },
|
|
1421
|
+
children: field.label
|
|
1134
1422
|
}),
|
|
1135
1423
|
fields.map((item, index) => /* @__PURE__ */ jsxs("div", {
|
|
1136
|
-
className: "
|
|
1424
|
+
className: "formkit-array-item",
|
|
1425
|
+
style: {
|
|
1426
|
+
position: "relative",
|
|
1427
|
+
border: "1px solid currentColor",
|
|
1428
|
+
borderRadius: "6px",
|
|
1429
|
+
padding: "1rem"
|
|
1430
|
+
},
|
|
1137
1431
|
children: [/* @__PURE__ */ jsx(GridRenderer, {
|
|
1138
1432
|
fields: field.itemFields?.map((f) => ({
|
|
1139
1433
|
...f,
|
|
@@ -1144,23 +1438,25 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
|
|
|
1144
1438
|
variant
|
|
1145
1439
|
}), /* @__PURE__ */ jsx("button", {
|
|
1146
1440
|
type: "button",
|
|
1441
|
+
className: "formkit-array-remove",
|
|
1147
1442
|
onClick: () => remove(index),
|
|
1148
1443
|
"aria-label": `Remove ${field.label ?? "item"} ${index + 1}`,
|
|
1149
|
-
|
|
1444
|
+
style: {
|
|
1445
|
+
position: "absolute",
|
|
1446
|
+
top: "0.5rem",
|
|
1447
|
+
right: "0.5rem"
|
|
1448
|
+
},
|
|
1150
1449
|
children: "Remove"
|
|
1151
1450
|
})]
|
|
1152
1451
|
}, item.id)),
|
|
1153
1452
|
/* @__PURE__ */ jsx("button", {
|
|
1154
1453
|
type: "button",
|
|
1454
|
+
className: "formkit-array-add",
|
|
1155
1455
|
onClick: () => {
|
|
1156
|
-
|
|
1157
|
-
if (field.itemFields) {
|
|
1158
|
-
for (const f of field.itemFields) if (f.defaultValue !== void 0) defaults[f.name] = f.defaultValue;
|
|
1159
|
-
}
|
|
1160
|
-
append(defaults);
|
|
1456
|
+
append(buildFieldDefaults(field.itemFields));
|
|
1161
1457
|
},
|
|
1162
1458
|
disabled,
|
|
1163
|
-
|
|
1459
|
+
style: { alignSelf: "flex-start" },
|
|
1164
1460
|
children: "+ Add Item"
|
|
1165
1461
|
})
|
|
1166
1462
|
]
|
|
@@ -1232,12 +1528,13 @@ const field = {
|
|
|
1232
1528
|
label,
|
|
1233
1529
|
...props ?? {}
|
|
1234
1530
|
}),
|
|
1235
|
-
/** Number input field
|
|
1531
|
+
/** Number input field. No implicit `min` — pass `{ min }` to add one, so the
|
|
1532
|
+
* builder never injects validation the author didn't write (signed
|
|
1533
|
+
* quantities like deltas / temperatures stay valid). */
|
|
1236
1534
|
number: (name, label, props) => ({
|
|
1237
1535
|
type: "number",
|
|
1238
1536
|
name,
|
|
1239
1537
|
label,
|
|
1240
|
-
min: 0,
|
|
1241
1538
|
...props ?? {}
|
|
1242
1539
|
}),
|
|
1243
1540
|
/** Textarea field with default 3 rows. */
|
|
@@ -1531,29 +1828,31 @@ function sectionUntitled(fields, props = {}) {
|
|
|
1531
1828
|
* ```
|
|
1532
1829
|
*/
|
|
1533
1830
|
function useFormKit(options) {
|
|
1534
|
-
const { schema, disabled, variant, className, defaultValues, ...formOptions } = options;
|
|
1831
|
+
const { schema, disabled, variant, className, defaultValues, resetOnSchemaChange, ...formOptions } = options;
|
|
1535
1832
|
const schemaDefaults = useMemo(() => extractDefaultValues(schema), [schema]);
|
|
1536
1833
|
const mergedDefaults = useMemo(() => {
|
|
1537
1834
|
if (typeof defaultValues === "function") {
|
|
1538
1835
|
const userFn = defaultValues;
|
|
1539
1836
|
const captured = schemaDefaults;
|
|
1540
1837
|
return async () => {
|
|
1541
|
-
|
|
1542
|
-
return {
|
|
1543
|
-
...captured,
|
|
1544
|
-
...userVals
|
|
1545
|
-
};
|
|
1838
|
+
return mergeDefaultValues(captured, await Promise.resolve(userFn()) ?? {});
|
|
1546
1839
|
};
|
|
1547
1840
|
}
|
|
1548
|
-
return {
|
|
1549
|
-
...schemaDefaults,
|
|
1550
|
-
...defaultValues != null ? defaultValues : {}
|
|
1551
|
-
};
|
|
1841
|
+
return mergeDefaultValues(schemaDefaults, defaultValues != null ? defaultValues : {});
|
|
1552
1842
|
}, [schemaDefaults, defaultValues]);
|
|
1553
1843
|
const form = useForm({
|
|
1554
1844
|
...formOptions,
|
|
1555
1845
|
defaultValues: mergedDefaults
|
|
1556
1846
|
});
|
|
1847
|
+
const isFirstSchemaRef = useRef(true);
|
|
1848
|
+
useEffect(() => {
|
|
1849
|
+
if (isFirstSchemaRef.current) {
|
|
1850
|
+
isFirstSchemaRef.current = false;
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (!resetOnSchemaChange || typeof mergedDefaults === "function") return;
|
|
1854
|
+
form.reset(mergedDefaults);
|
|
1855
|
+
}, [schema]);
|
|
1557
1856
|
const generatorProps = useMemo(() => ({
|
|
1558
1857
|
schema,
|
|
1559
1858
|
control: form.control,
|
|
@@ -1571,4 +1870,56 @@ function useFormKit(options) {
|
|
|
1571
1870
|
}
|
|
1572
1871
|
|
|
1573
1872
|
//#endregion
|
|
1574
|
-
|
|
1873
|
+
//#region src/focus.ts
|
|
1874
|
+
/**
|
|
1875
|
+
* Focus (and scroll to) the first field currently shown as invalid.
|
|
1876
|
+
*
|
|
1877
|
+
* It queries the rendered `[data-formkit-field][data-invalid]` wrapper — which
|
|
1878
|
+
* `FormGenerator` marks once errors are visible (after touch / submit) — so it
|
|
1879
|
+
* targets the first error in **visual order**, not react-hook-form's error-key
|
|
1880
|
+
* order (which isn't guaranteed to match the layout). Wire it into your submit's
|
|
1881
|
+
* invalid handler:
|
|
1882
|
+
*
|
|
1883
|
+
* ```ts
|
|
1884
|
+
* form.handleSubmit(onValid, () => focusFirstError());
|
|
1885
|
+
* ```
|
|
1886
|
+
*
|
|
1887
|
+
* **Timing:** react-hook-form calls the invalid handler *before* React commits
|
|
1888
|
+
* the re-render that sets `data-invalid` on the wrappers, so on the very first
|
|
1889
|
+
* invalid submit a synchronous query finds nothing. When the immediate attempt
|
|
1890
|
+
* misses, this retries on the next two animation frames (the commit lands
|
|
1891
|
+
* before the next paint), so the plain wiring above Just Works.
|
|
1892
|
+
*
|
|
1893
|
+
* @param root Optional container to search within (defaults to `document`).
|
|
1894
|
+
* @returns `true` if a field was focused immediately; `false` if not found yet
|
|
1895
|
+
* (a deferred retry may still focus it on the next frame).
|
|
1896
|
+
*/
|
|
1897
|
+
function focusFirstError(root) {
|
|
1898
|
+
const scope = root ?? (typeof document !== "undefined" ? document : null);
|
|
1899
|
+
if (!scope) return false;
|
|
1900
|
+
if (attemptFocus(scope)) return true;
|
|
1901
|
+
if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => {
|
|
1902
|
+
if (attemptFocus(scope)) return;
|
|
1903
|
+
requestAnimationFrame(() => {
|
|
1904
|
+
attemptFocus(scope);
|
|
1905
|
+
});
|
|
1906
|
+
});
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1909
|
+
/** Single synchronous query-and-focus attempt. */
|
|
1910
|
+
function attemptFocus(scope) {
|
|
1911
|
+
const wrapper = scope.querySelector("[data-formkit-field][data-invalid]");
|
|
1912
|
+
if (!wrapper) return false;
|
|
1913
|
+
const target = wrapper.querySelector("input, select, textarea, [contenteditable='true'], [tabindex]:not([tabindex='-1'])") ?? wrapper;
|
|
1914
|
+
target.scrollIntoView({
|
|
1915
|
+
behavior: "smooth",
|
|
1916
|
+
block: "center"
|
|
1917
|
+
});
|
|
1918
|
+
try {
|
|
1919
|
+
target.focus({ preventScroll: true });
|
|
1920
|
+
} catch {}
|
|
1921
|
+
return true;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
//#endregion
|
|
1925
|
+
export { FieldWrapper, FormGenerator, FormSystemProvider, GridRenderer, SectionRenderer, applyServerErrors, buildFieldDefaults, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extendSection, extractDefaultValues, extractDefaultValuesAsync, extractWatchNames, field, flattenSchema, focusFirstError, isArrayField, isChoiceField, isConditionalField, isContainerField, isDateField, isDynamicField, isNumericField, isTextField, mergeDefaultValues, mergeSchemas, omitFields, pickFields, section, sectionUntitled, shallowEqual, useFieldComponent, useFormKit, useFormSystem, useLayoutComponent, validateSchema };
|