@classytic/formkit 1.3.1 → 1.4.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,7 +1,7 @@
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";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { clsx } from "clsx";
6
6
  import { twMerge } from "tailwind-merge";
7
7
 
@@ -93,7 +93,7 @@ function useFieldComponent(type, variant) {
93
93
  if (defaultComponent && typeof defaultComponent === "function") return defaultComponent;
94
94
  const textComponent = components["text"];
95
95
  if (textComponent && typeof textComponent === "function") return textComponent;
96
- if (process.env.NODE_ENV !== "production") {
96
+ if (typeof process === "undefined" || process.env["NODE_ENV"] !== "production") {
97
97
  console.warn(`[FormKit] No component found for type "${type}"${variant ? ` (variant: "${variant}")` : ""}. Register a component for this type in your FormSystemProvider.`);
98
98
  return MissingFieldComponent;
99
99
  }
@@ -269,6 +269,14 @@ function toRules(condition) {
269
269
  logic: "and"
270
270
  };
271
271
  }
272
+ function resolveRuleObject(rule, defaultMessage) {
273
+ if (rule !== null && typeof rule === "object" && "value" in rule && "message" in rule) return rule;
274
+ const v = rule;
275
+ return {
276
+ value: v,
277
+ message: defaultMessage(v)
278
+ };
279
+ }
272
280
  /**
273
281
  * Evaluates a conditional rule, array of rules, or a ConditionConfig against form values.
274
282
  * Supports AND (default) and OR logic via ConditionConfig.
@@ -319,6 +327,7 @@ function defineSection(section) {
319
327
  /**
320
328
  * Extracts default values from a form schema.
321
329
  * Walks all sections and fields, respecting nameSpace prefixes and group nesting.
330
+ * Array fields default to `[]` when no explicit `defaultValue` is provided.
322
331
  *
323
332
  * @example
324
333
  * ```ts
@@ -332,11 +341,62 @@ function extractDefaultValues(schema) {
332
341
  const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
333
342
  if (!section.fields) continue;
334
343
  for (const field of section.fields) {
335
- if (field.defaultValue !== void 0) defaults[`${prefix}${field.name}`] = field.defaultValue;
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
+ }
351
+ }
352
+ return defaults;
353
+ }
354
+ /**
355
+ * Yield to the browser's main thread to keep the UI responsive.
356
+ * Uses `scheduler.yield()` when available (Chromium 115+), falls back to a
357
+ * zero-duration `setTimeout` which still gives the browser a chance to
358
+ * process input, paint, and garbage-collect between chunks of work.
359
+ *
360
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
361
+ */
362
+ async function yieldToMain() {
363
+ if (typeof globalThis !== "undefined" && "scheduler" in globalThis && typeof globalThis.scheduler.yield === "function") return globalThis.scheduler.yield();
364
+ return new Promise((resolve) => setTimeout(resolve, 0));
365
+ }
366
+ /**
367
+ * Async version of `extractDefaultValues` for large schemas (50+ fields).
368
+ * Yields to the main thread after each section so that the browser can
369
+ * handle input events between chunks — keeping INP scores low.
370
+ *
371
+ * Use in `getDefaultValues` passed to `useForm` when the schema is known
372
+ * to be large:
373
+ *
374
+ * @example
375
+ * ```ts
376
+ * const form = useForm({
377
+ * defaultValues: async () => extractDefaultValuesAsync(schema),
378
+ * });
379
+ * ```
380
+ */
381
+ async function extractDefaultValuesAsync(schema) {
382
+ const defaults = {};
383
+ const BUDGET_MS = 50;
384
+ let deadline = performance.now() + BUDGET_MS;
385
+ for (const section of schema.sections) {
386
+ const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
387
+ 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] = [];
336
392
  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;
393
+ for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${key}.${sub.name}`] = sub.defaultValue;
338
394
  }
339
395
  }
396
+ if (performance.now() >= deadline) {
397
+ await yieldToMain();
398
+ deadline = performance.now() + BUDGET_MS;
399
+ }
340
400
  }
341
401
  return defaults;
342
402
  }
@@ -345,6 +405,9 @@ function extractDefaultValues(schema) {
345
405
  * from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
346
406
  * `maxLength`, `pattern`, and `validate` to RHF rules.
347
407
  *
408
+ * Supports both shorthand scalars and `{ value, message }` objects for all
409
+ * numeric/length rules, and `{ regex, message }` for pattern.
410
+ *
348
411
  * @example
349
412
  * ```tsx
350
413
  * import { buildValidationRules } from '@classytic/formkit';
@@ -357,34 +420,220 @@ function extractDefaultValues(schema) {
357
420
  */
358
421
  function buildValidationRules(field) {
359
422
  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}`
423
+ if (field.required) rules.required = {
424
+ value: true,
425
+ message: `${field.label || field.name} is required`
372
426
  };
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"
427
+ if (field.minLength !== void 0) {
428
+ const { value, message } = resolveRuleObject(field.minLength, (v) => `At least ${v} characters`);
429
+ rules.minLength = {
430
+ value,
431
+ message
381
432
  };
382
- } catch {
383
- console.warn(`[FormKit] Invalid regex pattern "${field.pattern}" in field "${field.name}", skipping.`);
433
+ }
434
+ if (field.maxLength !== void 0) {
435
+ const { value, message } = resolveRuleObject(field.maxLength, (v) => `At most ${v} characters`);
436
+ rules.maxLength = {
437
+ value,
438
+ message
439
+ };
440
+ }
441
+ if (field.min !== void 0) {
442
+ const { value, message } = resolveRuleObject(field.min, (v) => `Must be at least ${v}`);
443
+ rules.min = {
444
+ value,
445
+ message
446
+ };
447
+ }
448
+ if (field.max !== void 0) {
449
+ const { value, message } = resolveRuleObject(field.max, (v) => `Must be at most ${v}`);
450
+ rules.max = {
451
+ value,
452
+ message
453
+ };
454
+ }
455
+ if (field.pattern) {
456
+ const isObject = typeof field.pattern === "object";
457
+ const regexStr = isObject ? field.pattern.regex : field.pattern;
458
+ const message = isObject ? field.pattern.message : "Invalid format";
459
+ try {
460
+ rules.pattern = {
461
+ value: new RegExp(regexStr),
462
+ message
463
+ };
464
+ } catch {
465
+ console.warn(`[FormKit] Invalid regex pattern "${regexStr}" in field "${field.name}", skipping.`);
466
+ }
384
467
  }
385
468
  if (field.validate) rules.validate = field.validate;
386
469
  return rules;
387
470
  }
471
+ /** Returns true for fields that carry an `options` array (select, radio, etc.) */
472
+ function isChoiceField(field) {
473
+ return [
474
+ "select",
475
+ "combobox",
476
+ "multiselect",
477
+ "dependentSelect",
478
+ "radio",
479
+ "checkbox"
480
+ ].includes(field.type);
481
+ }
482
+ /** Returns true for free-text input fields */
483
+ function isTextField(field) {
484
+ return [
485
+ "text",
486
+ "email",
487
+ "password",
488
+ "tel",
489
+ "phone",
490
+ "url",
491
+ "slug",
492
+ "textarea",
493
+ "rich-text"
494
+ ].includes(field.type);
495
+ }
496
+ /** Returns true for numeric input fields */
497
+ function isNumericField(field) {
498
+ return ["number", "rating"].includes(field.type);
499
+ }
500
+ /** Returns true for date / time fields */
501
+ function isDateField(field) {
502
+ return [
503
+ "date",
504
+ "time",
505
+ "datetime"
506
+ ].includes(field.type);
507
+ }
508
+ /** Returns true for structural fields that contain sub-fields (`itemFields`) */
509
+ function isContainerField(field) {
510
+ return ["group", "array"].includes(field.type);
511
+ }
512
+ /** Returns true for array fields that render a repeatable list */
513
+ function isArrayField(field) {
514
+ return field.type === "array";
515
+ }
516
+ /** Returns true for fields that load options asynchronously */
517
+ function isDynamicField(field) {
518
+ return !!field.loadOptions;
519
+ }
520
+ /** Returns true for fields with conditional rendering */
521
+ function isConditionalField(field) {
522
+ return field.condition !== void 0;
523
+ }
524
+ /**
525
+ * Merge two or more schemas into one, concatenating their sections.
526
+ *
527
+ * @example
528
+ * ```ts
529
+ * const full = mergeSchemas(personalSchema, addressSchema, billingSchema);
530
+ * ```
531
+ */
532
+ function mergeSchemas(...schemas) {
533
+ return { sections: schemas.flatMap((s) => s.sections) };
534
+ }
535
+ /**
536
+ * Add fields to a section identified by `sectionId`.
537
+ * Returns a new schema — the original is not mutated.
538
+ *
539
+ * @example
540
+ * ```ts
541
+ * const extended = extendSection(schema, "personal", [
542
+ * field.text("middleName", "Middle Name"),
543
+ * ]);
544
+ * ```
545
+ */
546
+ function extendSection(schema, sectionId, fields, position = "end") {
547
+ return {
548
+ ...schema,
549
+ sections: schema.sections.map((section) => {
550
+ if (section.id !== sectionId) return section;
551
+ const existing = section.fields ?? [];
552
+ return {
553
+ ...section,
554
+ fields: position === "start" ? [...fields, ...existing] : [...existing, ...fields]
555
+ };
556
+ })
557
+ };
558
+ }
559
+ /**
560
+ * Create a new schema that includes only the named fields.
561
+ *
562
+ * @example
563
+ * ```ts
564
+ * const slim = pickFields(schema, ["email", "password"]);
565
+ * ```
566
+ */
567
+ function pickFields(schema, names) {
568
+ const nameSet = new Set(names);
569
+ return {
570
+ ...schema,
571
+ sections: schema.sections.map((section) => ({
572
+ ...section,
573
+ fields: (section.fields ?? []).filter((f) => nameSet.has(f.name))
574
+ })).filter((section) => (section.fields?.length ?? 0) > 0)
575
+ };
576
+ }
577
+ /**
578
+ * Create a new schema that excludes the named fields.
579
+ *
580
+ * @example
581
+ * ```ts
582
+ * const withoutInternal = omitFields(schema, ["__id", "__createdAt"]);
583
+ * ```
584
+ */
585
+ function omitFields(schema, names) {
586
+ const nameSet = new Set(names);
587
+ return {
588
+ ...schema,
589
+ sections: schema.sections.map((section) => ({
590
+ ...section,
591
+ fields: (section.fields ?? []).filter((f) => !nameSet.has(f.name))
592
+ }))
593
+ };
594
+ }
595
+ /**
596
+ * Collect every field from every section into a flat array.
597
+ * Useful for validation, documentation, and AI schema introspection.
598
+ *
599
+ * @example
600
+ * ```ts
601
+ * const allFields = flattenSchema(schema);
602
+ * const required = allFields.filter(f => f.required);
603
+ * ```
604
+ */
605
+ function flattenSchema(schema) {
606
+ return schema.sections.flatMap((s) => s.fields ?? []);
607
+ }
608
+ /**
609
+ * Maps a server error response to react-hook-form field errors.
610
+ *
611
+ * Call this in your `onError` / `catch` handler after a failed API submission
612
+ * to surface per-field server-side errors using the same UX as client validation.
613
+ *
614
+ * @param form - The `useForm` return value
615
+ * @param errors - Map of field path → error message (dot-notation paths supported)
616
+ *
617
+ * @example
618
+ * ```ts
619
+ * async function onSubmit(data: FormValues) {
620
+ * try {
621
+ * await api.save(data);
622
+ * } catch (err) {
623
+ * if (err.fieldErrors) {
624
+ * applyServerErrors(form, err.fieldErrors);
625
+ * // { email: "Already taken", "address.zip": "Invalid ZIP" }
626
+ * }
627
+ * }
628
+ * }
629
+ * ```
630
+ */
631
+ function applyServerErrors(form, errors) {
632
+ for (const [path, message] of Object.entries(errors)) form.setError(path, {
633
+ type: "server",
634
+ message
635
+ });
636
+ }
388
637
 
389
638
  //#endregion
390
639
  //#region src/FormGenerator.tsx
@@ -424,14 +673,35 @@ function getNestedError(errors, path) {
424
673
  function prefixFields(fields, nameSpace) {
425
674
  return fields.map((f) => ({
426
675
  ...f,
427
- name: `${nameSpace}.${f.name}`,
428
- itemFields: f.itemFields?.map((i) => ({
429
- ...i,
430
- name: `${nameSpace}.${f.name}.${i.name}`
431
- }))
676
+ name: `${nameSpace}.${f.name}`
432
677
  }));
433
678
  }
434
679
  /**
680
+ * Field-level error boundary. Catches render errors in individual field
681
+ * components and shows a graceful fallback instead of crashing the whole form.
682
+ */
683
+ var FormFieldErrorBoundary = class extends PureComponent {
684
+ state = { hasError: false };
685
+ static getDerivedStateFromError() {
686
+ return { hasError: true };
687
+ }
688
+ componentDidCatch(error, info) {
689
+ console.error(`[FormKit] Render error in field "${this.props.fieldName}":`, error, info);
690
+ }
691
+ render() {
692
+ if (this.state.hasError) return /* @__PURE__ */ jsxs("div", {
693
+ role: "alert",
694
+ className: "formkit-field-error-boundary rounded border border-red-300 bg-red-50 p-2 text-sm text-red-600",
695
+ children: [
696
+ "Failed to render field \"",
697
+ this.props.fieldName,
698
+ "\"."
699
+ ]
700
+ });
701
+ return this.props.children;
702
+ }
703
+ };
704
+ /**
435
705
  * FormGenerator - Headless Form Generator Component
436
706
  *
437
707
  * Renders a form based on a schema definition, using components registered
@@ -476,14 +746,16 @@ function FormGenerator({ schema, control, disabled = false, variant, className,
476
746
  }, section.id ?? `section-${index}`))
477
747
  });
478
748
  }
479
- /**
480
- * Renders a single section with its fields.
481
- */
482
- function SectionRenderer(props) {
749
+ function SectionRendererImpl(props) {
483
750
  if (props.section.condition) return /* @__PURE__ */ jsx(DynamicSectionRenderer, { ...props });
484
751
  return /* @__PURE__ */ jsx(StaticSectionRenderer, { ...props });
485
752
  }
486
753
  /**
754
+ * Memoized section renderer. Re-renders only when section config or
755
+ * disabled/variant context changes — not on every form value change.
756
+ */
757
+ const SectionRenderer = memo(SectionRendererImpl);
758
+ /**
487
759
  * Section renderer that evaluates conditions reactively.
488
760
  * Scopes useWatch to only the fields referenced in the condition
489
761
  * to avoid re-rendering on every form change.
@@ -511,7 +783,7 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
511
783
  if (section.nameSpace && section.fields) return prefixFields(section.fields, section.nameSpace);
512
784
  return section.fields;
513
785
  }, [section.nameSpace, section.fields]);
514
- return /* @__PURE__ */ jsx(SectionLayout, {
786
+ const sectionNode = /* @__PURE__ */ jsx(SectionLayout, {
515
787
  title: section.title,
516
788
  description: section.description,
517
789
  icon: section.icon,
@@ -519,6 +791,7 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
519
791
  className: section.className,
520
792
  collapsible: section.collapsible,
521
793
  defaultCollapsed: section.defaultCollapsed,
794
+ deferRender: section.deferRender,
522
795
  children: section.render ? section.render({
523
796
  control,
524
797
  disabled,
@@ -532,11 +805,17 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
532
805
  variant: activeVariant
533
806
  })
534
807
  });
808
+ if (section.deferRender) return /* @__PURE__ */ jsx("div", {
809
+ className: "formkit-deferred-section",
810
+ style: {
811
+ contentVisibility: "auto",
812
+ containIntrinsicSize: "auto none auto 400px"
813
+ },
814
+ children: sectionNode
815
+ });
816
+ return sectionNode;
535
817
  }
536
- /**
537
- * Renders a grid of fields with specified column layout.
538
- */
539
- function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
818
+ function GridRendererImpl({ fields, cols = 1, gap, control, disabled, variant }) {
540
819
  const GridLayout = useLayoutComponent("grid", variant);
541
820
  if (!fields || fields.length === 0) return null;
542
821
  return /* @__PURE__ */ jsx(GridLayout, {
@@ -551,60 +830,77 @@ function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
551
830
  });
552
831
  }
553
832
  /**
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`.
833
+ * Memoized grid renderer.
557
834
  */
558
- function FieldWrapper(props) {
835
+ const GridRenderer = memo(GridRendererImpl);
836
+ function FieldWrapperImpl(props) {
559
837
  if (props.field.condition || props.field.loadOptions) return /* @__PURE__ */ jsx(DynamicFieldWrapper, { ...props });
560
838
  if (props.field.render) return /* @__PURE__ */ jsx(RenderedFieldWrapper, { ...props });
561
839
  return /* @__PURE__ */ jsx(StaticFieldWrapper, { ...props });
562
840
  }
841
+ /**
842
+ * Memoized field wrapper — the dispatch layer between schema fields and
843
+ * their renderer. Re-renders only when the field config itself changes.
844
+ */
845
+ const FieldWrapper = memo(FieldWrapperImpl);
563
846
  function RenderedFieldWrapper({ field, control, disabled, variant }) {
564
847
  const fieldName = field.name;
565
848
  const fieldId = toFieldId(fieldName);
849
+ const errorId = `${fieldId}-error`;
566
850
  const activeVariant = field.variant ?? variant;
567
851
  const isDisabled = disabled || field.disabled;
568
- const { errors, dirtyFields, touchedFields } = useFormState({
852
+ const { errors, dirtyFields, touchedFields, isValidating, isSubmitted } = useFormState({
569
853
  control,
570
854
  name: fieldName
571
855
  });
572
856
  const fieldError = getNestedError(errors, fieldName);
573
857
  const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
574
858
  const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
859
+ const shouldShowError = !!fieldError && (isTouched || isSubmitted);
575
860
  const fieldState = useMemo(() => ({
576
861
  invalid: !!fieldError,
577
862
  isDirty,
578
863
  isTouched,
579
- isValidating: false,
864
+ isValidating,
865
+ isSubmitted,
580
866
  error: fieldError
581
867
  }), [
582
868
  fieldError,
583
869
  isDirty,
584
- isTouched
870
+ isTouched,
871
+ isValidating,
872
+ isSubmitted
585
873
  ]);
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
874
+ return /* @__PURE__ */ jsx(FormFieldErrorBoundary, {
875
+ fieldName,
876
+ children: /* @__PURE__ */ jsx("div", {
877
+ className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
878
+ "data-formkit-field": fieldName,
879
+ "data-field-type": field.type,
880
+ "data-invalid": shouldShowError ? "" : void 0,
881
+ "data-valid": !fieldError && isTouched ? "" : void 0,
882
+ "data-disabled": isDisabled ? "" : void 0,
883
+ children: field.render?.({
884
+ ...field,
885
+ field,
886
+ control,
887
+ disabled: isDisabled,
888
+ variant: activeVariant,
889
+ error: fieldError,
890
+ fieldState,
891
+ fieldId,
892
+ errorId,
893
+ shouldShowError,
894
+ isLoading: void 0,
895
+ rules: buildValidationRules(field)
896
+ })
601
897
  })
602
898
  });
603
899
  }
604
900
  /**
605
901
  * Dynamic Field Wrapper
606
902
  * Conditionally calls `useWatch` to trigger re-renders only when form values change.
607
- * Can be optimized further by providing `watchNames` on the field.
903
+ * Uses `useTransition` for async loadOptions to keep the UI responsive.
608
904
  */
609
905
  function DynamicFieldWrapper({ field, control, disabled, variant }) {
610
906
  const ruleWatchNames = useMemo(() => extractWatchNames(field.condition), [field.condition]);
@@ -637,8 +933,10 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
637
933
  }), {});
638
934
  return stableWatched;
639
935
  }, [allWatchNames, stableWatched]);
936
+ const [, startTransition] = useTransition();
937
+ const [isLoading, setIsLoading] = useState(() => !!field.loadOptions);
640
938
  const [options, setOptions] = useState(field.options || []);
641
- const [isLoading, setIsLoading] = useState(false);
939
+ const [srAnnouncement, setSrAnnouncement] = useState("");
642
940
  const timeoutRef = useRef(null);
643
941
  useEffect(() => {
644
942
  if (!field.loadOptions) return;
@@ -647,18 +945,24 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
647
945
  const res = field.loadOptions(watchedValues);
648
946
  if (res instanceof Promise) {
649
947
  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);
948
+ startTransition(async () => {
949
+ try {
950
+ const newOptions = await res;
951
+ if (isActive) {
952
+ setOptions(newOptions);
953
+ setIsLoading(false);
954
+ setSrAnnouncement(newOptions.length === 0 ? "No options available" : `${newOptions.length} option${newOptions.length === 1 ? "" : "s"} available`);
955
+ }
956
+ } catch (err) {
957
+ if (isActive) {
958
+ setIsLoading(false);
959
+ if (field.onLoadError) field.onLoadError(err);
960
+ else console.error("[FormKit] loadOptions error:", err);
961
+ setSrAnnouncement("Failed to load options");
962
+ }
963
+ }
657
964
  });
658
- } else {
659
- setOptions(res);
660
- setIsLoading(false);
661
- }
965
+ } else setOptions(res);
662
966
  };
663
967
  if (field.debounceMs && field.debounceMs > 0) {
664
968
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -680,49 +984,60 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
680
984
  const loadingState = field.loadOptions ? isLoading : void 0;
681
985
  const dynamicField = useMemo(() => ({
682
986
  ...field,
683
- options: field.loadOptions ? options : field.options,
684
- isLoading: loadingState
685
- }), [
686
- field,
687
- options,
688
- loadingState
689
- ]);
987
+ options: field.loadOptions ? options : field.options
988
+ }), [field, options]);
690
989
  if (!evaluateCondition(field.condition, watchedValues)) return null;
691
- return /* @__PURE__ */ jsx(StaticFieldWrapper, {
990
+ return /* @__PURE__ */ jsxs(Fragment, { children: [field.loadOptions && /* @__PURE__ */ jsx("span", {
991
+ role: "status",
992
+ "aria-live": "polite",
993
+ "aria-atomic": "true",
994
+ style: {
995
+ position: "absolute",
996
+ width: "1px",
997
+ height: "1px",
998
+ padding: 0,
999
+ margin: "-1px",
1000
+ overflow: "hidden",
1001
+ clip: "rect(0,0,0,0)",
1002
+ whiteSpace: "nowrap",
1003
+ border: 0
1004
+ },
1005
+ children: srAnnouncement
1006
+ }), /* @__PURE__ */ jsx(StaticFieldWrapper, {
692
1007
  field: dynamicField,
693
1008
  control,
694
1009
  disabled,
695
1010
  variant,
696
1011
  isLoading: loadingState
697
- });
1012
+ })] });
698
1013
  }
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 }) {
1014
+ function StaticFieldWrapperImpl({ field, control, disabled, variant, isLoading }) {
705
1015
  const { components } = useFormSystem();
706
1016
  const fieldName = field.name;
707
- const { errors, dirtyFields, touchedFields } = useFormState({
1017
+ const { errors, dirtyFields, touchedFields, isValidating, isSubmitted } = useFormState({
708
1018
  control,
709
1019
  name: fieldName
710
1020
  });
711
1021
  const isDisabled = disabled || field.disabled;
712
1022
  const fieldId = toFieldId(fieldName);
1023
+ const errorId = `${fieldId}-error`;
713
1024
  const fieldError = getNestedError(errors, fieldName);
714
1025
  const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
715
1026
  const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
1027
+ const shouldShowError = !!fieldError && (isTouched || isSubmitted);
716
1028
  const fieldState = useMemo(() => ({
717
1029
  invalid: !!fieldError,
718
1030
  isDirty,
719
1031
  isTouched,
720
- isValidating: false,
1032
+ isValidating,
1033
+ isSubmitted,
721
1034
  error: fieldError
722
1035
  }), [
723
1036
  fieldError,
724
1037
  isDirty,
725
- isTouched
1038
+ isTouched,
1039
+ isValidating,
1040
+ isSubmitted
726
1041
  ]);
727
1042
  const activeVariant = field.variant ?? variant;
728
1043
  if (!Boolean(components[field.type] || activeVariant && components[activeVariant] && typeof components[activeVariant] === "object" && components[activeVariant][field.type]) && field.itemFields && field.itemFields.length > 0) {
@@ -732,12 +1047,16 @@ function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
732
1047
  disabled: isDisabled,
733
1048
  variant: activeVariant
734
1049
  });
1050
+ const prefixedGroupFields = field.itemFields.map((f) => ({
1051
+ ...f,
1052
+ name: `${fieldName}.${f.name}`
1053
+ }));
735
1054
  return /* @__PURE__ */ jsx("div", {
736
1055
  className: cn("formkit-field-group", field.fullWidth && "col-span-full", field.className),
737
1056
  "data-formkit-field": fieldName,
738
1057
  "data-field-type": field.type,
739
1058
  children: /* @__PURE__ */ jsx(GridRenderer, {
740
- fields: field.itemFields,
1059
+ fields: prefixedGroupFields,
741
1060
  control,
742
1061
  disabled: isDisabled,
743
1062
  variant: activeVariant
@@ -746,7 +1065,7 @@ function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
746
1065
  }
747
1066
  const FieldComponent = useFieldComponent(field.type, activeVariant);
748
1067
  if (!FieldComponent && !field.render) return null;
749
- const fieldProps = {
1068
+ const fieldProps = useMemo(() => ({
750
1069
  ...field,
751
1070
  field,
752
1071
  control,
@@ -755,29 +1074,60 @@ function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
755
1074
  error: fieldError,
756
1075
  fieldState,
757
1076
  fieldId,
1077
+ errorId,
1078
+ shouldShowError,
1079
+ isLoading,
1080
+ rules: buildValidationRules(field)
1081
+ }), [
1082
+ field,
1083
+ control,
1084
+ isDisabled,
1085
+ activeVariant,
1086
+ fieldError,
1087
+ fieldState,
1088
+ fieldId,
1089
+ errorId,
1090
+ shouldShowError,
758
1091
  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 })
1092
+ ]);
1093
+ return /* @__PURE__ */ jsx(FormFieldErrorBoundary, {
1094
+ fieldName,
1095
+ children: /* @__PURE__ */ jsx("div", {
1096
+ className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
1097
+ "data-formkit-field": fieldName,
1098
+ "data-field-type": field.type,
1099
+ "data-invalid": shouldShowError ? "" : void 0,
1100
+ "data-valid": !fieldError && isTouched ? "" : void 0,
1101
+ "data-loading": isLoading ? "" : void 0,
1102
+ "data-disabled": isDisabled ? "" : void 0,
1103
+ children: field.render ? field.render(fieldProps) : /* @__PURE__ */ jsx(FieldComponent, { ...fieldProps })
1104
+ })
766
1105
  });
767
1106
  }
1107
+ /**
1108
+ * Memoized static field wrapper — the hot path for all non-conditional fields.
1109
+ * Subscribes to `useFormState` scoped to a single field name so re-renders
1110
+ * are limited to changes in that specific field's validation state.
1111
+ */
1112
+ const StaticFieldWrapper = memo(StaticFieldWrapperImpl);
768
1113
  function ArrayFieldFallback({ field, control, disabled, variant }) {
769
1114
  const { fields, append, remove } = useFieldArray({
770
1115
  control,
771
1116
  name: field.name
772
1117
  });
1118
+ const fieldId = toFieldId(field.name);
1119
+ const labelId = field.label ? `${fieldId}-label` : void 0;
773
1120
  return /* @__PURE__ */ jsxs("div", {
1121
+ role: "group",
1122
+ "aria-labelledby": labelId,
774
1123
  className: cn("formkit-field-array flex flex-col gap-4", field.fullWidth && "col-span-full", field.className),
775
1124
  "data-formkit-field": field.name,
776
1125
  "data-field-type": "array",
777
1126
  children: [
778
- /* @__PURE__ */ jsx("div", {
1127
+ field.label && /* @__PURE__ */ jsx("div", {
779
1128
  className: "flex items-center justify-between",
780
- children: field.label && /* @__PURE__ */ jsx("label", {
1129
+ children: /* @__PURE__ */ jsx("span", {
1130
+ id: labelId,
781
1131
  className: "font-semibold",
782
1132
  children: field.label
783
1133
  })
@@ -795,7 +1145,8 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
795
1145
  }), /* @__PURE__ */ jsx("button", {
796
1146
  type: "button",
797
1147
  onClick: () => remove(index),
798
- className: "absolute top-2 right-2 text-red-500 hover:text-red-700 text-sm font-medium",
1148
+ "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",
799
1150
  children: "Remove"
800
1151
  })]
801
1152
  }, item.id)),
@@ -809,7 +1160,7 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
809
1160
  append(defaults);
810
1161
  },
811
1162
  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",
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",
813
1164
  children: "+ Add Item"
814
1165
  })
815
1166
  ]
@@ -821,195 +1172,314 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
821
1172
  /**
822
1173
  * Type-safe field builder helpers for schema-driven forms.
823
1174
  *
824
- * Provides shorthand methods for common field types with sensible defaults,
825
- * reducing boilerplate while maintaining full type safety.
1175
+ * All methods are generic over TFieldValues, defaulting to FieldValues (any string)
1176
+ * when no type argument is provided. Specify the generic to enforce that field
1177
+ * names are valid paths in your form values type.
1178
+ *
1179
+ * For fully-typed schemas where every field name is checked, prefer
1180
+ * `field.for<MyForm>()` which fixes the generic once for the whole schema:
826
1181
  *
827
1182
  * @example
828
1183
  * ```ts
829
- * import { field, section } from '@classytic/formkit';
1184
+ * // Untyped any string accepted (backwards compatible)
1185
+ * field.text("email", "Email")
830
1186
  *
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
- * };
1187
+ * // Per-call generic — name is checked against MyForm
1188
+ * field.text<MyForm>("email", "Email")
1189
+ *
1190
+ * // Typed factory name checked on every call without repeating the generic
1191
+ * const f = field.for<MyForm>()
1192
+ * f.text("email", "Email") //
1193
+ * f.text("typo", "Email") // ✗ TypeScript error
843
1194
  * ```
844
1195
  */
845
1196
  const field = {
846
- text: (name, label, props = {}) => ({
1197
+ /** Text input field. */
1198
+ text: (name, label, props) => ({
847
1199
  type: "text",
848
1200
  name,
849
1201
  label,
850
- ...props
1202
+ ...props ?? {}
851
1203
  }),
852
- email: (name, label, props = {}) => ({
1204
+ /** Email input field with default placeholder. */
1205
+ email: (name, label, props) => ({
853
1206
  type: "email",
854
1207
  name,
855
1208
  label,
856
1209
  placeholder: "example@email.com",
857
- ...props
1210
+ ...props ?? {}
858
1211
  }),
859
- url: (name, label, props = {}) => ({
1212
+ /** URL input field with default placeholder. */
1213
+ url: (name, label, props) => ({
860
1214
  type: "url",
861
1215
  name,
862
1216
  label,
863
1217
  placeholder: "https://example.com",
864
- ...props
1218
+ ...props ?? {}
865
1219
  }),
866
- tel: (name, label, props = {}) => ({
1220
+ /** Phone/tel input field with default placeholder. */
1221
+ tel: (name, label, props) => ({
867
1222
  type: "tel",
868
1223
  name,
869
1224
  label,
870
1225
  placeholder: "+1 (555) 000-0000",
871
- ...props
1226
+ ...props ?? {}
872
1227
  }),
873
- password: (name, label, props = {}) => ({
1228
+ /** Password input field. */
1229
+ password: (name, label, props) => ({
874
1230
  type: "password",
875
1231
  name,
876
1232
  label,
877
- ...props
1233
+ ...props ?? {}
878
1234
  }),
879
- number: (name, label, props = {}) => ({
1235
+ /** Number input field with min: 0 default (overrideable via props). */
1236
+ number: (name, label, props) => ({
880
1237
  type: "number",
881
1238
  name,
882
1239
  label,
883
1240
  min: 0,
884
- ...props
1241
+ ...props ?? {}
885
1242
  }),
886
- textarea: (name, label, props = {}) => ({
1243
+ /** Textarea field with default 3 rows. */
1244
+ textarea: (name, label, props) => ({
887
1245
  type: "textarea",
888
1246
  name,
889
1247
  label,
890
1248
  rows: 3,
891
- ...props
1249
+ ...props ?? {}
892
1250
  }),
893
- select: (name, label, options, props = {}) => ({
1251
+ /** Select dropdown field. */
1252
+ select: (name, label, options, props) => ({
894
1253
  type: "select",
895
1254
  name,
896
1255
  label,
897
1256
  options,
898
- ...props
1257
+ ...props ?? {}
899
1258
  }),
900
- combobox: (name, label, options, props = {}) => ({
1259
+ /** Searchable combobox field. */
1260
+ combobox: (name, label, options, props) => ({
901
1261
  type: "combobox",
902
1262
  name,
903
1263
  label,
904
1264
  options,
905
- ...props
1265
+ ...props ?? {}
906
1266
  }),
907
- multiselect: (name, label, options, props = {}) => ({
1267
+ /** Multi-select field. */
1268
+ multiselect: (name, label, options, props) => ({
908
1269
  type: "multiselect",
909
1270
  name,
910
1271
  label,
911
1272
  options,
912
1273
  placeholder: "Select options...",
913
- ...props
1274
+ ...props ?? {}
914
1275
  }),
915
- dependentSelect: (name, label, props = {}) => ({
1276
+ /** Dependent select field that reacts to parent field changes. */
1277
+ dependentSelect: (name, label, props) => ({
916
1278
  type: "dependentSelect",
917
1279
  name,
918
1280
  label,
919
- ...props
1281
+ ...props ?? {}
920
1282
  }),
921
- switch: (name, label, props = {}) => ({
1283
+ /** Switch/toggle field. */
1284
+ switch: (name, label, props) => ({
922
1285
  type: "switch",
923
1286
  name,
924
1287
  label,
925
- ...props
1288
+ ...props ?? {}
926
1289
  }),
927
- boolean: (name, label, props = {}) => ({
1290
+ /** Boolean field (alias for switch). */
1291
+ boolean: (name, label, props) => ({
928
1292
  type: "switch",
929
1293
  name,
930
1294
  label,
931
- ...props
1295
+ ...props ?? {}
932
1296
  }),
933
- checkbox: (name, label, props = {}) => ({
1297
+ /** Checkbox field. */
1298
+ checkbox: (name, label, props) => ({
934
1299
  type: "checkbox",
935
1300
  name,
936
1301
  label,
937
- ...props
1302
+ ...props ?? {}
938
1303
  }),
939
- radio: (name, label, options, props = {}) => ({
1304
+ /** Radio button group field. */
1305
+ radio: (name, label, options, props) => ({
940
1306
  type: "radio",
941
1307
  name,
942
1308
  label,
943
1309
  options,
944
- ...props
1310
+ ...props ?? {}
945
1311
  }),
946
- date: (name, label, props = {}) => ({
1312
+ /** Date picker field. */
1313
+ date: (name, label, props) => ({
947
1314
  type: "date",
948
1315
  name,
949
1316
  label,
950
- ...props
1317
+ ...props ?? {}
951
1318
  }),
952
- tags: (name, label, props = {}) => ({
1319
+ /** Tag input field. */
1320
+ tags: (name, label, props) => ({
953
1321
  type: "tags",
954
1322
  name,
955
1323
  label,
956
1324
  placeholder: "Add tags...",
957
- ...props
1325
+ ...props ?? {}
958
1326
  }),
959
- slug: (name, label, props = {}) => ({
1327
+ /** Slug field. */
1328
+ slug: (name, label, props) => ({
960
1329
  type: "slug",
961
1330
  name,
962
1331
  label,
963
1332
  placeholder: "my-page-slug",
964
- ...props
1333
+ ...props ?? {}
965
1334
  }),
966
- file: (name, label, props = {}) => ({
1335
+ /** File upload field. */
1336
+ file: (name, label, props) => ({
967
1337
  type: "file",
968
1338
  name,
969
1339
  label,
970
- ...props
1340
+ ...props ?? {}
971
1341
  }),
972
- hidden: (name, props = {}) => ({
1342
+ /** Hidden field (no UI). */
1343
+ hidden: (name, props) => ({
973
1344
  type: "hidden",
974
1345
  name,
975
- ...props
1346
+ ...props ?? {}
976
1347
  }),
977
- group: (name, label, itemFields, props = {}) => ({
1348
+ /**
1349
+ * Group field for nested objects.
1350
+ * Renders itemFields as a sub-grid. Child names are relative (e.g. "street"),
1351
+ * FormGenerator prefixes them with the group name at render time.
1352
+ *
1353
+ * @example
1354
+ * ```ts
1355
+ * field.group("address", "Address", [
1356
+ * field.text("street", "Street"),
1357
+ * field.text("city", "City"),
1358
+ * ], { cols: 2 })
1359
+ * ```
1360
+ */
1361
+ group: (name, label, itemFields, props) => ({
978
1362
  type: "group",
979
1363
  name,
980
1364
  label,
981
1365
  itemFields,
982
- ...props
1366
+ ...props ?? {}
983
1367
  }),
984
- array: (name, label, itemFields, props = {}) => ({
1368
+ /**
1369
+ * Array/repeatable field backed by react-hook-form's useFieldArray.
1370
+ *
1371
+ * @example
1372
+ * ```ts
1373
+ * field.array("contacts", "Contacts", [
1374
+ * field.text("name", "Name"),
1375
+ * field.email("email", "Email"),
1376
+ * ])
1377
+ * ```
1378
+ */
1379
+ array: (name, label, itemFields, props) => ({
985
1380
  type: "array",
986
1381
  name,
987
1382
  label,
988
1383
  itemFields,
989
- ...props
1384
+ ...props ?? {}
990
1385
  }),
991
- custom: (name, label, render, props = {}) => ({
1386
+ /**
1387
+ * Custom field with a render function.
1388
+ * Bypasses the component registry — full control over rendering.
1389
+ *
1390
+ * The render callback receives the complete `FieldComponentProps` including
1391
+ * `fieldId`, `errorId`, `shouldShowError`, `error`, `rules`, and `control`.
1392
+ *
1393
+ * Use `shouldShowError` (not `!!error`) to drive `aria-invalid` and error
1394
+ * visibility so timing mirrors the CSS `:user-invalid` pseudo-class.
1395
+ *
1396
+ * @example
1397
+ * ```tsx
1398
+ * field.custom("skills", "Skills", ({ control, shouldShowError, errorId, error, fieldId }) => (
1399
+ * <div>
1400
+ * <SkillSelector
1401
+ * id={fieldId}
1402
+ * control={control}
1403
+ * aria-invalid={shouldShowError || undefined}
1404
+ * aria-errormessage={shouldShowError ? errorId : undefined}
1405
+ * />
1406
+ * {shouldShowError && (
1407
+ * <p id={errorId} role="alert" className="text-sm text-destructive">
1408
+ * {error?.message}
1409
+ * </p>
1410
+ * )}
1411
+ * </div>
1412
+ * ))
1413
+ * ```
1414
+ */
1415
+ custom: (name, label, render, props) => ({
992
1416
  type: "custom",
993
1417
  name,
994
1418
  label,
995
1419
  render,
996
- ...props
1420
+ ...props ?? {}
1421
+ }),
1422
+ /**
1423
+ * Returns a typed field builder with `TFieldValues` fixed.
1424
+ * Every field name is validated against `Path<TFieldValues>` at the call site —
1425
+ * no need to repeat the generic on each individual builder call.
1426
+ *
1427
+ * @example
1428
+ * ```ts
1429
+ * interface ContactForm {
1430
+ * firstName: string;
1431
+ * email: string;
1432
+ * address: { street: string; city: string };
1433
+ * }
1434
+ *
1435
+ * const f = field.for<ContactForm>()
1436
+ *
1437
+ * const schema = defineSchema<ContactForm>({
1438
+ * sections: [{
1439
+ * fields: [
1440
+ * f.text("firstName", "First Name"), // ✓
1441
+ * f.email("email", "Email"), // ✓
1442
+ * f.text("typo", "Label"), // ✗ TypeScript error
1443
+ * ],
1444
+ * }],
1445
+ * })
1446
+ * ```
1447
+ */
1448
+ for: () => ({
1449
+ text: (name, label, props) => field.text(name, label, props),
1450
+ email: (name, label, props) => field.email(name, label, props),
1451
+ url: (name, label, props) => field.url(name, label, props),
1452
+ tel: (name, label, props) => field.tel(name, label, props),
1453
+ password: (name, label, props) => field.password(name, label, props),
1454
+ number: (name, label, props) => field.number(name, label, props),
1455
+ textarea: (name, label, props) => field.textarea(name, label, props),
1456
+ select: (name, label, options, props) => field.select(name, label, options, props),
1457
+ combobox: (name, label, options, props) => field.combobox(name, label, options, props),
1458
+ multiselect: (name, label, options, props) => field.multiselect(name, label, options, props),
1459
+ dependentSelect: (name, label, props) => field.dependentSelect(name, label, props),
1460
+ switch: (name, label, props) => field.switch(name, label, props),
1461
+ boolean: (name, label, props) => field.boolean(name, label, props),
1462
+ checkbox: (name, label, props) => field.checkbox(name, label, props),
1463
+ radio: (name, label, options, props) => field.radio(name, label, options, props),
1464
+ date: (name, label, props) => field.date(name, label, props),
1465
+ tags: (name, label, props) => field.tags(name, label, props),
1466
+ slug: (name, label, props) => field.slug(name, label, props),
1467
+ file: (name, label, props) => field.file(name, label, props),
1468
+ hidden: (name, props) => field.hidden(name, props),
1469
+ group: (name, label, itemFields, props) => field.group(name, label, itemFields, props),
1470
+ array: (name, label, itemFields, props) => field.array(name, label, itemFields, props),
1471
+ custom: (name, label, render, builderProps) => field.custom(name, label, render, builderProps)
997
1472
  })
998
1473
  };
999
1474
  /**
1000
1475
  * Create a section definition with sensible defaults.
1001
1476
  *
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
1477
  * @example
1008
1478
  * ```ts
1009
1479
  * section("personal", "Personal Info", [
1010
1480
  * field.text("name", "Name", { required: true }),
1011
1481
  * field.email("email", "Email"),
1012
- * ], { cols: 2, variant: "card" })
1482
+ * ], { cols: 2 })
1013
1483
  * ```
1014
1484
  */
1015
1485
  function section(id, title, fields, props = {}) {
@@ -1025,6 +1495,9 @@ function section(id, title, fields, props = {}) {
1025
1495
  /**
1026
1496
  * Create a section without a title (transparent section).
1027
1497
  * Useful for grouping fields without visual separation.
1498
+ *
1499
+ * Accepts `BaseField[]` (no generic) so mixed-type field arrays don't trigger
1500
+ * conflicting type inference across different field name generics.
1028
1501
  */
1029
1502
  function sectionUntitled(fields, props = {}) {
1030
1503
  const { cols = 1, ...rest } = props;
@@ -1060,10 +1533,23 @@ function sectionUntitled(fields, props = {}) {
1060
1533
  function useFormKit(options) {
1061
1534
  const { schema, disabled, variant, className, defaultValues, ...formOptions } = options;
1062
1535
  const schemaDefaults = useMemo(() => extractDefaultValues(schema), [schema]);
1063
- const mergedDefaults = useMemo(() => ({
1064
- ...schemaDefaults,
1065
- ...typeof defaultValues === "object" && defaultValues !== null ? defaultValues : {}
1066
- }), [schemaDefaults, defaultValues]);
1536
+ const mergedDefaults = useMemo(() => {
1537
+ if (typeof defaultValues === "function") {
1538
+ const userFn = defaultValues;
1539
+ const captured = schemaDefaults;
1540
+ return async () => {
1541
+ const userVals = await Promise.resolve(userFn());
1542
+ return {
1543
+ ...captured,
1544
+ ...userVals
1545
+ };
1546
+ };
1547
+ }
1548
+ return {
1549
+ ...schemaDefaults,
1550
+ ...defaultValues != null ? defaultValues : {}
1551
+ };
1552
+ }, [schemaDefaults, defaultValues]);
1067
1553
  const form = useForm({
1068
1554
  ...formOptions,
1069
1555
  defaultValues: mergedDefaults
@@ -1085,4 +1571,4 @@ function useFormKit(options) {
1085
1571
  }
1086
1572
 
1087
1573
  //#endregion
1088
- export { FieldWrapper, FormGenerator, FormSystemProvider, GridRenderer, SectionRenderer, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extractDefaultValues, extractWatchNames, field, section, sectionUntitled, shallowEqual, useFieldComponent, useFormKit, useFormSystem, useLayoutComponent };
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 };