@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/CHANGELOG.md +12 -0
- package/README.md +31 -12
- package/dist/index.d.mts +447 -161
- package/dist/index.mjs +665 -179
- package/dist/server.d.mts +393 -150
- package/dist/server.mjs +385 -92
- package/package.json +115 -113
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
|
|
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
|
-
|
|
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[`${
|
|
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 =
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
message: `At least ${field.minLength} characters`
|
|
364
|
-
};
|
|
365
|
-
if (field.maxLength !== void 0) rules.maxLength = {
|
|
366
|
-
value: field.maxLength,
|
|
367
|
-
message: `At most ${field.maxLength} characters`
|
|
368
|
-
};
|
|
369
|
-
if (field.min !== void 0) rules.min = {
|
|
370
|
-
value: field.min,
|
|
371
|
-
message: `Must be at least ${field.min}`
|
|
423
|
+
if (field.required) rules.required = {
|
|
424
|
+
value: true,
|
|
425
|
+
message: `${field.label || field.name} is required`
|
|
372
426
|
};
|
|
373
|
-
if (field.
|
|
374
|
-
value
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
}
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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(
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
*
|
|
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 [
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
825
|
-
*
|
|
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
|
-
*
|
|
1184
|
+
* // Untyped — any string accepted (backwards compatible)
|
|
1185
|
+
* field.text("email", "Email")
|
|
830
1186
|
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1342
|
+
/** Hidden field (no UI). */
|
|
1343
|
+
hidden: (name, props) => ({
|
|
973
1344
|
type: "hidden",
|
|
974
1345
|
name,
|
|
975
|
-
...props
|
|
1346
|
+
...props ?? {}
|
|
976
1347
|
}),
|
|
977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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 };
|