@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/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
- }), [components, layouts]),
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
- * 4. Text fallback: `components["text"]`
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 fallback
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
- const textComponent = components["text"];
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
- * Null component for production fallback.
141
- */
142
- function NullComponent() {
143
- return null;
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 #dc2626",
154
- borderRadius: "4px",
218
+ border: "1px dashed currentColor",
219
+ borderRadius: "6px",
155
220
  fontSize: "12px",
156
- fontFamily: "monospace",
157
- backgroundColor: "#fef2f2"
221
+ fontFamily: "ui-monospace, SFMono-Regular, monospace",
222
+ opacity: .75
158
223
  },
159
224
  children: [
160
- "Missing component: ",
225
+ "Unsupported field type ",
161
226
  /* @__PURE__ */ jsx("strong", { children: field.type }),
162
- " (field: ",
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
- for (const field of section.fields) {
335
- if (field.defaultValue !== void 0) defaults[`${prefix}${field.name}`] = field.defaultValue;
336
- if (field.itemFields && field.type !== "array") {
337
- for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${prefix}${field.name}.${sub.name}`] = sub.defaultValue;
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 = `${field.label || field.name} is required`;
361
- if (field.minLength !== void 0) rules.minLength = {
362
- value: field.minLength,
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.max !== void 0) rules.max = {
374
- value: field.max,
375
- message: `Must be at most ${field.max}`
376
- };
377
- if (field.pattern) try {
378
- rules.pattern = {
379
- value: new RegExp(field.pattern),
380
- message: "Invalid format"
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
- } catch {
383
- console.warn(`[FormKit] Invalid regex pattern "${field.pattern}" in field "${field.name}", skipping.`);
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 = conditionWatchNames.length > 0 ? useWatch({
1013
+ const watchedRaw = useWatch({
494
1014
  control: props.control,
495
- name: conditionWatchNames
496
- }) : useWatch({ control: props.control });
1015
+ name: conditionWatchNames.length > 0 ? conditionWatchNames : void 0
1016
+ });
497
1017
  const sectionValues = useMemo(() => {
498
- if (conditionWatchNames.length > 0 && Array.isArray(watchedRaw)) return conditionWatchNames.reduce((acc, name, i) => ({
499
- ...acc,
500
- [name]: watchedRaw[i]
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
- return /* @__PURE__ */ jsx(SectionLayout, {
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
- * Wraps individual fields.
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
- function FieldWrapper(props) {
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: false,
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("div", {
587
- className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
588
- id: fieldId,
589
- "data-formkit-field": fieldName,
590
- "data-field-type": field.type,
591
- children: field.render?.({
592
- ...field,
593
- field,
594
- control,
595
- disabled: isDisabled,
596
- variant: activeVariant,
597
- error: fieldError,
598
- fieldState,
599
- fieldId,
600
- isLoading: void 0
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
- * Can be optimized further by providing `watchNames` on the field.
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 = allWatchNames.length > 0 ? useWatch({
1170
+ const watchedRaw = useWatch({
624
1171
  control,
625
- name: allWatchNames
626
- }) : useWatch({ control });
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)) return allWatchNames.reduce((acc, name, i) => ({
635
- ...acc,
636
- [name]: stableWatched[i]
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 [isLoading, setIsLoading] = useState(false);
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 res = field.loadOptions(watchedValues);
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
- res.then((newOptions) => {
651
- if (isActive) setOptions(newOptions);
652
- }).catch((err) => {
653
- if (isActive) if (field.onLoadError) field.onLoadError(err);
654
- else console.error("[FormKit] loadOptions error:", err);
655
- }).finally(() => {
656
- if (isActive) setIsLoading(false);
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
- isLoading: loadingState
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(StaticFieldWrapper, {
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: false,
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
- if (!Boolean(components[field.type] || activeVariant && components[activeVariant] && typeof components[activeVariant] === "object" && components[activeVariant][field.type]) && field.itemFields && field.itemFields.length > 0) {
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: field.itemFields,
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
- const fieldProps = {
750
- ...field,
751
- field,
752
- control,
753
- disabled: isDisabled,
754
- variant: activeVariant,
755
- error: fieldError,
756
- fieldState,
757
- fieldId,
758
- isLoading
759
- };
760
- return /* @__PURE__ */ jsx("div", {
761
- className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
762
- id: fieldId,
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
- className: cn("formkit-field-array flex flex-col gap-4", field.fullWidth && "col-span-full", field.className),
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("div", {
779
- className: "flex items-center justify-between",
780
- children: field.label && /* @__PURE__ */ jsx("label", {
781
- className: "font-semibold",
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: "relative formkit-array-item border p-4 rounded-md",
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
- className: "absolute top-2 right-2 text-red-500 hover:text-red-700 text-sm font-medium",
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
- const defaults = {};
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
- className: "self-start mt-2 px-4 py-2 bg-blue-50 text-blue-600 rounded-md text-sm font-medium hover:bg-blue-100 disabled:opacity-50",
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
- * Provides shorthand methods for common field types with sensible defaults,
825
- * reducing boilerplate while maintaining full type safety.
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
- * import { field, section } from '@classytic/formkit';
1480
+ * // Untyped any string accepted (backwards compatible)
1481
+ * field.text("email", "Email")
830
1482
  *
831
- * const schema = {
832
- * sections: [
833
- * section("personal", "Personal Info", [
834
- * field.text("firstName", "First Name", { required: true }),
835
- * field.email("email", "Email"),
836
- * field.select("role", "Role", [
837
- * { label: "Admin", value: "admin" },
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
- text: (name, label, props = {}) => ({
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
- email: (name, label, props = {}) => ({
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
- url: (name, label, props = {}) => ({
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: (name, label, props = {}) => ({
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
- password: (name, label, props = {}) => ({
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
- number: (name, label, props = {}) => ({
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
- min: 0,
884
- ...props
1538
+ ...props ?? {}
885
1539
  }),
886
- textarea: (name, label, props = {}) => ({
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
- select: (name, label, options, props = {}) => ({
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
- combobox: (name, label, options, props = {}) => ({
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
- multiselect: (name, label, options, props = {}) => ({
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
- dependentSelect: (name, label, props = {}) => ({
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
- switch: (name, label, props = {}) => ({
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
- boolean: (name, label, props = {}) => ({
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
- checkbox: (name, label, props = {}) => ({
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
- radio: (name, label, options, props = {}) => ({
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
- date: (name, label, props = {}) => ({
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
- tags: (name, label, props = {}) => ({
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
- slug: (name, label, props = {}) => ({
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
- file: (name, label, props = {}) => ({
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
- hidden: (name, props = {}) => ({
1639
+ /** Hidden field (no UI). */
1640
+ hidden: (name, props) => ({
973
1641
  type: "hidden",
974
1642
  name,
975
- ...props
1643
+ ...props ?? {}
976
1644
  }),
977
- group: (name, label, itemFields, props = {}) => ({
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
- array: (name, label, itemFields, props = {}) => ({
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
- custom: (name, label, render, props = {}) => ({
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, variant: "card" })
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
- ...schemaDefaults,
1065
- ...typeof defaultValues === "object" && defaultValues !== null ? defaultValues : {}
1066
- }), [schemaDefaults, defaultValues]);
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
- export { FieldWrapper, FormGenerator, FormSystemProvider, GridRenderer, SectionRenderer, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extractDefaultValues, extractWatchNames, field, section, sectionUntitled, shallowEqual, useFieldComponent, useFormKit, useFormSystem, useLayoutComponent };
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 };