@classytic/formkit 1.3.1 → 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 +73 -0
- package/README.md +166 -20
- package/dist/index.d.mts +577 -164
- package/dist/index.mjs +1131 -294
- package/dist/server.d.mts +472 -151
- package/dist/server.mjs +593 -98
- package/package.json +116 -113
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,65 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
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 { 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 (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
|
/**
|
|
@@ -269,6 +287,14 @@ function toRules(condition) {
|
|
|
269
287
|
logic: "and"
|
|
270
288
|
};
|
|
271
289
|
}
|
|
290
|
+
function resolveRuleObject(rule, defaultMessage) {
|
|
291
|
+
if (rule !== null && typeof rule === "object" && "value" in rule && "message" in rule) return rule;
|
|
292
|
+
const v = rule;
|
|
293
|
+
return {
|
|
294
|
+
value: v,
|
|
295
|
+
message: defaultMessage(v)
|
|
296
|
+
};
|
|
297
|
+
}
|
|
272
298
|
/**
|
|
273
299
|
* Evaluates a conditional rule, array of rules, or a ConditionConfig against form values.
|
|
274
300
|
* Supports AND (default) and OR logic via ConditionConfig.
|
|
@@ -319,6 +345,7 @@ function defineSection(section) {
|
|
|
319
345
|
/**
|
|
320
346
|
* Extracts default values from a form schema.
|
|
321
347
|
* Walks all sections and fields, respecting nameSpace prefixes and group nesting.
|
|
348
|
+
* Array fields default to `[]` when no explicit `defaultValue` is provided.
|
|
322
349
|
*
|
|
323
350
|
* @example
|
|
324
351
|
* ```ts
|
|
@@ -329,13 +356,257 @@ function defineSection(section) {
|
|
|
329
356
|
function extractDefaultValues(schema) {
|
|
330
357
|
const defaults = {};
|
|
331
358
|
for (const section of schema.sections) {
|
|
332
|
-
const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
|
|
333
359
|
if (!section.fields) continue;
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
360
|
+
collectExplicitDefaults(section.fields, section.nameSpace ?? "", defaults);
|
|
361
|
+
}
|
|
362
|
+
return defaults;
|
|
363
|
+
}
|
|
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);
|
|
338
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
|
+
/**
|
|
574
|
+
* Yield to the browser's main thread to keep the UI responsive.
|
|
575
|
+
* Uses `scheduler.yield()` when available (Chromium 115+), falls back to a
|
|
576
|
+
* zero-duration `setTimeout` which still gives the browser a chance to
|
|
577
|
+
* process input, paint, and garbage-collect between chunks of work.
|
|
578
|
+
*
|
|
579
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
|
|
580
|
+
*/
|
|
581
|
+
async function yieldToMain() {
|
|
582
|
+
if (typeof globalThis !== "undefined" && "scheduler" in globalThis && typeof globalThis.scheduler.yield === "function") return globalThis.scheduler.yield();
|
|
583
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Async version of `extractDefaultValues` for large schemas (50+ fields).
|
|
587
|
+
* Yields to the main thread after each section so that the browser can
|
|
588
|
+
* handle input events between chunks — keeping INP scores low.
|
|
589
|
+
*
|
|
590
|
+
* Use in `getDefaultValues` passed to `useForm` when the schema is known
|
|
591
|
+
* to be large:
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```ts
|
|
595
|
+
* const form = useForm({
|
|
596
|
+
* defaultValues: async () => extractDefaultValuesAsync(schema),
|
|
597
|
+
* });
|
|
598
|
+
* ```
|
|
599
|
+
*/
|
|
600
|
+
async function extractDefaultValuesAsync(schema) {
|
|
601
|
+
const defaults = {};
|
|
602
|
+
const BUDGET_MS = 50;
|
|
603
|
+
let deadline = performance.now() + BUDGET_MS;
|
|
604
|
+
for (const section of schema.sections) {
|
|
605
|
+
if (!section.fields) continue;
|
|
606
|
+
collectExplicitDefaults(section.fields, section.nameSpace ?? "", defaults);
|
|
607
|
+
if (performance.now() >= deadline) {
|
|
608
|
+
await yieldToMain();
|
|
609
|
+
deadline = performance.now() + BUDGET_MS;
|
|
339
610
|
}
|
|
340
611
|
}
|
|
341
612
|
return defaults;
|
|
@@ -345,6 +616,9 @@ function extractDefaultValues(schema) {
|
|
|
345
616
|
* from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
|
|
346
617
|
* `maxLength`, `pattern`, and `validate` to RHF rules.
|
|
347
618
|
*
|
|
619
|
+
* Supports both shorthand scalars and `{ value, message }` objects for all
|
|
620
|
+
* numeric/length rules, and `{ regex, message }` for pattern.
|
|
621
|
+
*
|
|
348
622
|
* @example
|
|
349
623
|
* ```tsx
|
|
350
624
|
* import { buildValidationRules } from '@classytic/formkit';
|
|
@@ -357,34 +631,220 @@ function extractDefaultValues(schema) {
|
|
|
357
631
|
*/
|
|
358
632
|
function buildValidationRules(field) {
|
|
359
633
|
const rules = {};
|
|
360
|
-
if (field.required) rules.required =
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
message: `At least ${field.minLength} characters`
|
|
364
|
-
};
|
|
365
|
-
if (field.maxLength !== void 0) rules.maxLength = {
|
|
366
|
-
value: field.maxLength,
|
|
367
|
-
message: `At most ${field.maxLength} characters`
|
|
368
|
-
};
|
|
369
|
-
if (field.min !== void 0) rules.min = {
|
|
370
|
-
value: field.min,
|
|
371
|
-
message: `Must be at least ${field.min}`
|
|
634
|
+
if (field.required) rules.required = {
|
|
635
|
+
value: true,
|
|
636
|
+
message: `${field.label || field.name} is required`
|
|
372
637
|
};
|
|
373
|
-
if (field.
|
|
374
|
-
value
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
638
|
+
if (field.minLength !== void 0) {
|
|
639
|
+
const { value, message } = resolveRuleObject(field.minLength, (v) => `At least ${v} characters`);
|
|
640
|
+
rules.minLength = {
|
|
641
|
+
value,
|
|
642
|
+
message
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
if (field.maxLength !== void 0) {
|
|
646
|
+
const { value, message } = resolveRuleObject(field.maxLength, (v) => `At most ${v} characters`);
|
|
647
|
+
rules.maxLength = {
|
|
648
|
+
value,
|
|
649
|
+
message
|
|
381
650
|
};
|
|
382
|
-
}
|
|
383
|
-
|
|
651
|
+
}
|
|
652
|
+
if (field.min !== void 0) {
|
|
653
|
+
const { value, message } = resolveRuleObject(field.min, (v) => `Must be at least ${v}`);
|
|
654
|
+
rules.min = {
|
|
655
|
+
value,
|
|
656
|
+
message
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
if (field.max !== void 0) {
|
|
660
|
+
const { value, message } = resolveRuleObject(field.max, (v) => `Must be at most ${v}`);
|
|
661
|
+
rules.max = {
|
|
662
|
+
value,
|
|
663
|
+
message
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
if (field.pattern) {
|
|
667
|
+
const isObject = typeof field.pattern === "object";
|
|
668
|
+
const regexStr = isObject ? field.pattern.regex : field.pattern;
|
|
669
|
+
const message = isObject ? field.pattern.message : "Invalid format";
|
|
670
|
+
try {
|
|
671
|
+
rules.pattern = {
|
|
672
|
+
value: new RegExp(regexStr),
|
|
673
|
+
message
|
|
674
|
+
};
|
|
675
|
+
} catch {
|
|
676
|
+
console.warn(`[FormKit] Invalid regex pattern "${regexStr}" in field "${field.name}", skipping.`);
|
|
677
|
+
}
|
|
384
678
|
}
|
|
385
679
|
if (field.validate) rules.validate = field.validate;
|
|
386
680
|
return rules;
|
|
387
681
|
}
|
|
682
|
+
/** Returns true for fields that carry an `options` array (select, radio, etc.) */
|
|
683
|
+
function isChoiceField(field) {
|
|
684
|
+
return [
|
|
685
|
+
"select",
|
|
686
|
+
"combobox",
|
|
687
|
+
"multiselect",
|
|
688
|
+
"dependentSelect",
|
|
689
|
+
"radio",
|
|
690
|
+
"checkbox"
|
|
691
|
+
].includes(field.type);
|
|
692
|
+
}
|
|
693
|
+
/** Returns true for free-text input fields */
|
|
694
|
+
function isTextField(field) {
|
|
695
|
+
return [
|
|
696
|
+
"text",
|
|
697
|
+
"email",
|
|
698
|
+
"password",
|
|
699
|
+
"tel",
|
|
700
|
+
"phone",
|
|
701
|
+
"url",
|
|
702
|
+
"slug",
|
|
703
|
+
"textarea",
|
|
704
|
+
"rich-text"
|
|
705
|
+
].includes(field.type);
|
|
706
|
+
}
|
|
707
|
+
/** Returns true for numeric input fields */
|
|
708
|
+
function isNumericField(field) {
|
|
709
|
+
return ["number", "rating"].includes(field.type);
|
|
710
|
+
}
|
|
711
|
+
/** Returns true for date / time fields */
|
|
712
|
+
function isDateField(field) {
|
|
713
|
+
return [
|
|
714
|
+
"date",
|
|
715
|
+
"time",
|
|
716
|
+
"datetime"
|
|
717
|
+
].includes(field.type);
|
|
718
|
+
}
|
|
719
|
+
/** Returns true for structural fields that contain sub-fields (`itemFields`) */
|
|
720
|
+
function isContainerField(field) {
|
|
721
|
+
return ["group", "array"].includes(field.type);
|
|
722
|
+
}
|
|
723
|
+
/** Returns true for array fields that render a repeatable list */
|
|
724
|
+
function isArrayField(field) {
|
|
725
|
+
return field.type === "array";
|
|
726
|
+
}
|
|
727
|
+
/** Returns true for fields that load options asynchronously */
|
|
728
|
+
function isDynamicField(field) {
|
|
729
|
+
return !!field.loadOptions;
|
|
730
|
+
}
|
|
731
|
+
/** Returns true for fields with conditional rendering */
|
|
732
|
+
function isConditionalField(field) {
|
|
733
|
+
return field.condition !== void 0;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Merge two or more schemas into one, concatenating their sections.
|
|
737
|
+
*
|
|
738
|
+
* @example
|
|
739
|
+
* ```ts
|
|
740
|
+
* const full = mergeSchemas(personalSchema, addressSchema, billingSchema);
|
|
741
|
+
* ```
|
|
742
|
+
*/
|
|
743
|
+
function mergeSchemas(...schemas) {
|
|
744
|
+
return { sections: schemas.flatMap((s) => s.sections) };
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Add fields to a section identified by `sectionId`.
|
|
748
|
+
* Returns a new schema — the original is not mutated.
|
|
749
|
+
*
|
|
750
|
+
* @example
|
|
751
|
+
* ```ts
|
|
752
|
+
* const extended = extendSection(schema, "personal", [
|
|
753
|
+
* field.text("middleName", "Middle Name"),
|
|
754
|
+
* ]);
|
|
755
|
+
* ```
|
|
756
|
+
*/
|
|
757
|
+
function extendSection(schema, sectionId, fields, position = "end") {
|
|
758
|
+
return {
|
|
759
|
+
...schema,
|
|
760
|
+
sections: schema.sections.map((section) => {
|
|
761
|
+
if (section.id !== sectionId) return section;
|
|
762
|
+
const existing = section.fields ?? [];
|
|
763
|
+
return {
|
|
764
|
+
...section,
|
|
765
|
+
fields: position === "start" ? [...fields, ...existing] : [...existing, ...fields]
|
|
766
|
+
};
|
|
767
|
+
})
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Create a new schema that includes only the named fields.
|
|
772
|
+
*
|
|
773
|
+
* @example
|
|
774
|
+
* ```ts
|
|
775
|
+
* const slim = pickFields(schema, ["email", "password"]);
|
|
776
|
+
* ```
|
|
777
|
+
*/
|
|
778
|
+
function pickFields(schema, names) {
|
|
779
|
+
const nameSet = new Set(names);
|
|
780
|
+
return {
|
|
781
|
+
...schema,
|
|
782
|
+
sections: schema.sections.map((section) => ({
|
|
783
|
+
...section,
|
|
784
|
+
fields: (section.fields ?? []).filter((f) => nameSet.has(f.name))
|
|
785
|
+
})).filter((section) => (section.fields?.length ?? 0) > 0)
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Create a new schema that excludes the named fields.
|
|
790
|
+
*
|
|
791
|
+
* @example
|
|
792
|
+
* ```ts
|
|
793
|
+
* const withoutInternal = omitFields(schema, ["__id", "__createdAt"]);
|
|
794
|
+
* ```
|
|
795
|
+
*/
|
|
796
|
+
function omitFields(schema, names) {
|
|
797
|
+
const nameSet = new Set(names);
|
|
798
|
+
return {
|
|
799
|
+
...schema,
|
|
800
|
+
sections: schema.sections.map((section) => ({
|
|
801
|
+
...section,
|
|
802
|
+
fields: (section.fields ?? []).filter((f) => !nameSet.has(f.name))
|
|
803
|
+
}))
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Collect every field from every section into a flat array.
|
|
808
|
+
* Useful for validation, documentation, and AI schema introspection.
|
|
809
|
+
*
|
|
810
|
+
* @example
|
|
811
|
+
* ```ts
|
|
812
|
+
* const allFields = flattenSchema(schema);
|
|
813
|
+
* const required = allFields.filter(f => f.required);
|
|
814
|
+
* ```
|
|
815
|
+
*/
|
|
816
|
+
function flattenSchema(schema) {
|
|
817
|
+
return schema.sections.flatMap((s) => s.fields ?? []);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Maps a server error response to react-hook-form field errors.
|
|
821
|
+
*
|
|
822
|
+
* Call this in your `onError` / `catch` handler after a failed API submission
|
|
823
|
+
* to surface per-field server-side errors using the same UX as client validation.
|
|
824
|
+
*
|
|
825
|
+
* @param form - The `useForm` return value
|
|
826
|
+
* @param errors - Map of field path → error message (dot-notation paths supported)
|
|
827
|
+
*
|
|
828
|
+
* @example
|
|
829
|
+
* ```ts
|
|
830
|
+
* async function onSubmit(data: FormValues) {
|
|
831
|
+
* try {
|
|
832
|
+
* await api.save(data);
|
|
833
|
+
* } catch (err) {
|
|
834
|
+
* if (err.fieldErrors) {
|
|
835
|
+
* applyServerErrors(form, err.fieldErrors);
|
|
836
|
+
* // { email: "Already taken", "address.zip": "Invalid ZIP" }
|
|
837
|
+
* }
|
|
838
|
+
* }
|
|
839
|
+
* }
|
|
840
|
+
* ```
|
|
841
|
+
*/
|
|
842
|
+
function applyServerErrors(form, errors) {
|
|
843
|
+
for (const [path, message] of Object.entries(errors)) form.setError(path, {
|
|
844
|
+
type: "server",
|
|
845
|
+
message
|
|
846
|
+
});
|
|
847
|
+
}
|
|
388
848
|
|
|
389
849
|
//#endregion
|
|
390
850
|
//#region src/FormGenerator.tsx
|
|
@@ -393,6 +853,31 @@ function toFieldId(name) {
|
|
|
393
853
|
return `formkit-field-${name.replace(/[.[\]]/g, "-")}`;
|
|
394
854
|
}
|
|
395
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
|
+
/**
|
|
396
881
|
* Get nested value from an object using dot-notation path.
|
|
397
882
|
* Handles array indices: "items.0.name" resolves through arrays correctly.
|
|
398
883
|
*/
|
|
@@ -424,14 +909,41 @@ function getNestedError(errors, path) {
|
|
|
424
909
|
function prefixFields(fields, nameSpace) {
|
|
425
910
|
return fields.map((f) => ({
|
|
426
911
|
...f,
|
|
427
|
-
name: `${nameSpace}.${f.name}
|
|
428
|
-
itemFields: f.itemFields?.map((i) => ({
|
|
429
|
-
...i,
|
|
430
|
-
name: `${nameSpace}.${f.name}.${i.name}`
|
|
431
|
-
}))
|
|
912
|
+
name: `${nameSpace}.${f.name}`
|
|
432
913
|
}));
|
|
433
914
|
}
|
|
434
915
|
/**
|
|
916
|
+
* Field-level error boundary. Catches render errors in individual field
|
|
917
|
+
* components and shows a graceful fallback instead of crashing the whole form.
|
|
918
|
+
*/
|
|
919
|
+
var FormFieldErrorBoundary = class extends PureComponent {
|
|
920
|
+
state = { hasError: false };
|
|
921
|
+
static getDerivedStateFromError() {
|
|
922
|
+
return { hasError: true };
|
|
923
|
+
}
|
|
924
|
+
componentDidCatch(error, info) {
|
|
925
|
+
console.error(`[FormKit] Render error in field "${this.props.fieldName}":`, error, info);
|
|
926
|
+
}
|
|
927
|
+
render() {
|
|
928
|
+
if (this.state.hasError) return /* @__PURE__ */ jsxs("div", {
|
|
929
|
+
role: "alert",
|
|
930
|
+
className: "formkit-field-error-boundary",
|
|
931
|
+
style: {
|
|
932
|
+
border: "1px solid currentColor",
|
|
933
|
+
borderRadius: "6px",
|
|
934
|
+
padding: "8px 12px",
|
|
935
|
+
fontSize: "13px"
|
|
936
|
+
},
|
|
937
|
+
children: [
|
|
938
|
+
"Failed to render field \"",
|
|
939
|
+
this.props.fieldName,
|
|
940
|
+
"\"."
|
|
941
|
+
]
|
|
942
|
+
});
|
|
943
|
+
return this.props.children;
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
/**
|
|
435
947
|
* FormGenerator - Headless Form Generator Component
|
|
436
948
|
*
|
|
437
949
|
* Renders a form based on a schema definition, using components registered
|
|
@@ -459,6 +971,12 @@ function prefixFields(fields, nameSpace) {
|
|
|
459
971
|
function FormGenerator({ schema, control, disabled = false, variant, className, ref }) {
|
|
460
972
|
const formContext = useFormContext();
|
|
461
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]);
|
|
462
980
|
if (!activeControl) {
|
|
463
981
|
console.warn("[FormKit] FormGenerator requires a `control` prop or to be wrapped in a <FormProvider>.");
|
|
464
982
|
return null;
|
|
@@ -476,29 +994,32 @@ function FormGenerator({ schema, control, disabled = false, variant, className,
|
|
|
476
994
|
}, section.id ?? `section-${index}`))
|
|
477
995
|
});
|
|
478
996
|
}
|
|
479
|
-
|
|
480
|
-
* Renders a single section with its fields.
|
|
481
|
-
*/
|
|
482
|
-
function SectionRenderer(props) {
|
|
997
|
+
function SectionRendererImpl(props) {
|
|
483
998
|
if (props.section.condition) return /* @__PURE__ */ jsx(DynamicSectionRenderer, { ...props });
|
|
484
999
|
return /* @__PURE__ */ jsx(StaticSectionRenderer, { ...props });
|
|
485
1000
|
}
|
|
486
1001
|
/**
|
|
1002
|
+
* Memoized section renderer. Re-renders only when section config or
|
|
1003
|
+
* disabled/variant context changes — not on every form value change.
|
|
1004
|
+
*/
|
|
1005
|
+
const SectionRenderer = memo(SectionRendererImpl);
|
|
1006
|
+
/**
|
|
487
1007
|
* Section renderer that evaluates conditions reactively.
|
|
488
1008
|
* Scopes useWatch to only the fields referenced in the condition
|
|
489
1009
|
* to avoid re-rendering on every form change.
|
|
490
1010
|
*/
|
|
491
1011
|
function DynamicSectionRenderer(props) {
|
|
492
1012
|
const conditionWatchNames = useMemo(() => extractWatchNames(props.section.condition), [props.section.condition]);
|
|
493
|
-
const watchedRaw =
|
|
1013
|
+
const watchedRaw = useWatch({
|
|
494
1014
|
control: props.control,
|
|
495
|
-
name: conditionWatchNames
|
|
496
|
-
})
|
|
1015
|
+
name: conditionWatchNames.length > 0 ? conditionWatchNames : void 0
|
|
1016
|
+
});
|
|
497
1017
|
const sectionValues = useMemo(() => {
|
|
498
|
-
if (conditionWatchNames.length > 0 && Array.isArray(watchedRaw))
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
}
|
|
502
1023
|
return watchedRaw;
|
|
503
1024
|
}, [conditionWatchNames, watchedRaw]);
|
|
504
1025
|
if (!evaluateCondition(props.section.condition, sectionValues)) return null;
|
|
@@ -511,7 +1032,7 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
|
|
|
511
1032
|
if (section.nameSpace && section.fields) return prefixFields(section.fields, section.nameSpace);
|
|
512
1033
|
return section.fields;
|
|
513
1034
|
}, [section.nameSpace, section.fields]);
|
|
514
|
-
|
|
1035
|
+
const sectionNode = /* @__PURE__ */ jsx(SectionLayout, {
|
|
515
1036
|
title: section.title,
|
|
516
1037
|
description: section.description,
|
|
517
1038
|
icon: section.icon,
|
|
@@ -519,6 +1040,7 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
|
|
|
519
1040
|
className: section.className,
|
|
520
1041
|
collapsible: section.collapsible,
|
|
521
1042
|
defaultCollapsed: section.defaultCollapsed,
|
|
1043
|
+
deferRender: section.deferRender,
|
|
522
1044
|
children: section.render ? section.render({
|
|
523
1045
|
control,
|
|
524
1046
|
disabled,
|
|
@@ -532,11 +1054,17 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
|
|
|
532
1054
|
variant: activeVariant
|
|
533
1055
|
})
|
|
534
1056
|
});
|
|
1057
|
+
if (section.deferRender) return /* @__PURE__ */ jsx("div", {
|
|
1058
|
+
className: "formkit-deferred-section",
|
|
1059
|
+
style: {
|
|
1060
|
+
contentVisibility: "auto",
|
|
1061
|
+
containIntrinsicSize: "auto none auto 400px"
|
|
1062
|
+
},
|
|
1063
|
+
children: sectionNode
|
|
1064
|
+
});
|
|
1065
|
+
return sectionNode;
|
|
535
1066
|
}
|
|
536
|
-
|
|
537
|
-
* Renders a grid of fields with specified column layout.
|
|
538
|
-
*/
|
|
539
|
-
function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
|
|
1067
|
+
function GridRendererImpl({ fields, cols = 1, gap, control, disabled, variant }) {
|
|
540
1068
|
const GridLayout = useLayoutComponent("grid", variant);
|
|
541
1069
|
if (!fields || fields.length === 0) return null;
|
|
542
1070
|
return /* @__PURE__ */ jsx(GridLayout, {
|
|
@@ -551,60 +1079,79 @@ function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
|
|
|
551
1079
|
});
|
|
552
1080
|
}
|
|
553
1081
|
/**
|
|
554
|
-
*
|
|
555
|
-
* If the field requires conditional logic or dynamic options, it uses the Dynamic wrapper.
|
|
556
|
-
* Otherwise, it uses the Static wrapper, vastly improving performance by skipping `useWatch`.
|
|
1082
|
+
* Memoized grid renderer.
|
|
557
1083
|
*/
|
|
558
|
-
|
|
1084
|
+
const GridRenderer = memo(GridRendererImpl);
|
|
1085
|
+
function FieldWrapperImpl(props) {
|
|
559
1086
|
if (props.field.condition || props.field.loadOptions) return /* @__PURE__ */ jsx(DynamicFieldWrapper, { ...props });
|
|
560
1087
|
if (props.field.render) return /* @__PURE__ */ jsx(RenderedFieldWrapper, { ...props });
|
|
561
1088
|
return /* @__PURE__ */ jsx(StaticFieldWrapper, { ...props });
|
|
562
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Memoized field wrapper — the dispatch layer between schema fields and
|
|
1092
|
+
* their renderer. Re-renders only when the field config itself changes.
|
|
1093
|
+
*/
|
|
1094
|
+
const FieldWrapper = memo(FieldWrapperImpl);
|
|
563
1095
|
function RenderedFieldWrapper({ field, control, disabled, variant }) {
|
|
564
1096
|
const fieldName = field.name;
|
|
565
1097
|
const fieldId = toFieldId(fieldName);
|
|
1098
|
+
const errorId = `${fieldId}-error`;
|
|
566
1099
|
const activeVariant = field.variant ?? variant;
|
|
567
1100
|
const isDisabled = disabled || field.disabled;
|
|
568
|
-
const { errors, dirtyFields, touchedFields } = useFormState({
|
|
1101
|
+
const { errors, dirtyFields, touchedFields, isValidating, isSubmitted } = useFormState({
|
|
569
1102
|
control,
|
|
570
1103
|
name: fieldName
|
|
571
1104
|
});
|
|
572
1105
|
const fieldError = getNestedError(errors, fieldName);
|
|
573
1106
|
const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
|
|
574
1107
|
const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
|
|
1108
|
+
const shouldShowError = !!fieldError && (isTouched || isSubmitted);
|
|
575
1109
|
const fieldState = useMemo(() => ({
|
|
576
1110
|
invalid: !!fieldError,
|
|
577
1111
|
isDirty,
|
|
578
1112
|
isTouched,
|
|
579
|
-
isValidating
|
|
1113
|
+
isValidating,
|
|
1114
|
+
isSubmitted,
|
|
580
1115
|
error: fieldError
|
|
581
1116
|
}), [
|
|
582
1117
|
fieldError,
|
|
583
1118
|
isDirty,
|
|
584
|
-
isTouched
|
|
1119
|
+
isTouched,
|
|
1120
|
+
isValidating,
|
|
1121
|
+
isSubmitted
|
|
585
1122
|
]);
|
|
586
|
-
return /* @__PURE__ */ jsx(
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
field,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1123
|
+
return /* @__PURE__ */ jsx(FormFieldErrorBoundary, {
|
|
1124
|
+
fieldName,
|
|
1125
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1126
|
+
className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
|
|
1127
|
+
style: fullWidthStyle(field),
|
|
1128
|
+
"data-col-span": fieldColSpan(field),
|
|
1129
|
+
"data-formkit-field": fieldName,
|
|
1130
|
+
"data-field-type": field.type,
|
|
1131
|
+
"data-invalid": shouldShowError ? "" : void 0,
|
|
1132
|
+
"data-valid": !fieldError && isTouched ? "" : void 0,
|
|
1133
|
+
"data-disabled": isDisabled ? "" : void 0,
|
|
1134
|
+
children: field.render?.({
|
|
1135
|
+
...field,
|
|
1136
|
+
field,
|
|
1137
|
+
control,
|
|
1138
|
+
disabled: isDisabled,
|
|
1139
|
+
variant: activeVariant,
|
|
1140
|
+
error: fieldError,
|
|
1141
|
+
fieldState,
|
|
1142
|
+
fieldId,
|
|
1143
|
+
errorId,
|
|
1144
|
+
shouldShowError,
|
|
1145
|
+
isLoading: void 0,
|
|
1146
|
+
rules: buildValidationRules(field)
|
|
1147
|
+
})
|
|
601
1148
|
})
|
|
602
1149
|
});
|
|
603
1150
|
}
|
|
604
1151
|
/**
|
|
605
1152
|
* Dynamic Field Wrapper
|
|
606
1153
|
* Conditionally calls `useWatch` to trigger re-renders only when form values change.
|
|
607
|
-
*
|
|
1154
|
+
* Uses `useTransition` for async loadOptions to keep the UI responsive.
|
|
608
1155
|
*/
|
|
609
1156
|
function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
610
1157
|
const ruleWatchNames = useMemo(() => extractWatchNames(field.condition), [field.condition]);
|
|
@@ -620,10 +1167,10 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
620
1167
|
allWatchNamesRef.current = next;
|
|
621
1168
|
return next;
|
|
622
1169
|
}, [explicitWatchNames, ruleWatchNames]);
|
|
623
|
-
const watchedRaw =
|
|
1170
|
+
const watchedRaw = useWatch({
|
|
624
1171
|
control,
|
|
625
|
-
name: allWatchNames
|
|
626
|
-
})
|
|
1172
|
+
name: allWatchNames.length > 0 ? allWatchNames : void 0
|
|
1173
|
+
});
|
|
627
1174
|
const prevWatchedRef = useRef(watchedRaw);
|
|
628
1175
|
const stableWatched = useMemo(() => {
|
|
629
1176
|
if (shallowEqual(prevWatchedRef.current, watchedRaw)) return prevWatchedRef.current;
|
|
@@ -631,34 +1178,67 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
631
1178
|
return watchedRaw;
|
|
632
1179
|
}, [watchedRaw]);
|
|
633
1180
|
const watchedValues = useMemo(() => {
|
|
634
|
-
if (allWatchNames.length > 0 && Array.isArray(stableWatched))
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
+
}
|
|
638
1186
|
return stableWatched;
|
|
639
1187
|
}, [allWatchNames, stableWatched]);
|
|
1188
|
+
const [, startTransition] = useTransition();
|
|
1189
|
+
const [isLoading, setIsLoading] = useState(() => !!field.loadOptions);
|
|
640
1190
|
const [options, setOptions] = useState(field.options || []);
|
|
641
|
-
const [
|
|
1191
|
+
const [srAnnouncement, setSrAnnouncement] = useState("");
|
|
642
1192
|
const timeoutRef = useRef(null);
|
|
1193
|
+
const optionsCacheRef = useRef(/* @__PURE__ */ new Map());
|
|
1194
|
+
const loadOptionsWarnedRef = useRef(false);
|
|
643
1195
|
useEffect(() => {
|
|
644
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
|
+
}
|
|
645
1201
|
let isActive = true;
|
|
1202
|
+
const controller = new AbortController();
|
|
646
1203
|
const executeLoad = () => {
|
|
647
|
-
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 });
|
|
648
1214
|
if (res instanceof Promise) {
|
|
649
1215
|
setIsLoading(true);
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1216
|
+
startTransition(async () => {
|
|
1217
|
+
try {
|
|
1218
|
+
const newOptions = await res;
|
|
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
|
+
}
|
|
1228
|
+
setOptions(newOptions);
|
|
1229
|
+
setIsLoading(false);
|
|
1230
|
+
setSrAnnouncement(newOptions.length === 0 ? "No options available" : `${newOptions.length} option${newOptions.length === 1 ? "" : "s"} available`);
|
|
1231
|
+
}
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
if (isActive) {
|
|
1234
|
+
setIsLoading(false);
|
|
1235
|
+
if (field.onLoadError) field.onLoadError(err);
|
|
1236
|
+
else console.error("[FormKit] loadOptions error:", err);
|
|
1237
|
+
setSrAnnouncement("Failed to load options");
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
657
1240
|
});
|
|
658
|
-
} else
|
|
659
|
-
setOptions(res);
|
|
660
|
-
setIsLoading(false);
|
|
661
|
-
}
|
|
1241
|
+
} else setOptions(res);
|
|
662
1242
|
};
|
|
663
1243
|
if (field.debounceMs && field.debounceMs > 0) {
|
|
664
1244
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
@@ -666,6 +1246,7 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
666
1246
|
} else executeLoad();
|
|
667
1247
|
return () => {
|
|
668
1248
|
isActive = false;
|
|
1249
|
+
controller.abort();
|
|
669
1250
|
if (timeoutRef.current) {
|
|
670
1251
|
clearTimeout(timeoutRef.current);
|
|
671
1252
|
timeoutRef.current = null;
|
|
@@ -675,115 +1256,178 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
|
|
|
675
1256
|
watchedValues,
|
|
676
1257
|
field.loadOptions,
|
|
677
1258
|
field.debounceMs,
|
|
678
|
-
field.onLoadError
|
|
1259
|
+
field.onLoadError,
|
|
1260
|
+
field.cacheOptions
|
|
679
1261
|
]);
|
|
680
1262
|
const loadingState = field.loadOptions ? isLoading : void 0;
|
|
681
1263
|
const dynamicField = useMemo(() => ({
|
|
682
1264
|
...field,
|
|
683
|
-
options: field.loadOptions ? options : field.options
|
|
684
|
-
|
|
685
|
-
}), [
|
|
686
|
-
field,
|
|
687
|
-
options,
|
|
688
|
-
loadingState
|
|
689
|
-
]);
|
|
1265
|
+
options: field.loadOptions ? options : field.options
|
|
1266
|
+
}), [field, options]);
|
|
690
1267
|
if (!evaluateCondition(field.condition, watchedValues)) return null;
|
|
691
|
-
return /* @__PURE__ */ jsx(
|
|
1268
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [field.loadOptions && /* @__PURE__ */ jsx("span", {
|
|
1269
|
+
role: "status",
|
|
1270
|
+
"aria-live": "polite",
|
|
1271
|
+
"aria-atomic": "true",
|
|
1272
|
+
style: {
|
|
1273
|
+
position: "absolute",
|
|
1274
|
+
width: "1px",
|
|
1275
|
+
height: "1px",
|
|
1276
|
+
padding: 0,
|
|
1277
|
+
margin: "-1px",
|
|
1278
|
+
overflow: "hidden",
|
|
1279
|
+
clip: "rect(0,0,0,0)",
|
|
1280
|
+
whiteSpace: "nowrap",
|
|
1281
|
+
border: 0
|
|
1282
|
+
},
|
|
1283
|
+
children: srAnnouncement
|
|
1284
|
+
}), /* @__PURE__ */ jsx(StaticFieldWrapper, {
|
|
692
1285
|
field: dynamicField,
|
|
693
1286
|
control,
|
|
694
1287
|
disabled,
|
|
695
1288
|
variant,
|
|
696
1289
|
isLoading: loadingState
|
|
697
|
-
});
|
|
1290
|
+
})] });
|
|
698
1291
|
}
|
|
699
|
-
|
|
700
|
-
* Static Field Wrapper
|
|
701
|
-
* Handles rendering the actual component via the registry, or via a custom static `render`.
|
|
702
|
-
* Does not use `useWatch` internally.
|
|
703
|
-
*/
|
|
704
|
-
function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
|
|
1292
|
+
function StaticFieldWrapperImpl({ field, control, disabled, variant, isLoading }) {
|
|
705
1293
|
const { components } = useFormSystem();
|
|
706
1294
|
const fieldName = field.name;
|
|
707
|
-
const { errors, dirtyFields, touchedFields } = useFormState({
|
|
1295
|
+
const { errors, dirtyFields, touchedFields, isValidating, isSubmitted } = useFormState({
|
|
708
1296
|
control,
|
|
709
1297
|
name: fieldName
|
|
710
1298
|
});
|
|
711
1299
|
const isDisabled = disabled || field.disabled;
|
|
712
1300
|
const fieldId = toFieldId(fieldName);
|
|
1301
|
+
const errorId = `${fieldId}-error`;
|
|
713
1302
|
const fieldError = getNestedError(errors, fieldName);
|
|
714
1303
|
const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
|
|
715
1304
|
const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
|
|
1305
|
+
const shouldShowError = !!fieldError && (isTouched || isSubmitted);
|
|
716
1306
|
const fieldState = useMemo(() => ({
|
|
717
1307
|
invalid: !!fieldError,
|
|
718
1308
|
isDirty,
|
|
719
1309
|
isTouched,
|
|
720
|
-
isValidating
|
|
1310
|
+
isValidating,
|
|
1311
|
+
isSubmitted,
|
|
721
1312
|
error: fieldError
|
|
722
1313
|
}), [
|
|
723
1314
|
fieldError,
|
|
724
1315
|
isDirty,
|
|
725
|
-
isTouched
|
|
1316
|
+
isTouched,
|
|
1317
|
+
isValidating,
|
|
1318
|
+
isSubmitted
|
|
726
1319
|
]);
|
|
727
1320
|
const activeVariant = field.variant ?? variant;
|
|
728
|
-
|
|
1321
|
+
const hasExplicitComponent = Boolean(components[field.type] || activeVariant && components[activeVariant] && typeof components[activeVariant] === "object" && components[activeVariant][field.type]);
|
|
1322
|
+
const FieldComponent = useFieldComponent(field.type, activeVariant);
|
|
1323
|
+
const fieldProps = useMemo(() => ({
|
|
1324
|
+
...field,
|
|
1325
|
+
field,
|
|
1326
|
+
control,
|
|
1327
|
+
disabled: isDisabled,
|
|
1328
|
+
variant: activeVariant,
|
|
1329
|
+
error: fieldError,
|
|
1330
|
+
fieldState,
|
|
1331
|
+
fieldId,
|
|
1332
|
+
errorId,
|
|
1333
|
+
shouldShowError,
|
|
1334
|
+
isLoading,
|
|
1335
|
+
rules: buildValidationRules(field)
|
|
1336
|
+
}), [
|
|
1337
|
+
field,
|
|
1338
|
+
control,
|
|
1339
|
+
isDisabled,
|
|
1340
|
+
activeVariant,
|
|
1341
|
+
fieldError,
|
|
1342
|
+
fieldState,
|
|
1343
|
+
fieldId,
|
|
1344
|
+
errorId,
|
|
1345
|
+
shouldShowError,
|
|
1346
|
+
isLoading
|
|
1347
|
+
]);
|
|
1348
|
+
if (field.type === "hidden" && !hasExplicitComponent && !field.render) return null;
|
|
1349
|
+
if (!hasExplicitComponent && field.itemFields && field.itemFields.length > 0) {
|
|
729
1350
|
if (field.type === "array") return /* @__PURE__ */ jsx(ArrayFieldFallback, {
|
|
730
1351
|
field,
|
|
731
1352
|
control,
|
|
732
1353
|
disabled: isDisabled,
|
|
733
1354
|
variant: activeVariant
|
|
734
1355
|
});
|
|
1356
|
+
const prefixedGroupFields = field.itemFields.map((f) => ({
|
|
1357
|
+
...f,
|
|
1358
|
+
name: `${fieldName}.${f.name}`
|
|
1359
|
+
}));
|
|
735
1360
|
return /* @__PURE__ */ jsx("div", {
|
|
736
1361
|
className: cn("formkit-field-group", field.fullWidth && "col-span-full", field.className),
|
|
1362
|
+
style: fullWidthStyle(field),
|
|
1363
|
+
"data-col-span": fieldColSpan(field),
|
|
737
1364
|
"data-formkit-field": fieldName,
|
|
738
1365
|
"data-field-type": field.type,
|
|
739
1366
|
children: /* @__PURE__ */ jsx(GridRenderer, {
|
|
740
|
-
fields:
|
|
1367
|
+
fields: prefixedGroupFields,
|
|
741
1368
|
control,
|
|
742
1369
|
disabled: isDisabled,
|
|
743
1370
|
variant: activeVariant
|
|
744
1371
|
})
|
|
745
1372
|
});
|
|
746
1373
|
}
|
|
747
|
-
const FieldComponent = useFieldComponent(field.type, activeVariant);
|
|
748
1374
|
if (!FieldComponent && !field.render) return null;
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
"data-formkit-field": fieldName,
|
|
764
|
-
"data-field-type": field.type,
|
|
765
|
-
children: field.render ? field.render(fieldProps) : /* @__PURE__ */ jsx(FieldComponent, { ...fieldProps })
|
|
1375
|
+
return /* @__PURE__ */ jsx(FormFieldErrorBoundary, {
|
|
1376
|
+
fieldName,
|
|
1377
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1378
|
+
className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
|
|
1379
|
+
style: fullWidthStyle(field),
|
|
1380
|
+
"data-col-span": fieldColSpan(field),
|
|
1381
|
+
"data-formkit-field": fieldName,
|
|
1382
|
+
"data-field-type": field.type,
|
|
1383
|
+
"data-invalid": shouldShowError ? "" : void 0,
|
|
1384
|
+
"data-valid": !fieldError && isTouched ? "" : void 0,
|
|
1385
|
+
"data-loading": isLoading ? "" : void 0,
|
|
1386
|
+
"data-disabled": isDisabled ? "" : void 0,
|
|
1387
|
+
children: field.render ? field.render(fieldProps) : /* @__PURE__ */ jsx(FieldComponent, { ...fieldProps })
|
|
1388
|
+
})
|
|
766
1389
|
});
|
|
767
1390
|
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Memoized static field wrapper — the hot path for all non-conditional fields.
|
|
1393
|
+
* Subscribes to `useFormState` scoped to a single field name so re-renders
|
|
1394
|
+
* are limited to changes in that specific field's validation state.
|
|
1395
|
+
*/
|
|
1396
|
+
const StaticFieldWrapper = memo(StaticFieldWrapperImpl);
|
|
768
1397
|
function ArrayFieldFallback({ field, control, disabled, variant }) {
|
|
769
1398
|
const { fields, append, remove } = useFieldArray({
|
|
770
1399
|
control,
|
|
771
1400
|
name: field.name
|
|
772
1401
|
});
|
|
1402
|
+
const fieldId = toFieldId(field.name);
|
|
1403
|
+
const labelId = field.label ? `${fieldId}-label` : void 0;
|
|
773
1404
|
return /* @__PURE__ */ jsxs("div", {
|
|
774
|
-
|
|
1405
|
+
role: "group",
|
|
1406
|
+
"aria-labelledby": labelId,
|
|
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),
|
|
775
1415
|
"data-formkit-field": field.name,
|
|
776
1416
|
"data-field-type": "array",
|
|
777
1417
|
children: [
|
|
778
|
-
/* @__PURE__ */ jsx("
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
children: field.label
|
|
783
|
-
})
|
|
1418
|
+
field.label && /* @__PURE__ */ jsx("span", {
|
|
1419
|
+
id: labelId,
|
|
1420
|
+
style: { fontWeight: 600 },
|
|
1421
|
+
children: field.label
|
|
784
1422
|
}),
|
|
785
1423
|
fields.map((item, index) => /* @__PURE__ */ jsxs("div", {
|
|
786
|
-
className: "
|
|
1424
|
+
className: "formkit-array-item",
|
|
1425
|
+
style: {
|
|
1426
|
+
position: "relative",
|
|
1427
|
+
border: "1px solid currentColor",
|
|
1428
|
+
borderRadius: "6px",
|
|
1429
|
+
padding: "1rem"
|
|
1430
|
+
},
|
|
787
1431
|
children: [/* @__PURE__ */ jsx(GridRenderer, {
|
|
788
1432
|
fields: field.itemFields?.map((f) => ({
|
|
789
1433
|
...f,
|
|
@@ -794,22 +1438,25 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
|
|
|
794
1438
|
variant
|
|
795
1439
|
}), /* @__PURE__ */ jsx("button", {
|
|
796
1440
|
type: "button",
|
|
1441
|
+
className: "formkit-array-remove",
|
|
797
1442
|
onClick: () => remove(index),
|
|
798
|
-
|
|
1443
|
+
"aria-label": `Remove ${field.label ?? "item"} ${index + 1}`,
|
|
1444
|
+
style: {
|
|
1445
|
+
position: "absolute",
|
|
1446
|
+
top: "0.5rem",
|
|
1447
|
+
right: "0.5rem"
|
|
1448
|
+
},
|
|
799
1449
|
children: "Remove"
|
|
800
1450
|
})]
|
|
801
1451
|
}, item.id)),
|
|
802
1452
|
/* @__PURE__ */ jsx("button", {
|
|
803
1453
|
type: "button",
|
|
1454
|
+
className: "formkit-array-add",
|
|
804
1455
|
onClick: () => {
|
|
805
|
-
|
|
806
|
-
if (field.itemFields) {
|
|
807
|
-
for (const f of field.itemFields) if (f.defaultValue !== void 0) defaults[f.name] = f.defaultValue;
|
|
808
|
-
}
|
|
809
|
-
append(defaults);
|
|
1456
|
+
append(buildFieldDefaults(field.itemFields));
|
|
810
1457
|
},
|
|
811
1458
|
disabled,
|
|
812
|
-
|
|
1459
|
+
style: { alignSelf: "flex-start" },
|
|
813
1460
|
children: "+ Add Item"
|
|
814
1461
|
})
|
|
815
1462
|
]
|
|
@@ -821,195 +1468,315 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
|
|
|
821
1468
|
/**
|
|
822
1469
|
* Type-safe field builder helpers for schema-driven forms.
|
|
823
1470
|
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
1471
|
+
* All methods are generic over TFieldValues, defaulting to FieldValues (any string)
|
|
1472
|
+
* when no type argument is provided. Specify the generic to enforce that field
|
|
1473
|
+
* names are valid paths in your form values type.
|
|
1474
|
+
*
|
|
1475
|
+
* For fully-typed schemas where every field name is checked, prefer
|
|
1476
|
+
* `field.for<MyForm>()` which fixes the generic once for the whole schema:
|
|
826
1477
|
*
|
|
827
1478
|
* @example
|
|
828
1479
|
* ```ts
|
|
829
|
-
*
|
|
1480
|
+
* // Untyped — any string accepted (backwards compatible)
|
|
1481
|
+
* field.text("email", "Email")
|
|
830
1482
|
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
* { label: "User", value: "user" },
|
|
839
|
-
* ]),
|
|
840
|
-
* ], { cols: 2 }),
|
|
841
|
-
* ],
|
|
842
|
-
* };
|
|
1483
|
+
* // Per-call generic — name is checked against MyForm
|
|
1484
|
+
* field.text<MyForm>("email", "Email")
|
|
1485
|
+
*
|
|
1486
|
+
* // Typed factory — name checked on every call without repeating the generic
|
|
1487
|
+
* const f = field.for<MyForm>()
|
|
1488
|
+
* f.text("email", "Email") // ✓
|
|
1489
|
+
* f.text("typo", "Email") // ✗ TypeScript error
|
|
843
1490
|
* ```
|
|
844
1491
|
*/
|
|
845
1492
|
const field = {
|
|
846
|
-
|
|
1493
|
+
/** Text input field. */
|
|
1494
|
+
text: (name, label, props) => ({
|
|
847
1495
|
type: "text",
|
|
848
1496
|
name,
|
|
849
1497
|
label,
|
|
850
|
-
...props
|
|
1498
|
+
...props ?? {}
|
|
851
1499
|
}),
|
|
852
|
-
|
|
1500
|
+
/** Email input field with default placeholder. */
|
|
1501
|
+
email: (name, label, props) => ({
|
|
853
1502
|
type: "email",
|
|
854
1503
|
name,
|
|
855
1504
|
label,
|
|
856
1505
|
placeholder: "example@email.com",
|
|
857
|
-
...props
|
|
1506
|
+
...props ?? {}
|
|
858
1507
|
}),
|
|
859
|
-
|
|
1508
|
+
/** URL input field with default placeholder. */
|
|
1509
|
+
url: (name, label, props) => ({
|
|
860
1510
|
type: "url",
|
|
861
1511
|
name,
|
|
862
1512
|
label,
|
|
863
1513
|
placeholder: "https://example.com",
|
|
864
|
-
...props
|
|
1514
|
+
...props ?? {}
|
|
865
1515
|
}),
|
|
866
|
-
tel
|
|
1516
|
+
/** Phone/tel input field with default placeholder. */
|
|
1517
|
+
tel: (name, label, props) => ({
|
|
867
1518
|
type: "tel",
|
|
868
1519
|
name,
|
|
869
1520
|
label,
|
|
870
1521
|
placeholder: "+1 (555) 000-0000",
|
|
871
|
-
...props
|
|
1522
|
+
...props ?? {}
|
|
872
1523
|
}),
|
|
873
|
-
|
|
1524
|
+
/** Password input field. */
|
|
1525
|
+
password: (name, label, props) => ({
|
|
874
1526
|
type: "password",
|
|
875
1527
|
name,
|
|
876
1528
|
label,
|
|
877
|
-
...props
|
|
1529
|
+
...props ?? {}
|
|
878
1530
|
}),
|
|
879
|
-
|
|
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). */
|
|
1534
|
+
number: (name, label, props) => ({
|
|
880
1535
|
type: "number",
|
|
881
1536
|
name,
|
|
882
1537
|
label,
|
|
883
|
-
|
|
884
|
-
...props
|
|
1538
|
+
...props ?? {}
|
|
885
1539
|
}),
|
|
886
|
-
|
|
1540
|
+
/** Textarea field with default 3 rows. */
|
|
1541
|
+
textarea: (name, label, props) => ({
|
|
887
1542
|
type: "textarea",
|
|
888
1543
|
name,
|
|
889
1544
|
label,
|
|
890
1545
|
rows: 3,
|
|
891
|
-
...props
|
|
1546
|
+
...props ?? {}
|
|
892
1547
|
}),
|
|
893
|
-
|
|
1548
|
+
/** Select dropdown field. */
|
|
1549
|
+
select: (name, label, options, props) => ({
|
|
894
1550
|
type: "select",
|
|
895
1551
|
name,
|
|
896
1552
|
label,
|
|
897
1553
|
options,
|
|
898
|
-
...props
|
|
1554
|
+
...props ?? {}
|
|
899
1555
|
}),
|
|
900
|
-
|
|
1556
|
+
/** Searchable combobox field. */
|
|
1557
|
+
combobox: (name, label, options, props) => ({
|
|
901
1558
|
type: "combobox",
|
|
902
1559
|
name,
|
|
903
1560
|
label,
|
|
904
1561
|
options,
|
|
905
|
-
...props
|
|
1562
|
+
...props ?? {}
|
|
906
1563
|
}),
|
|
907
|
-
|
|
1564
|
+
/** Multi-select field. */
|
|
1565
|
+
multiselect: (name, label, options, props) => ({
|
|
908
1566
|
type: "multiselect",
|
|
909
1567
|
name,
|
|
910
1568
|
label,
|
|
911
1569
|
options,
|
|
912
1570
|
placeholder: "Select options...",
|
|
913
|
-
...props
|
|
1571
|
+
...props ?? {}
|
|
914
1572
|
}),
|
|
915
|
-
|
|
1573
|
+
/** Dependent select field that reacts to parent field changes. */
|
|
1574
|
+
dependentSelect: (name, label, props) => ({
|
|
916
1575
|
type: "dependentSelect",
|
|
917
1576
|
name,
|
|
918
1577
|
label,
|
|
919
|
-
...props
|
|
1578
|
+
...props ?? {}
|
|
920
1579
|
}),
|
|
921
|
-
|
|
1580
|
+
/** Switch/toggle field. */
|
|
1581
|
+
switch: (name, label, props) => ({
|
|
922
1582
|
type: "switch",
|
|
923
1583
|
name,
|
|
924
1584
|
label,
|
|
925
|
-
...props
|
|
1585
|
+
...props ?? {}
|
|
926
1586
|
}),
|
|
927
|
-
|
|
1587
|
+
/** Boolean field (alias for switch). */
|
|
1588
|
+
boolean: (name, label, props) => ({
|
|
928
1589
|
type: "switch",
|
|
929
1590
|
name,
|
|
930
1591
|
label,
|
|
931
|
-
...props
|
|
1592
|
+
...props ?? {}
|
|
932
1593
|
}),
|
|
933
|
-
|
|
1594
|
+
/** Checkbox field. */
|
|
1595
|
+
checkbox: (name, label, props) => ({
|
|
934
1596
|
type: "checkbox",
|
|
935
1597
|
name,
|
|
936
1598
|
label,
|
|
937
|
-
...props
|
|
1599
|
+
...props ?? {}
|
|
938
1600
|
}),
|
|
939
|
-
|
|
1601
|
+
/** Radio button group field. */
|
|
1602
|
+
radio: (name, label, options, props) => ({
|
|
940
1603
|
type: "radio",
|
|
941
1604
|
name,
|
|
942
1605
|
label,
|
|
943
1606
|
options,
|
|
944
|
-
...props
|
|
1607
|
+
...props ?? {}
|
|
945
1608
|
}),
|
|
946
|
-
|
|
1609
|
+
/** Date picker field. */
|
|
1610
|
+
date: (name, label, props) => ({
|
|
947
1611
|
type: "date",
|
|
948
1612
|
name,
|
|
949
1613
|
label,
|
|
950
|
-
...props
|
|
1614
|
+
...props ?? {}
|
|
951
1615
|
}),
|
|
952
|
-
|
|
1616
|
+
/** Tag input field. */
|
|
1617
|
+
tags: (name, label, props) => ({
|
|
953
1618
|
type: "tags",
|
|
954
1619
|
name,
|
|
955
1620
|
label,
|
|
956
1621
|
placeholder: "Add tags...",
|
|
957
|
-
...props
|
|
1622
|
+
...props ?? {}
|
|
958
1623
|
}),
|
|
959
|
-
|
|
1624
|
+
/** Slug field. */
|
|
1625
|
+
slug: (name, label, props) => ({
|
|
960
1626
|
type: "slug",
|
|
961
1627
|
name,
|
|
962
1628
|
label,
|
|
963
1629
|
placeholder: "my-page-slug",
|
|
964
|
-
...props
|
|
1630
|
+
...props ?? {}
|
|
965
1631
|
}),
|
|
966
|
-
|
|
1632
|
+
/** File upload field. */
|
|
1633
|
+
file: (name, label, props) => ({
|
|
967
1634
|
type: "file",
|
|
968
1635
|
name,
|
|
969
1636
|
label,
|
|
970
|
-
...props
|
|
1637
|
+
...props ?? {}
|
|
971
1638
|
}),
|
|
972
|
-
|
|
1639
|
+
/** Hidden field (no UI). */
|
|
1640
|
+
hidden: (name, props) => ({
|
|
973
1641
|
type: "hidden",
|
|
974
1642
|
name,
|
|
975
|
-
...props
|
|
1643
|
+
...props ?? {}
|
|
976
1644
|
}),
|
|
977
|
-
|
|
1645
|
+
/**
|
|
1646
|
+
* Group field for nested objects.
|
|
1647
|
+
* Renders itemFields as a sub-grid. Child names are relative (e.g. "street"),
|
|
1648
|
+
* FormGenerator prefixes them with the group name at render time.
|
|
1649
|
+
*
|
|
1650
|
+
* @example
|
|
1651
|
+
* ```ts
|
|
1652
|
+
* field.group("address", "Address", [
|
|
1653
|
+
* field.text("street", "Street"),
|
|
1654
|
+
* field.text("city", "City"),
|
|
1655
|
+
* ], { cols: 2 })
|
|
1656
|
+
* ```
|
|
1657
|
+
*/
|
|
1658
|
+
group: (name, label, itemFields, props) => ({
|
|
978
1659
|
type: "group",
|
|
979
1660
|
name,
|
|
980
1661
|
label,
|
|
981
1662
|
itemFields,
|
|
982
|
-
...props
|
|
1663
|
+
...props ?? {}
|
|
983
1664
|
}),
|
|
984
|
-
|
|
1665
|
+
/**
|
|
1666
|
+
* Array/repeatable field backed by react-hook-form's useFieldArray.
|
|
1667
|
+
*
|
|
1668
|
+
* @example
|
|
1669
|
+
* ```ts
|
|
1670
|
+
* field.array("contacts", "Contacts", [
|
|
1671
|
+
* field.text("name", "Name"),
|
|
1672
|
+
* field.email("email", "Email"),
|
|
1673
|
+
* ])
|
|
1674
|
+
* ```
|
|
1675
|
+
*/
|
|
1676
|
+
array: (name, label, itemFields, props) => ({
|
|
985
1677
|
type: "array",
|
|
986
1678
|
name,
|
|
987
1679
|
label,
|
|
988
1680
|
itemFields,
|
|
989
|
-
...props
|
|
1681
|
+
...props ?? {}
|
|
990
1682
|
}),
|
|
991
|
-
|
|
1683
|
+
/**
|
|
1684
|
+
* Custom field with a render function.
|
|
1685
|
+
* Bypasses the component registry — full control over rendering.
|
|
1686
|
+
*
|
|
1687
|
+
* The render callback receives the complete `FieldComponentProps` including
|
|
1688
|
+
* `fieldId`, `errorId`, `shouldShowError`, `error`, `rules`, and `control`.
|
|
1689
|
+
*
|
|
1690
|
+
* Use `shouldShowError` (not `!!error`) to drive `aria-invalid` and error
|
|
1691
|
+
* visibility so timing mirrors the CSS `:user-invalid` pseudo-class.
|
|
1692
|
+
*
|
|
1693
|
+
* @example
|
|
1694
|
+
* ```tsx
|
|
1695
|
+
* field.custom("skills", "Skills", ({ control, shouldShowError, errorId, error, fieldId }) => (
|
|
1696
|
+
* <div>
|
|
1697
|
+
* <SkillSelector
|
|
1698
|
+
* id={fieldId}
|
|
1699
|
+
* control={control}
|
|
1700
|
+
* aria-invalid={shouldShowError || undefined}
|
|
1701
|
+
* aria-errormessage={shouldShowError ? errorId : undefined}
|
|
1702
|
+
* />
|
|
1703
|
+
* {shouldShowError && (
|
|
1704
|
+
* <p id={errorId} role="alert" className="text-sm text-destructive">
|
|
1705
|
+
* {error?.message}
|
|
1706
|
+
* </p>
|
|
1707
|
+
* )}
|
|
1708
|
+
* </div>
|
|
1709
|
+
* ))
|
|
1710
|
+
* ```
|
|
1711
|
+
*/
|
|
1712
|
+
custom: (name, label, render, props) => ({
|
|
992
1713
|
type: "custom",
|
|
993
1714
|
name,
|
|
994
1715
|
label,
|
|
995
1716
|
render,
|
|
996
|
-
...props
|
|
1717
|
+
...props ?? {}
|
|
1718
|
+
}),
|
|
1719
|
+
/**
|
|
1720
|
+
* Returns a typed field builder with `TFieldValues` fixed.
|
|
1721
|
+
* Every field name is validated against `Path<TFieldValues>` at the call site —
|
|
1722
|
+
* no need to repeat the generic on each individual builder call.
|
|
1723
|
+
*
|
|
1724
|
+
* @example
|
|
1725
|
+
* ```ts
|
|
1726
|
+
* interface ContactForm {
|
|
1727
|
+
* firstName: string;
|
|
1728
|
+
* email: string;
|
|
1729
|
+
* address: { street: string; city: string };
|
|
1730
|
+
* }
|
|
1731
|
+
*
|
|
1732
|
+
* const f = field.for<ContactForm>()
|
|
1733
|
+
*
|
|
1734
|
+
* const schema = defineSchema<ContactForm>({
|
|
1735
|
+
* sections: [{
|
|
1736
|
+
* fields: [
|
|
1737
|
+
* f.text("firstName", "First Name"), // ✓
|
|
1738
|
+
* f.email("email", "Email"), // ✓
|
|
1739
|
+
* f.text("typo", "Label"), // ✗ TypeScript error
|
|
1740
|
+
* ],
|
|
1741
|
+
* }],
|
|
1742
|
+
* })
|
|
1743
|
+
* ```
|
|
1744
|
+
*/
|
|
1745
|
+
for: () => ({
|
|
1746
|
+
text: (name, label, props) => field.text(name, label, props),
|
|
1747
|
+
email: (name, label, props) => field.email(name, label, props),
|
|
1748
|
+
url: (name, label, props) => field.url(name, label, props),
|
|
1749
|
+
tel: (name, label, props) => field.tel(name, label, props),
|
|
1750
|
+
password: (name, label, props) => field.password(name, label, props),
|
|
1751
|
+
number: (name, label, props) => field.number(name, label, props),
|
|
1752
|
+
textarea: (name, label, props) => field.textarea(name, label, props),
|
|
1753
|
+
select: (name, label, options, props) => field.select(name, label, options, props),
|
|
1754
|
+
combobox: (name, label, options, props) => field.combobox(name, label, options, props),
|
|
1755
|
+
multiselect: (name, label, options, props) => field.multiselect(name, label, options, props),
|
|
1756
|
+
dependentSelect: (name, label, props) => field.dependentSelect(name, label, props),
|
|
1757
|
+
switch: (name, label, props) => field.switch(name, label, props),
|
|
1758
|
+
boolean: (name, label, props) => field.boolean(name, label, props),
|
|
1759
|
+
checkbox: (name, label, props) => field.checkbox(name, label, props),
|
|
1760
|
+
radio: (name, label, options, props) => field.radio(name, label, options, props),
|
|
1761
|
+
date: (name, label, props) => field.date(name, label, props),
|
|
1762
|
+
tags: (name, label, props) => field.tags(name, label, props),
|
|
1763
|
+
slug: (name, label, props) => field.slug(name, label, props),
|
|
1764
|
+
file: (name, label, props) => field.file(name, label, props),
|
|
1765
|
+
hidden: (name, props) => field.hidden(name, props),
|
|
1766
|
+
group: (name, label, itemFields, props) => field.group(name, label, itemFields, props),
|
|
1767
|
+
array: (name, label, itemFields, props) => field.array(name, label, itemFields, props),
|
|
1768
|
+
custom: (name, label, render, builderProps) => field.custom(name, label, render, builderProps)
|
|
997
1769
|
})
|
|
998
1770
|
};
|
|
999
1771
|
/**
|
|
1000
1772
|
* Create a section definition with sensible defaults.
|
|
1001
1773
|
*
|
|
1002
|
-
* @param id - Unique section identifier
|
|
1003
|
-
* @param title - Section title
|
|
1004
|
-
* @param fields - Array of field definitions
|
|
1005
|
-
* @param props - Additional section configuration
|
|
1006
|
-
*
|
|
1007
1774
|
* @example
|
|
1008
1775
|
* ```ts
|
|
1009
1776
|
* section("personal", "Personal Info", [
|
|
1010
1777
|
* field.text("name", "Name", { required: true }),
|
|
1011
1778
|
* field.email("email", "Email"),
|
|
1012
|
-
* ], { cols: 2
|
|
1779
|
+
* ], { cols: 2 })
|
|
1013
1780
|
* ```
|
|
1014
1781
|
*/
|
|
1015
1782
|
function section(id, title, fields, props = {}) {
|
|
@@ -1025,6 +1792,9 @@ function section(id, title, fields, props = {}) {
|
|
|
1025
1792
|
/**
|
|
1026
1793
|
* Create a section without a title (transparent section).
|
|
1027
1794
|
* Useful for grouping fields without visual separation.
|
|
1795
|
+
*
|
|
1796
|
+
* Accepts `BaseField[]` (no generic) so mixed-type field arrays don't trigger
|
|
1797
|
+
* conflicting type inference across different field name generics.
|
|
1028
1798
|
*/
|
|
1029
1799
|
function sectionUntitled(fields, props = {}) {
|
|
1030
1800
|
const { cols = 1, ...rest } = props;
|
|
@@ -1058,16 +1828,31 @@ function sectionUntitled(fields, props = {}) {
|
|
|
1058
1828
|
* ```
|
|
1059
1829
|
*/
|
|
1060
1830
|
function useFormKit(options) {
|
|
1061
|
-
const { schema, disabled, variant, className, defaultValues, ...formOptions } = options;
|
|
1831
|
+
const { schema, disabled, variant, className, defaultValues, resetOnSchemaChange, ...formOptions } = options;
|
|
1062
1832
|
const schemaDefaults = useMemo(() => extractDefaultValues(schema), [schema]);
|
|
1063
|
-
const mergedDefaults = useMemo(() =>
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1833
|
+
const mergedDefaults = useMemo(() => {
|
|
1834
|
+
if (typeof defaultValues === "function") {
|
|
1835
|
+
const userFn = defaultValues;
|
|
1836
|
+
const captured = schemaDefaults;
|
|
1837
|
+
return async () => {
|
|
1838
|
+
return mergeDefaultValues(captured, await Promise.resolve(userFn()) ?? {});
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
return mergeDefaultValues(schemaDefaults, defaultValues != null ? defaultValues : {});
|
|
1842
|
+
}, [schemaDefaults, defaultValues]);
|
|
1067
1843
|
const form = useForm({
|
|
1068
1844
|
...formOptions,
|
|
1069
1845
|
defaultValues: mergedDefaults
|
|
1070
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]);
|
|
1071
1856
|
const generatorProps = useMemo(() => ({
|
|
1072
1857
|
schema,
|
|
1073
1858
|
control: form.control,
|
|
@@ -1085,4 +1870,56 @@ function useFormKit(options) {
|
|
|
1085
1870
|
}
|
|
1086
1871
|
|
|
1087
1872
|
//#endregion
|
|
1088
|
-
|
|
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 };
|