@classytic/formkit 1.4.0 → 1.5.0

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