@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/CHANGELOG.md +12 -0
- package/README.md +31 -12
- package/dist/index.d.mts +447 -181
- package/dist/index.mjs +717 -219
- package/dist/server.d.mts +393 -170
- package/dist/server.mjs +392 -121
- 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.
|
|
@@ -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")
|
|
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
|
-
|
|
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[`${
|
|
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 =
|
|
356
|
-
|
|
357
|
-
|
|
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.
|
|
361
|
-
value
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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 [
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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:
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
*
|
|
781
|
-
*
|
|
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
|
-
*
|
|
1184
|
+
* // Untyped — any string accepted (backwards compatible)
|
|
1185
|
+
* field.text("email", "Email")
|
|
786
1186
|
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1342
|
+
/** Hidden field (no UI). */
|
|
1343
|
+
hidden: (name, props) => ({
|
|
960
1344
|
type: "hidden",
|
|
961
1345
|
name,
|
|
962
|
-
...props
|
|
1346
|
+
...props ?? {}
|
|
963
1347
|
}),
|
|
964
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1050
|
-
|
|
1051
|
-
|
|
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 };
|