@classytic/formkit 1.3.0 → 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.
@@ -279,7 +287,12 @@ function toRules(condition) {
279
287
  */
280
288
  function evaluateCondition(condition, formValues) {
281
289
  if (!condition) return true;
282
- if (typeof condition === "function") return condition(formValues);
290
+ if (typeof condition === "function") try {
291
+ return condition(formValues);
292
+ } catch (err) {
293
+ console.warn("[FormKit] Condition function threw:", err);
294
+ return false;
295
+ }
283
296
  const { rules, logic } = toRules(condition);
284
297
  const evalFn = (rule) => evaluateRule(rule, formValues);
285
298
  return logic === "or" ? rules.some(evalFn) : rules.every(evalFn);
@@ -314,6 +327,7 @@ function defineSection(section) {
314
327
  /**
315
328
  * Extracts default values from a form schema.
316
329
  * Walks all sections and fields, respecting nameSpace prefixes and group nesting.
330
+ * Array fields default to `[]` when no explicit `defaultValue` is provided.
317
331
  *
318
332
  * @example
319
333
  * ```ts
@@ -327,19 +341,73 @@ function extractDefaultValues(schema) {
327
341
  const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
328
342
  if (!section.fields) continue;
329
343
  for (const field of section.fields) {
330
- 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] = [];
331
347
  if (field.itemFields && field.type !== "array") {
332
- for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${prefix}${field.name}.${sub.name}`] = sub.defaultValue;
348
+ for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${key}.${sub.name}`] = sub.defaultValue;
333
349
  }
334
350
  }
335
351
  }
336
352
  return defaults;
337
353
  }
338
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] = [];
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
+ }
396
+ if (performance.now() >= deadline) {
397
+ await yieldToMain();
398
+ deadline = performance.now() + BUDGET_MS;
399
+ }
400
+ }
401
+ return defaults;
402
+ }
403
+ /**
339
404
  * Generates react-hook-form `RegisterOptions`-compatible validation rules
340
405
  * from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
341
406
  * `maxLength`, `pattern`, and `validate` to RHF rules.
342
407
  *
408
+ * Supports both shorthand scalars and `{ value, message }` objects for all
409
+ * numeric/length rules, and `{ regex, message }` for pattern.
410
+ *
343
411
  * @example
344
412
  * ```tsx
345
413
  * import { buildValidationRules } from '@classytic/formkit';
@@ -352,29 +420,219 @@ function extractDefaultValues(schema) {
352
420
  */
353
421
  function buildValidationRules(field) {
354
422
  const rules = {};
355
- if (field.required) rules.required = `${field.label || field.name} is required`;
356
- if (field.minLength !== void 0) rules.minLength = {
357
- value: field.minLength,
358
- message: `At least ${field.minLength} characters`
423
+ if (field.required) rules.required = {
424
+ value: true,
425
+ message: `${field.label || field.name} is required`
359
426
  };
360
- if (field.maxLength !== void 0) rules.maxLength = {
361
- value: field.maxLength,
362
- message: `At most ${field.maxLength} characters`
363
- };
364
- if (field.min !== void 0) rules.min = {
365
- value: field.min,
366
- message: `Must be at least ${field.min}`
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
432
+ };
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
+ }
467
+ }
468
+ if (field.validate) rules.validate = field.validate;
469
+ return rules;
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
+ })
367
557
  };
368
- if (field.max !== void 0) rules.max = {
369
- value: field.max,
370
- message: `Must be at most ${field.max}`
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)
371
575
  };
372
- if (field.pattern) rules.pattern = {
373
- value: new RegExp(field.pattern),
374
- message: "Invalid format"
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
+ }))
375
593
  };
376
- if (field.validate) rules.validate = field.validate;
377
- return rules;
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
+ });
378
636
  }
379
637
 
380
638
  //#endregion
@@ -415,14 +673,35 @@ function getNestedError(errors, path) {
415
673
  function prefixFields(fields, nameSpace) {
416
674
  return fields.map((f) => ({
417
675
  ...f,
418
- name: `${nameSpace}.${f.name}`,
419
- itemFields: f.itemFields?.map((i) => ({
420
- ...i,
421
- name: `${nameSpace}.${f.name}.${i.name}`
422
- }))
676
+ name: `${nameSpace}.${f.name}`
423
677
  }));
424
678
  }
425
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
+ /**
426
705
  * FormGenerator - Headless Form Generator Component
427
706
  *
428
707
  * Renders a form based on a schema definition, using components registered
@@ -467,14 +746,16 @@ function FormGenerator({ schema, control, disabled = false, variant, className,
467
746
  }, section.id ?? `section-${index}`))
468
747
  });
469
748
  }
470
- /**
471
- * Renders a single section with its fields.
472
- */
473
- function SectionRenderer(props) {
749
+ function SectionRendererImpl(props) {
474
750
  if (props.section.condition) return /* @__PURE__ */ jsx(DynamicSectionRenderer, { ...props });
475
751
  return /* @__PURE__ */ jsx(StaticSectionRenderer, { ...props });
476
752
  }
477
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
+ /**
478
759
  * Section renderer that evaluates conditions reactively.
479
760
  * Scopes useWatch to only the fields referenced in the condition
480
761
  * to avoid re-rendering on every form change.
@@ -502,7 +783,7 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
502
783
  if (section.nameSpace && section.fields) return prefixFields(section.fields, section.nameSpace);
503
784
  return section.fields;
504
785
  }, [section.nameSpace, section.fields]);
505
- return /* @__PURE__ */ jsx(SectionLayout, {
786
+ const sectionNode = /* @__PURE__ */ jsx(SectionLayout, {
506
787
  title: section.title,
507
788
  description: section.description,
508
789
  icon: section.icon,
@@ -510,6 +791,7 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
510
791
  className: section.className,
511
792
  collapsible: section.collapsible,
512
793
  defaultCollapsed: section.defaultCollapsed,
794
+ deferRender: section.deferRender,
513
795
  children: section.render ? section.render({
514
796
  control,
515
797
  disabled,
@@ -523,11 +805,17 @@ function StaticSectionRenderer({ section, control, disabled, variant }) {
523
805
  variant: activeVariant
524
806
  })
525
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;
526
817
  }
527
- /**
528
- * Renders a grid of fields with specified column layout.
529
- */
530
- function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
818
+ function GridRendererImpl({ fields, cols = 1, gap, control, disabled, variant }) {
531
819
  const GridLayout = useLayoutComponent("grid", variant);
532
820
  if (!fields || fields.length === 0) return null;
533
821
  return /* @__PURE__ */ jsx(GridLayout, {
@@ -542,18 +830,77 @@ function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
542
830
  });
543
831
  }
544
832
  /**
545
- * Wraps individual fields.
546
- * If the field requires conditional logic or dynamic options, it uses the Dynamic wrapper.
547
- * Otherwise, it uses the Static wrapper, vastly improving performance by skipping `useWatch`.
833
+ * Memoized grid renderer.
548
834
  */
549
- function FieldWrapper(props) {
835
+ const GridRenderer = memo(GridRendererImpl);
836
+ function FieldWrapperImpl(props) {
550
837
  if (props.field.condition || props.field.loadOptions) return /* @__PURE__ */ jsx(DynamicFieldWrapper, { ...props });
838
+ if (props.field.render) return /* @__PURE__ */ jsx(RenderedFieldWrapper, { ...props });
551
839
  return /* @__PURE__ */ jsx(StaticFieldWrapper, { ...props });
552
840
  }
553
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);
846
+ function RenderedFieldWrapper({ field, control, disabled, variant }) {
847
+ const fieldName = field.name;
848
+ const fieldId = toFieldId(fieldName);
849
+ const errorId = `${fieldId}-error`;
850
+ const activeVariant = field.variant ?? variant;
851
+ const isDisabled = disabled || field.disabled;
852
+ const { errors, dirtyFields, touchedFields, isValidating, isSubmitted } = useFormState({
853
+ control,
854
+ name: fieldName
855
+ });
856
+ const fieldError = getNestedError(errors, fieldName);
857
+ const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
858
+ const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
859
+ const shouldShowError = !!fieldError && (isTouched || isSubmitted);
860
+ const fieldState = useMemo(() => ({
861
+ invalid: !!fieldError,
862
+ isDirty,
863
+ isTouched,
864
+ isValidating,
865
+ isSubmitted,
866
+ error: fieldError
867
+ }), [
868
+ fieldError,
869
+ isDirty,
870
+ isTouched,
871
+ isValidating,
872
+ isSubmitted
873
+ ]);
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
+ })
897
+ })
898
+ });
899
+ }
900
+ /**
554
901
  * Dynamic Field Wrapper
555
902
  * Conditionally calls `useWatch` to trigger re-renders only when form values change.
556
- * Can be optimized further by providing `watchNames` on the field.
903
+ * Uses `useTransition` for async loadOptions to keep the UI responsive.
557
904
  */
558
905
  function DynamicFieldWrapper({ field, control, disabled, variant }) {
559
906
  const ruleWatchNames = useMemo(() => extractWatchNames(field.condition), [field.condition]);
@@ -586,8 +933,10 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
586
933
  }), {});
587
934
  return stableWatched;
588
935
  }, [allWatchNames, stableWatched]);
936
+ const [, startTransition] = useTransition();
937
+ const [isLoading, setIsLoading] = useState(() => !!field.loadOptions);
589
938
  const [options, setOptions] = useState(field.options || []);
590
- const [isLoading, setIsLoading] = useState(false);
939
+ const [srAnnouncement, setSrAnnouncement] = useState("");
591
940
  const timeoutRef = useRef(null);
592
941
  useEffect(() => {
593
942
  if (!field.loadOptions) return;
@@ -596,18 +945,24 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
596
945
  const res = field.loadOptions(watchedValues);
597
946
  if (res instanceof Promise) {
598
947
  setIsLoading(true);
599
- res.then((newOptions) => {
600
- if (isActive) setOptions(newOptions);
601
- }).catch((err) => {
602
- if (isActive) if (field.onLoadError) field.onLoadError(err);
603
- else console.error("[FormKit] loadOptions error:", err);
604
- }).finally(() => {
605
- 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
+ }
606
964
  });
607
- } else {
608
- setOptions(res);
609
- setIsLoading(false);
610
- }
965
+ } else setOptions(res);
611
966
  };
612
967
  if (field.debounceMs && field.debounceMs > 0) {
613
968
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -615,7 +970,10 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
615
970
  } else executeLoad();
616
971
  return () => {
617
972
  isActive = false;
618
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
973
+ if (timeoutRef.current) {
974
+ clearTimeout(timeoutRef.current);
975
+ timeoutRef.current = null;
976
+ }
619
977
  };
620
978
  }, [
621
979
  watchedValues,
@@ -626,68 +984,62 @@ function DynamicFieldWrapper({ field, control, disabled, variant }) {
626
984
  const loadingState = field.loadOptions ? isLoading : void 0;
627
985
  const dynamicField = useMemo(() => ({
628
986
  ...field,
629
- options: field.loadOptions ? options : field.options,
630
- isLoading: loadingState
631
- }), [
632
- field,
633
- options,
634
- loadingState
635
- ]);
987
+ options: field.loadOptions ? options : field.options
988
+ }), [field, options]);
636
989
  if (!evaluateCondition(field.condition, watchedValues)) return null;
637
- 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, {
638
1007
  field: dynamicField,
639
1008
  control,
640
1009
  disabled,
641
1010
  variant,
642
1011
  isLoading: loadingState
643
- });
1012
+ })] });
644
1013
  }
645
- /**
646
- * Static Field Wrapper
647
- * Handles rendering the actual component via the registry, or via a custom static `render`.
648
- * Does not use `useWatch` internally.
649
- */
650
- function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
1014
+ function StaticFieldWrapperImpl({ field, control, disabled, variant, isLoading }) {
651
1015
  const { components } = useFormSystem();
652
1016
  const fieldName = field.name;
653
- const { errors, dirtyFields, touchedFields } = useFormState({
1017
+ const { errors, dirtyFields, touchedFields, isValidating, isSubmitted } = useFormState({
654
1018
  control,
655
1019
  name: fieldName
656
1020
  });
657
1021
  const isDisabled = disabled || field.disabled;
658
1022
  const fieldId = toFieldId(fieldName);
1023
+ const errorId = `${fieldId}-error`;
659
1024
  const fieldError = getNestedError(errors, fieldName);
660
1025
  const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
661
1026
  const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
1027
+ const shouldShowError = !!fieldError && (isTouched || isSubmitted);
662
1028
  const fieldState = useMemo(() => ({
663
1029
  invalid: !!fieldError,
664
1030
  isDirty,
665
1031
  isTouched,
666
- isValidating: false,
1032
+ isValidating,
1033
+ isSubmitted,
667
1034
  error: fieldError
668
1035
  }), [
669
1036
  fieldError,
670
1037
  isDirty,
671
- isTouched
1038
+ isTouched,
1039
+ isValidating,
1040
+ isSubmitted
672
1041
  ]);
673
1042
  const activeVariant = field.variant ?? variant;
674
- if (field.render) return /* @__PURE__ */ jsx("div", {
675
- className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
676
- id: fieldId,
677
- "data-formkit-field": fieldName,
678
- "data-field-type": field.type,
679
- children: field.render({
680
- ...field,
681
- field,
682
- control,
683
- disabled: isDisabled,
684
- variant: activeVariant,
685
- error: fieldError,
686
- fieldState,
687
- fieldId,
688
- isLoading
689
- })
690
- });
691
1043
  if (!Boolean(components[field.type] || activeVariant && components[activeVariant] && typeof components[activeVariant] === "object" && components[activeVariant][field.type]) && field.itemFields && field.itemFields.length > 0) {
692
1044
  if (field.type === "array") return /* @__PURE__ */ jsx(ArrayFieldFallback, {
693
1045
  field,
@@ -695,12 +1047,16 @@ function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
695
1047
  disabled: isDisabled,
696
1048
  variant: activeVariant
697
1049
  });
1050
+ const prefixedGroupFields = field.itemFields.map((f) => ({
1051
+ ...f,
1052
+ name: `${fieldName}.${f.name}`
1053
+ }));
698
1054
  return /* @__PURE__ */ jsx("div", {
699
1055
  className: cn("formkit-field-group", field.fullWidth && "col-span-full", field.className),
700
1056
  "data-formkit-field": fieldName,
701
1057
  "data-field-type": field.type,
702
1058
  children: /* @__PURE__ */ jsx(GridRenderer, {
703
- fields: field.itemFields,
1059
+ fields: prefixedGroupFields,
704
1060
  control,
705
1061
  disabled: isDisabled,
706
1062
  variant: activeVariant
@@ -708,38 +1064,70 @@ function StaticFieldWrapper({ field, control, disabled, variant, isLoading }) {
708
1064
  });
709
1065
  }
710
1066
  const FieldComponent = useFieldComponent(field.type, activeVariant);
711
- if (!FieldComponent) return null;
712
- return /* @__PURE__ */ jsx("div", {
713
- className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
714
- id: fieldId,
715
- "data-formkit-field": fieldName,
716
- "data-field-type": field.type,
717
- children: /* @__PURE__ */ jsx(FieldComponent, {
718
- ...field,
719
- field,
720
- control,
721
- disabled: isDisabled,
722
- variant: activeVariant,
723
- error: fieldError,
724
- fieldState,
725
- fieldId,
726
- isLoading
1067
+ if (!FieldComponent && !field.render) return null;
1068
+ const fieldProps = useMemo(() => ({
1069
+ ...field,
1070
+ field,
1071
+ control,
1072
+ disabled: isDisabled,
1073
+ variant: activeVariant,
1074
+ error: fieldError,
1075
+ fieldState,
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,
1091
+ isLoading
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 })
727
1104
  })
728
1105
  });
729
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);
730
1113
  function ArrayFieldFallback({ field, control, disabled, variant }) {
731
1114
  const { fields, append, remove } = useFieldArray({
732
1115
  control,
733
1116
  name: field.name
734
1117
  });
1118
+ const fieldId = toFieldId(field.name);
1119
+ const labelId = field.label ? `${fieldId}-label` : void 0;
735
1120
  return /* @__PURE__ */ jsxs("div", {
1121
+ role: "group",
1122
+ "aria-labelledby": labelId,
736
1123
  className: cn("formkit-field-array flex flex-col gap-4", field.fullWidth && "col-span-full", field.className),
737
1124
  "data-formkit-field": field.name,
738
1125
  "data-field-type": "array",
739
1126
  children: [
740
- /* @__PURE__ */ jsx("div", {
1127
+ field.label && /* @__PURE__ */ jsx("div", {
741
1128
  className: "flex items-center justify-between",
742
- children: field.label && /* @__PURE__ */ jsx("label", {
1129
+ children: /* @__PURE__ */ jsx("span", {
1130
+ id: labelId,
743
1131
  className: "font-semibold",
744
1132
  children: field.label
745
1133
  })
@@ -757,15 +1145,22 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
757
1145
  }), /* @__PURE__ */ jsx("button", {
758
1146
  type: "button",
759
1147
  onClick: () => remove(index),
760
- 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",
761
1150
  children: "Remove"
762
1151
  })]
763
1152
  }, item.id)),
764
1153
  /* @__PURE__ */ jsx("button", {
765
1154
  type: "button",
766
- onClick: () => append({}),
1155
+ 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);
1161
+ },
767
1162
  disabled,
768
- 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",
769
1164
  children: "+ Add Item"
770
1165
  })
771
1166
  ]
@@ -777,226 +1172,314 @@ function ArrayFieldFallback({ field, control, disabled, variant }) {
777
1172
  /**
778
1173
  * Type-safe field builder helpers for schema-driven forms.
779
1174
  *
780
- * Provides shorthand methods for common field types with sensible defaults,
781
- * 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:
782
1181
  *
783
1182
  * @example
784
1183
  * ```ts
785
- * import { field, section } from '@classytic/formkit';
1184
+ * // Untyped any string accepted (backwards compatible)
1185
+ * field.text("email", "Email")
786
1186
  *
787
- * const schema = {
788
- * sections: [
789
- * section("personal", "Personal Info", [
790
- * field.text("firstName", "First Name", { required: true }),
791
- * field.email("email", "Email"),
792
- * field.select("role", "Role", [
793
- * { label: "Admin", value: "admin" },
794
- * { label: "User", value: "user" },
795
- * ]),
796
- * ], { cols: 2 }),
797
- * ],
798
- * };
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
799
1194
  * ```
800
1195
  */
801
1196
  const field = {
802
- text: (name, label, props = {}) => ({
1197
+ /** Text input field. */
1198
+ text: (name, label, props) => ({
803
1199
  type: "text",
804
1200
  name,
805
1201
  label,
806
- ...props
1202
+ ...props ?? {}
807
1203
  }),
808
- email: (name, label, props = {}) => ({
1204
+ /** Email input field with default placeholder. */
1205
+ email: (name, label, props) => ({
809
1206
  type: "email",
810
1207
  name,
811
1208
  label,
812
1209
  placeholder: "example@email.com",
813
- ...props
1210
+ ...props ?? {}
814
1211
  }),
815
- url: (name, label, props = {}) => ({
1212
+ /** URL input field with default placeholder. */
1213
+ url: (name, label, props) => ({
816
1214
  type: "url",
817
1215
  name,
818
1216
  label,
819
1217
  placeholder: "https://example.com",
820
- ...props
1218
+ ...props ?? {}
821
1219
  }),
822
- tel: (name, label, props = {}) => ({
1220
+ /** Phone/tel input field with default placeholder. */
1221
+ tel: (name, label, props) => ({
823
1222
  type: "tel",
824
1223
  name,
825
1224
  label,
826
1225
  placeholder: "+1 (555) 000-0000",
827
- ...props
1226
+ ...props ?? {}
828
1227
  }),
829
- password: (name, label, props = {}) => ({
1228
+ /** Password input field. */
1229
+ password: (name, label, props) => ({
830
1230
  type: "password",
831
1231
  name,
832
1232
  label,
833
- ...props
1233
+ ...props ?? {}
834
1234
  }),
835
- number: (name, label, props = {}) => ({
1235
+ /** Number input field with min: 0 default (overrideable via props). */
1236
+ number: (name, label, props) => ({
836
1237
  type: "number",
837
1238
  name,
838
1239
  label,
839
1240
  min: 0,
840
- ...props
1241
+ ...props ?? {}
841
1242
  }),
842
- textarea: (name, label, props = {}) => ({
1243
+ /** Textarea field with default 3 rows. */
1244
+ textarea: (name, label, props) => ({
843
1245
  type: "textarea",
844
1246
  name,
845
1247
  label,
846
1248
  rows: 3,
847
- ...props
1249
+ ...props ?? {}
848
1250
  }),
849
- select: (name, label, options, props = {}) => ({
1251
+ /** Select dropdown field. */
1252
+ select: (name, label, options, props) => ({
850
1253
  type: "select",
851
1254
  name,
852
1255
  label,
853
1256
  options,
854
- ...props
1257
+ ...props ?? {}
855
1258
  }),
856
- combobox: (name, label, options, props = {}) => ({
1259
+ /** Searchable combobox field. */
1260
+ combobox: (name, label, options, props) => ({
857
1261
  type: "combobox",
858
1262
  name,
859
1263
  label,
860
1264
  options,
861
- ...props
1265
+ ...props ?? {}
862
1266
  }),
863
- multiselect: (name, label, options, props = {}) => ({
1267
+ /** Multi-select field. */
1268
+ multiselect: (name, label, options, props) => ({
864
1269
  type: "multiselect",
865
1270
  name,
866
1271
  label,
867
1272
  options,
868
1273
  placeholder: "Select options...",
869
- ...props
1274
+ ...props ?? {}
870
1275
  }),
871
- tagChoice: (name, label, options, props = {}) => ({
872
- type: "tagChoice",
873
- name,
874
- label,
875
- options,
876
- ...props
877
- }),
878
- dependentSelect: (name, label, props = {}) => ({
1276
+ /** Dependent select field that reacts to parent field changes. */
1277
+ dependentSelect: (name, label, props) => ({
879
1278
  type: "dependentSelect",
880
1279
  name,
881
1280
  label,
882
- ...props
1281
+ ...props ?? {}
883
1282
  }),
884
- switch: (name, label, props = {}) => ({
1283
+ /** Switch/toggle field. */
1284
+ switch: (name, label, props) => ({
885
1285
  type: "switch",
886
1286
  name,
887
1287
  label,
888
- ...props
1288
+ ...props ?? {}
889
1289
  }),
890
- boolean: (name, label, props = {}) => ({
1290
+ /** Boolean field (alias for switch). */
1291
+ boolean: (name, label, props) => ({
891
1292
  type: "switch",
892
1293
  name,
893
1294
  label,
894
- ...props
1295
+ ...props ?? {}
895
1296
  }),
896
- checkbox: (name, label, props = {}) => ({
1297
+ /** Checkbox field. */
1298
+ checkbox: (name, label, props) => ({
897
1299
  type: "checkbox",
898
1300
  name,
899
1301
  label,
900
- ...props
1302
+ ...props ?? {}
901
1303
  }),
902
- radio: (name, label, options, props = {}) => ({
1304
+ /** Radio button group field. */
1305
+ radio: (name, label, options, props) => ({
903
1306
  type: "radio",
904
1307
  name,
905
1308
  label,
906
1309
  options,
907
- ...props
1310
+ ...props ?? {}
908
1311
  }),
909
- date: (name, label, props = {}) => ({
1312
+ /** Date picker field. */
1313
+ date: (name, label, props) => ({
910
1314
  type: "date",
911
1315
  name,
912
1316
  label,
913
- ...props
1317
+ ...props ?? {}
914
1318
  }),
915
- tags: (name, label, props = {}) => ({
1319
+ /** Tag input field. */
1320
+ tags: (name, label, props) => ({
916
1321
  type: "tags",
917
1322
  name,
918
1323
  label,
919
1324
  placeholder: "Add tags...",
920
- ...props
1325
+ ...props ?? {}
921
1326
  }),
922
- slug: (name, label, props = {}) => ({
1327
+ /** Slug field. */
1328
+ slug: (name, label, props) => ({
923
1329
  type: "slug",
924
1330
  name,
925
1331
  label,
926
1332
  placeholder: "my-page-slug",
927
- ...props
1333
+ ...props ?? {}
928
1334
  }),
929
- file: (name, label, props = {}) => ({
1335
+ /** File upload field. */
1336
+ file: (name, label, props) => ({
930
1337
  type: "file",
931
1338
  name,
932
1339
  label,
933
- ...props
934
- }),
935
- otp: (name, label, props = {}) => ({
936
- type: "otp",
937
- name,
938
- label,
939
- ...props
940
- }),
941
- asyncCombobox: (name, label, props = {}) => ({
942
- type: "asyncCombobox",
943
- name,
944
- label,
945
- ...props
946
- }),
947
- asyncMultiselect: (name, label, props = {}) => ({
948
- type: "asyncMultiselect",
949
- name,
950
- label,
951
- ...props
952
- }),
953
- dateTime: (name, label, props = {}) => ({
954
- type: "dateTime",
955
- name,
956
- label,
957
- ...props
1340
+ ...props ?? {}
958
1341
  }),
959
- hidden: (name, props = {}) => ({
1342
+ /** Hidden field (no UI). */
1343
+ hidden: (name, props) => ({
960
1344
  type: "hidden",
961
1345
  name,
962
- ...props
1346
+ ...props ?? {}
963
1347
  }),
964
- 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) => ({
965
1362
  type: "group",
966
1363
  name,
967
1364
  label,
968
1365
  itemFields,
969
- ...props
1366
+ ...props ?? {}
970
1367
  }),
971
- 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) => ({
972
1380
  type: "array",
973
1381
  name,
974
1382
  label,
975
1383
  itemFields,
976
- ...props
1384
+ ...props ?? {}
977
1385
  }),
978
- 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) => ({
979
1416
  type: "custom",
980
1417
  name,
981
1418
  label,
982
1419
  render,
983
- ...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)
984
1472
  })
985
1473
  };
986
1474
  /**
987
1475
  * Create a section definition with sensible defaults.
988
1476
  *
989
- * @param id - Unique section identifier
990
- * @param title - Section title
991
- * @param fields - Array of field definitions
992
- * @param props - Additional section configuration
993
- *
994
1477
  * @example
995
1478
  * ```ts
996
1479
  * section("personal", "Personal Info", [
997
1480
  * field.text("name", "Name", { required: true }),
998
1481
  * field.email("email", "Email"),
999
- * ], { cols: 2, variant: "card" })
1482
+ * ], { cols: 2 })
1000
1483
  * ```
1001
1484
  */
1002
1485
  function section(id, title, fields, props = {}) {
@@ -1012,6 +1495,9 @@ function section(id, title, fields, props = {}) {
1012
1495
  /**
1013
1496
  * Create a section without a title (transparent section).
1014
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.
1015
1501
  */
1016
1502
  function sectionUntitled(fields, props = {}) {
1017
1503
  const { cols = 1, ...rest } = props;
@@ -1046,13 +1532,25 @@ function sectionUntitled(fields, props = {}) {
1046
1532
  */
1047
1533
  function useFormKit(options) {
1048
1534
  const { schema, disabled, variant, className, defaultValues, ...formOptions } = options;
1049
- const mergedDefaults = {
1050
- ...extractDefaultValues(schema),
1051
- ...typeof defaultValues === "object" && defaultValues !== null ? defaultValues : {}
1052
- };
1535
+ const schemaDefaults = useMemo(() => extractDefaultValues(schema), [schema]);
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]);
1053
1553
  const form = useForm({
1054
- mode: "onBlur",
1055
- reValidateMode: "onChange",
1056
1554
  ...formOptions,
1057
1555
  defaultValues: mergedDefaults
1058
1556
  });
@@ -1073,4 +1571,4 @@ function useFormKit(options) {
1073
1571
  }
1074
1572
 
1075
1573
  //#endregion
1076
- 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 };