@classytic/formkit 1.0.3 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,1044 @@
1
+ "use client";
2
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useFieldArray, useForm, useFormContext, useFormState, useWatch } from "react-hook-form";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { clsx } from "clsx";
6
+ import { twMerge } from "tailwind-merge";
7
+
8
+ //#region src/FormSystemContext.tsx
9
+ const FormSystemContext = createContext(null);
10
+ FormSystemContext.displayName = "FormSystemContext";
11
+ /**
12
+ * FormSystemProvider
13
+ *
14
+ * Root provider that enables the form system. Provides component and layout
15
+ * registries to FormGenerator and its descendants.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * import { FormSystemProvider } from '@classytic/formkit';
20
+ *
21
+ * const components = {
22
+ * text: TextInput,
23
+ * select: SelectInput,
24
+ * // Variant-specific components
25
+ * compact: {
26
+ * text: CompactTextInput,
27
+ * },
28
+ * };
29
+ *
30
+ * const layouts = {
31
+ * section: SectionLayout,
32
+ * grid: GridLayout,
33
+ * };
34
+ *
35
+ * function App() {
36
+ * return (
37
+ * <FormSystemProvider components={components} layouts={layouts}>
38
+ * <YourFormComponent />
39
+ * </FormSystemProvider>
40
+ * );
41
+ * }
42
+ * ```
43
+ */
44
+ function FormSystemProvider({ components, layouts, children }) {
45
+ return /* @__PURE__ */ jsx(FormSystemContext, {
46
+ value: useMemo(() => ({
47
+ components: components ?? {},
48
+ layouts: layouts ?? {}
49
+ }), [components, layouts]),
50
+ children
51
+ });
52
+ }
53
+ /**
54
+ * Hook to access the form system context.
55
+ *
56
+ * @throws {Error} If used outside FormSystemProvider
57
+ * @returns Form system context value
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * const { components, layouts } = useFormSystem();
62
+ * ```
63
+ */
64
+ function useFormSystem() {
65
+ const context = useContext(FormSystemContext);
66
+ if (!context) throw new Error("[FormKit] useFormSystem must be used within a FormSystemProvider. Make sure to wrap your form components with <FormSystemProvider>.");
67
+ return context;
68
+ }
69
+ /**
70
+ * Hook to get a field component by type and optional variant.
71
+ *
72
+ * Resolution order:
73
+ * 1. Variant-specific component: `components[variant][type]`
74
+ * 2. Type-specific component: `components[type]`
75
+ * 3. Default component: `components["default"]`
76
+ * 4. Text fallback: `components["text"]`
77
+ *
78
+ * @param type - Field type identifier
79
+ * @param variant - Optional variant name
80
+ * @returns Field component or fallback
81
+ *
82
+ * @internal
83
+ */
84
+ function useFieldComponent(type, variant) {
85
+ const { components } = useFormSystem();
86
+ if (variant && typeof components[variant] === "object" && components[variant] !== null) {
87
+ const variantComponent = components[variant][type];
88
+ if (variantComponent) return variantComponent;
89
+ }
90
+ const typeComponent = components[type];
91
+ if (typeComponent && typeof typeComponent === "function") return typeComponent;
92
+ const defaultComponent = components["default"];
93
+ if (defaultComponent && typeof defaultComponent === "function") return defaultComponent;
94
+ const textComponent = components["text"];
95
+ if (textComponent && typeof textComponent === "function") return textComponent;
96
+ if (process.env.NODE_ENV !== "production") {
97
+ console.warn(`[FormKit] No component found for type "${type}"${variant ? ` (variant: "${variant}")` : ""}. Register a component for this type in your FormSystemProvider.`);
98
+ return MissingFieldComponent;
99
+ }
100
+ return NullComponent;
101
+ }
102
+ /**
103
+ * Hook to get a layout component by type and optional variant.
104
+ *
105
+ * Resolution order:
106
+ * 1. Variant-specific layout: `layouts[variant][type]`
107
+ * 2. Type-specific layout: `layouts[type]`
108
+ * 3. Default layout: `layouts["default"]`
109
+ * 4. Built-in default layout
110
+ *
111
+ * @param type - Layout type identifier
112
+ * @param variant - Optional variant name
113
+ * @returns Layout component or fallback
114
+ *
115
+ * @internal
116
+ */
117
+ function useLayoutComponent(type, variant) {
118
+ const { layouts } = useFormSystem();
119
+ if (variant && typeof layouts[variant] === "object" && layouts[variant] !== null) {
120
+ const variantLayout = layouts[variant][type];
121
+ if (variantLayout) return variantLayout;
122
+ }
123
+ const typeLayout = layouts[type];
124
+ if (typeLayout && typeof typeLayout === "function") return typeLayout;
125
+ const defaultLayout = layouts["default"];
126
+ if (defaultLayout && typeof defaultLayout === "function") return defaultLayout;
127
+ return DefaultLayout;
128
+ }
129
+ /**
130
+ * Default layout component - simple div wrapper.
131
+ * Used when no layout is registered.
132
+ */
133
+ function DefaultLayout({ children, className }) {
134
+ return /* @__PURE__ */ jsx("div", {
135
+ className,
136
+ children
137
+ });
138
+ }
139
+ /**
140
+ * Null component for production fallback.
141
+ */
142
+ function NullComponent() {
143
+ return null;
144
+ }
145
+ /**
146
+ * Development placeholder for missing field components.
147
+ */
148
+ function MissingFieldComponent({ field }) {
149
+ return /* @__PURE__ */ jsxs("div", {
150
+ style: {
151
+ color: "#dc2626",
152
+ padding: "8px 12px",
153
+ border: "1px dashed #dc2626",
154
+ borderRadius: "4px",
155
+ fontSize: "12px",
156
+ fontFamily: "monospace",
157
+ backgroundColor: "#fef2f2"
158
+ },
159
+ children: [
160
+ "Missing component: ",
161
+ /* @__PURE__ */ jsx("strong", { children: field.type }),
162
+ " (field: ",
163
+ field.name,
164
+ ")"
165
+ ]
166
+ });
167
+ }
168
+
169
+ //#endregion
170
+ //#region src/utils.ts
171
+ /**
172
+ * Utility function to merge CSS classes with Tailwind CSS conflict resolution.
173
+ *
174
+ * Combines `clsx` for conditional class handling with `tailwind-merge`
175
+ * for proper Tailwind CSS class conflict resolution.
176
+ *
177
+ * @param inputs - Class values to merge (strings, arrays, objects, or conditionals)
178
+ * @returns Merged and deduplicated class string
179
+ *
180
+ * @example
181
+ * ```tsx
182
+ * // Basic usage
183
+ * cn("px-2 py-1", "px-4") // "py-1 px-4"
184
+ *
185
+ * // Conditional classes
186
+ * cn("base", isActive && "active", { "disabled": isDisabled })
187
+ *
188
+ * // Arrays
189
+ * cn(["flex", "items-center"], "gap-2")
190
+ * ```
191
+ */
192
+ function cn(...inputs) {
193
+ return twMerge(clsx(inputs));
194
+ }
195
+ /**
196
+ * Shallow equality check for arrays and primitives.
197
+ * Used to stabilize useWatch output without JSON.stringify overhead.
198
+ */
199
+ function shallowEqual(a, b) {
200
+ if (Object.is(a, b)) return true;
201
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
202
+ if (Array.isArray(a) && Array.isArray(b)) {
203
+ if (a.length !== b.length) return false;
204
+ for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false;
205
+ return true;
206
+ }
207
+ const keysA = Object.keys(a);
208
+ const keysB = Object.keys(b);
209
+ if (keysA.length !== keysB.length) return false;
210
+ for (const key of keysA) if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) return false;
211
+ return true;
212
+ }
213
+
214
+ //#endregion
215
+ //#region src/schema.ts
216
+ /**
217
+ * Resolves a dot-notation path against an object.
218
+ * Handles array indices: "items.0.name" resolves through arrays correctly.
219
+ */
220
+ function getNestedValue$1(obj, path) {
221
+ const parts = path.split(".");
222
+ let current = obj;
223
+ for (const part of parts) {
224
+ if (current == null || typeof current !== "object") return void 0;
225
+ if (Array.isArray(current)) {
226
+ const index = Number(part);
227
+ if (Number.isNaN(index)) return void 0;
228
+ current = current[index];
229
+ } else current = current[part];
230
+ }
231
+ return current;
232
+ }
233
+ /**
234
+ * Evaluates a single condition rule against form values.
235
+ * Supports both flat dotted keys ("address.city" as literal key) and
236
+ * nested object resolution (values.address.city). Flat key takes priority
237
+ * because DynamicFieldWrapper reconstructs watched values as flat keys.
238
+ */
239
+ function evaluateRule(rule, formValues) {
240
+ const watchPath = rule.watch;
241
+ const obj = formValues;
242
+ const value = watchPath in obj ? obj[watchPath] : getNestedValue$1(obj, watchPath);
243
+ switch (rule.operator) {
244
+ case "===": return value === rule.value;
245
+ case "!==": return value !== rule.value;
246
+ case "in": return Array.isArray(rule.value) && rule.value.includes(value);
247
+ case "not-in": return Array.isArray(rule.value) && !rule.value.includes(value);
248
+ case "truthy": return Boolean(value);
249
+ case "falsy": return !value;
250
+ default: return false;
251
+ }
252
+ }
253
+ /**
254
+ * Type guard: checks if a condition is a ConditionConfig (has `rules` array).
255
+ */
256
+ function isConditionConfig(condition) {
257
+ return typeof condition === "object" && !Array.isArray(condition) && "rules" in condition;
258
+ }
259
+ /**
260
+ * Extracts the rules array from any non-function condition shape.
261
+ */
262
+ function toRules(condition) {
263
+ if (isConditionConfig(condition)) return {
264
+ rules: condition.rules,
265
+ logic: condition.logic ?? "and"
266
+ };
267
+ return {
268
+ rules: Array.isArray(condition) ? condition : [condition],
269
+ logic: "and"
270
+ };
271
+ }
272
+ /**
273
+ * Evaluates a conditional rule, array of rules, or a ConditionConfig against form values.
274
+ * Supports AND (default) and OR logic via ConditionConfig.
275
+ *
276
+ * @param condition - The condition function, rule(s), or config
277
+ * @param formValues - The form values to evaluate against
278
+ * @returns boolean indicating if condition matches
279
+ */
280
+ function evaluateCondition(condition, formValues) {
281
+ if (!condition) return true;
282
+ if (typeof condition === "function") return condition(formValues);
283
+ const { rules, logic } = toRules(condition);
284
+ const evalFn = (rule) => evaluateRule(rule, formValues);
285
+ return logic === "or" ? rules.some(evalFn) : rules.every(evalFn);
286
+ }
287
+ /**
288
+ * Extracts all watch names from a condition to optimize `useWatch`.
289
+ * Handles single rules, arrays, and ConditionConfig objects.
290
+ */
291
+ function extractWatchNames(condition) {
292
+ if (!condition || typeof condition === "function") return [];
293
+ const { rules } = toRules(condition);
294
+ return rules.map((r) => r.watch);
295
+ }
296
+ /**
297
+ * Strictly types a comprehensive form schema, granting exact intellisense bounds across conditions and nested watches.
298
+ */
299
+ function defineSchema(schema) {
300
+ return schema;
301
+ }
302
+ /**
303
+ * Standard utility to strictly type a standalone field out-of-bounds, useful for externalizing massive schema structures.
304
+ */
305
+ function defineField(field) {
306
+ return field;
307
+ }
308
+ /**
309
+ * Standard utility to strictly type a standalone logic section layout block.
310
+ */
311
+ function defineSection(section) {
312
+ return section;
313
+ }
314
+ /**
315
+ * Extracts default values from a form schema.
316
+ * Walks all sections and fields, respecting nameSpace prefixes and group nesting.
317
+ *
318
+ * @example
319
+ * ```ts
320
+ * const defaults = extractDefaultValues(schema);
321
+ * const form = useForm({ defaultValues: defaults });
322
+ * ```
323
+ */
324
+ function extractDefaultValues(schema) {
325
+ const defaults = {};
326
+ for (const section of schema.sections) {
327
+ const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
328
+ if (!section.fields) continue;
329
+ for (const field of section.fields) {
330
+ if (field.defaultValue !== void 0) defaults[`${prefix}${field.name}`] = field.defaultValue;
331
+ if (field.itemFields && field.type !== "array") {
332
+ for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${prefix}${field.name}.${sub.name}`] = sub.defaultValue;
333
+ }
334
+ }
335
+ }
336
+ return defaults;
337
+ }
338
+ /**
339
+ * Generates react-hook-form `RegisterOptions`-compatible validation rules
340
+ * from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
341
+ * `maxLength`, `pattern`, and `validate` to RHF rules.
342
+ *
343
+ * @example
344
+ * ```tsx
345
+ * import { buildValidationRules } from '@classytic/formkit';
346
+ *
347
+ * function FormInput({ field, control }: FieldComponentProps) {
348
+ * const rules = buildValidationRules(field);
349
+ * return <Controller name={field.name} control={control} rules={rules} render={...} />;
350
+ * }
351
+ * ```
352
+ */
353
+ function buildValidationRules(field) {
354
+ const rules = {};
355
+ if (field.required) rules.required = `${field.label || field.name} is required`;
356
+ if (field.minLength !== void 0) rules.minLength = {
357
+ value: field.minLength,
358
+ message: `At least ${field.minLength} characters`
359
+ };
360
+ if (field.maxLength !== void 0) rules.maxLength = {
361
+ value: field.maxLength,
362
+ message: `At most ${field.maxLength} characters`
363
+ };
364
+ if (field.min !== void 0) rules.min = {
365
+ value: field.min,
366
+ message: `Must be at least ${field.min}`
367
+ };
368
+ if (field.max !== void 0) rules.max = {
369
+ value: field.max,
370
+ message: `Must be at most ${field.max}`
371
+ };
372
+ if (field.pattern) rules.pattern = {
373
+ value: new RegExp(field.pattern),
374
+ message: "Invalid format"
375
+ };
376
+ if (field.validate) rules.validate = field.validate;
377
+ return rules;
378
+ }
379
+
380
+ //#endregion
381
+ //#region src/FormGenerator.tsx
382
+ /** Generate a deterministic field ID for accessibility label-input association. */
383
+ function toFieldId(name) {
384
+ return `formkit-field-${name.replace(/[.[\]]/g, "-")}`;
385
+ }
386
+ /**
387
+ * Get nested value from an object using dot-notation path.
388
+ * Handles array indices: "items.0.name" resolves through arrays correctly.
389
+ */
390
+ function getNestedValue(obj, path) {
391
+ const parts = path.split(".");
392
+ let current = obj;
393
+ for (const part of parts) {
394
+ if (current == null || typeof current !== "object") return void 0;
395
+ if (Array.isArray(current)) {
396
+ const index = Number(part);
397
+ if (Number.isNaN(index)) return void 0;
398
+ current = current[index];
399
+ } else current = current[part];
400
+ }
401
+ return current;
402
+ }
403
+ /**
404
+ * Get nested error from react-hook-form errors object.
405
+ * Supports dot-notation paths like "address.street" and array paths like "items.0.name".
406
+ */
407
+ function getNestedError(errors, path) {
408
+ const result = getNestedValue(errors, path);
409
+ if (result && typeof result === "object" && "message" in result) return result;
410
+ }
411
+ /**
412
+ * Prefix field names with a namespace.
413
+ * Memoization-friendly: returns the same shape for stable inputs.
414
+ */
415
+ function prefixFields(fields, nameSpace) {
416
+ return fields.map((f) => ({
417
+ ...f,
418
+ name: `${nameSpace}.${f.name}`,
419
+ itemFields: f.itemFields?.map((i) => ({
420
+ ...i,
421
+ name: `${nameSpace}.${f.name}.${i.name}`
422
+ }))
423
+ }));
424
+ }
425
+ /**
426
+ * FormGenerator - Headless Form Generator Component
427
+ *
428
+ * Renders a form based on a schema definition, using components registered
429
+ * via FormSystemProvider. Supports conditional fields, dynamic layouts,
430
+ * and component variants.
431
+ *
432
+ * @template TFieldValues - Form field values type for type safety
433
+ *
434
+ * @example
435
+ * ```tsx
436
+ * import { useFormKit, FormGenerator } from '@classytic/formkit';
437
+ *
438
+ * const { handleSubmit, generatorProps } = useFormKit({
439
+ * schema: formSchema,
440
+ * resolver: zodResolver(validationSchema),
441
+ * });
442
+ *
443
+ * return (
444
+ * <form onSubmit={handleSubmit(onSubmit)}>
445
+ * <FormGenerator {...generatorProps} />
446
+ * </form>
447
+ * );
448
+ * ```
449
+ */
450
+ function FormGenerator({ schema, control, disabled = false, variant, className, ref }) {
451
+ const formContext = useFormContext();
452
+ const activeControl = control ?? formContext?.control;
453
+ if (!activeControl) {
454
+ console.warn("[FormKit] FormGenerator requires a `control` prop or to be wrapped in a <FormProvider>.");
455
+ return null;
456
+ }
457
+ if (!schema?.sections || schema.sections.length === 0) return null;
458
+ return /* @__PURE__ */ jsx("div", {
459
+ ref,
460
+ className: cn("formkit-root", variant && `formkit-variant-${variant}`, className),
461
+ "data-formkit-root": "",
462
+ children: schema.sections.map((section, index) => /* @__PURE__ */ jsx(SectionRenderer, {
463
+ section,
464
+ control: activeControl,
465
+ disabled,
466
+ variant
467
+ }, section.id ?? `section-${index}`))
468
+ });
469
+ }
470
+ /**
471
+ * Renders a single section with its fields.
472
+ */
473
+ function SectionRenderer(props) {
474
+ if (props.section.condition) return /* @__PURE__ */ jsx(DynamicSectionRenderer, { ...props });
475
+ return /* @__PURE__ */ jsx(StaticSectionRenderer, { ...props });
476
+ }
477
+ /**
478
+ * Section renderer that evaluates conditions reactively.
479
+ * Scopes useWatch to only the fields referenced in the condition
480
+ * to avoid re-rendering on every form change.
481
+ */
482
+ function DynamicSectionRenderer(props) {
483
+ const conditionWatchNames = useMemo(() => extractWatchNames(props.section.condition), [props.section.condition]);
484
+ const watchedRaw = conditionWatchNames.length > 0 ? useWatch({
485
+ control: props.control,
486
+ name: conditionWatchNames
487
+ }) : useWatch({ control: props.control });
488
+ const sectionValues = useMemo(() => {
489
+ if (conditionWatchNames.length > 0 && Array.isArray(watchedRaw)) return conditionWatchNames.reduce((acc, name, i) => ({
490
+ ...acc,
491
+ [name]: watchedRaw[i]
492
+ }), {});
493
+ return watchedRaw;
494
+ }, [conditionWatchNames, watchedRaw]);
495
+ if (!evaluateCondition(props.section.condition, sectionValues)) return null;
496
+ return /* @__PURE__ */ jsx(StaticSectionRenderer, { ...props });
497
+ }
498
+ function StaticSectionRenderer({ section, control, disabled, variant }) {
499
+ const activeVariant = section.variant ?? variant;
500
+ const SectionLayout = useLayoutComponent("section", activeVariant);
501
+ const resolvedFields = useMemo(() => {
502
+ if (section.nameSpace && section.fields) return prefixFields(section.fields, section.nameSpace);
503
+ return section.fields;
504
+ }, [section.nameSpace, section.fields]);
505
+ return /* @__PURE__ */ jsx(SectionLayout, {
506
+ title: section.title,
507
+ description: section.description,
508
+ icon: section.icon,
509
+ variant: activeVariant,
510
+ className: section.className,
511
+ collapsible: section.collapsible,
512
+ defaultCollapsed: section.defaultCollapsed,
513
+ children: section.render ? section.render({
514
+ control,
515
+ disabled,
516
+ section
517
+ }) : /* @__PURE__ */ jsx(GridRenderer, {
518
+ fields: resolvedFields,
519
+ cols: section.cols,
520
+ gap: section.gap,
521
+ control,
522
+ disabled,
523
+ variant: activeVariant
524
+ })
525
+ });
526
+ }
527
+ /**
528
+ * Renders a grid of fields with specified column layout.
529
+ */
530
+ function GridRenderer({ fields, cols = 1, gap, control, disabled, variant }) {
531
+ const GridLayout = useLayoutComponent("grid", variant);
532
+ if (!fields || fields.length === 0) return null;
533
+ return /* @__PURE__ */ jsx(GridLayout, {
534
+ cols,
535
+ gap,
536
+ children: fields.map((field, index) => /* @__PURE__ */ jsx(FieldWrapper, {
537
+ field,
538
+ control,
539
+ disabled,
540
+ variant
541
+ }, field.name || `field-${index}`))
542
+ });
543
+ }
544
+ /**
545
+ * Wraps individual fields.
546
+ * If the field requires conditional logic or dynamic options, it uses the Dynamic wrapper.
547
+ * Otherwise, it uses the Static wrapper, vastly improving performance by skipping `useWatch`.
548
+ */
549
+ function FieldWrapper(props) {
550
+ if (props.field.condition || props.field.loadOptions) return /* @__PURE__ */ jsx(DynamicFieldWrapper, { ...props });
551
+ return /* @__PURE__ */ jsx(StaticFieldWrapper, { ...props });
552
+ }
553
+ /**
554
+ * Dynamic Field Wrapper
555
+ * Conditionally calls `useWatch` to trigger re-renders only when form values change.
556
+ * Can be optimized further by providing `watchNames` on the field.
557
+ */
558
+ function DynamicFieldWrapper({ field, control, disabled, variant }) {
559
+ const ruleWatchNames = useMemo(() => extractWatchNames(field.condition), [field.condition]);
560
+ const explicitWatchNames = useMemo(() => {
561
+ if (Array.isArray(field.watchNames)) return field.watchNames;
562
+ if (field.watchNames) return [field.watchNames];
563
+ return [];
564
+ }, [field.watchNames]);
565
+ const allWatchNamesRef = useRef([]);
566
+ const allWatchNames = useMemo(() => {
567
+ const next = Array.from(new Set([...explicitWatchNames, ...ruleWatchNames]));
568
+ if (next.length === allWatchNamesRef.current.length && next.every((n, i) => n === allWatchNamesRef.current[i])) return allWatchNamesRef.current;
569
+ allWatchNamesRef.current = next;
570
+ return next;
571
+ }, [explicitWatchNames, ruleWatchNames]);
572
+ const watchedRaw = allWatchNames.length > 0 ? useWatch({
573
+ control,
574
+ name: allWatchNames
575
+ }) : useWatch({ control });
576
+ const prevWatchedRef = useRef(watchedRaw);
577
+ const stableWatched = useMemo(() => {
578
+ if (shallowEqual(prevWatchedRef.current, watchedRaw)) return prevWatchedRef.current;
579
+ prevWatchedRef.current = watchedRaw;
580
+ return watchedRaw;
581
+ }, [watchedRaw]);
582
+ const watchedValues = useMemo(() => {
583
+ if (allWatchNames.length > 0 && Array.isArray(stableWatched)) return allWatchNames.reduce((acc, name, i) => ({
584
+ ...acc,
585
+ [name]: stableWatched[i]
586
+ }), {});
587
+ return stableWatched;
588
+ }, [allWatchNames, stableWatched]);
589
+ const [options, setOptions] = useState(field.options || []);
590
+ const [isLoading, setIsLoading] = useState(false);
591
+ const timeoutRef = useRef(null);
592
+ useEffect(() => {
593
+ if (!field.loadOptions) return;
594
+ let isActive = true;
595
+ const executeLoad = () => {
596
+ const res = field.loadOptions(watchedValues);
597
+ if (res instanceof Promise) {
598
+ setIsLoading(true);
599
+ res.then((newOptions) => {
600
+ if (isActive) setOptions(newOptions);
601
+ }).catch((err) => {
602
+ if (isActive) if (field.onLoadError) field.onLoadError(err);
603
+ else console.error("[FormKit] loadOptions error:", err);
604
+ }).finally(() => {
605
+ if (isActive) setIsLoading(false);
606
+ });
607
+ } else {
608
+ setOptions(res);
609
+ setIsLoading(false);
610
+ }
611
+ };
612
+ if (field.debounceMs && field.debounceMs > 0) {
613
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
614
+ timeoutRef.current = setTimeout(executeLoad, field.debounceMs);
615
+ } else executeLoad();
616
+ return () => {
617
+ isActive = false;
618
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
619
+ };
620
+ }, [
621
+ watchedValues,
622
+ field.loadOptions,
623
+ field.debounceMs,
624
+ field.onLoadError
625
+ ]);
626
+ const loadingState = field.loadOptions ? isLoading : void 0;
627
+ const dynamicField = useMemo(() => ({
628
+ ...field,
629
+ options: field.loadOptions ? options : field.options,
630
+ isLoading: loadingState
631
+ }), [
632
+ field,
633
+ options,
634
+ loadingState
635
+ ]);
636
+ if (!evaluateCondition(field.condition, watchedValues)) return null;
637
+ return /* @__PURE__ */ jsx(StaticFieldWrapper, {
638
+ field: dynamicField,
639
+ control,
640
+ disabled,
641
+ variant,
642
+ isLoading: loadingState
643
+ });
644
+ }
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 }) {
651
+ const { components } = useFormSystem();
652
+ const fieldName = field.name;
653
+ const { errors, dirtyFields, touchedFields } = useFormState({
654
+ control,
655
+ name: fieldName
656
+ });
657
+ const isDisabled = disabled || field.disabled;
658
+ const fieldId = toFieldId(fieldName);
659
+ const fieldError = getNestedError(errors, fieldName);
660
+ const isDirty = Boolean(getNestedValue(dirtyFields, fieldName));
661
+ const isTouched = Boolean(getNestedValue(touchedFields, fieldName));
662
+ const fieldState = useMemo(() => ({
663
+ invalid: !!fieldError,
664
+ isDirty,
665
+ isTouched,
666
+ isValidating: false,
667
+ error: fieldError
668
+ }), [
669
+ fieldError,
670
+ isDirty,
671
+ isTouched
672
+ ]);
673
+ 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
+ if (!Boolean(components[field.type] || activeVariant && components[activeVariant] && typeof components[activeVariant] === "object" && components[activeVariant][field.type]) && field.itemFields && field.itemFields.length > 0) {
692
+ if (field.type === "array") return /* @__PURE__ */ jsx(ArrayFieldFallback, {
693
+ field,
694
+ control,
695
+ disabled: isDisabled,
696
+ variant: activeVariant
697
+ });
698
+ return /* @__PURE__ */ jsx("div", {
699
+ className: cn("formkit-field-group", field.fullWidth && "col-span-full", field.className),
700
+ "data-formkit-field": fieldName,
701
+ "data-field-type": field.type,
702
+ children: /* @__PURE__ */ jsx(GridRenderer, {
703
+ fields: field.itemFields,
704
+ control,
705
+ disabled: isDisabled,
706
+ variant: activeVariant
707
+ })
708
+ });
709
+ }
710
+ const FieldComponent = useFieldComponent(field.type, activeVariant);
711
+ if (!FieldComponent) return null;
712
+ return /* @__PURE__ */ jsx("div", {
713
+ className: cn("formkit-field", field.fullWidth && "col-span-full", field.className),
714
+ id: fieldId,
715
+ "data-formkit-field": fieldName,
716
+ "data-field-type": field.type,
717
+ children: /* @__PURE__ */ jsx(FieldComponent, {
718
+ ...field,
719
+ field,
720
+ control,
721
+ disabled: isDisabled,
722
+ variant: activeVariant,
723
+ error: fieldError,
724
+ fieldState,
725
+ fieldId,
726
+ isLoading
727
+ })
728
+ });
729
+ }
730
+ function ArrayFieldFallback({ field, control, disabled, variant }) {
731
+ const { fields, append, remove } = useFieldArray({
732
+ control,
733
+ name: field.name
734
+ });
735
+ return /* @__PURE__ */ jsxs("div", {
736
+ className: cn("formkit-field-array flex flex-col gap-4", field.fullWidth && "col-span-full", field.className),
737
+ "data-formkit-field": field.name,
738
+ "data-field-type": "array",
739
+ children: [
740
+ /* @__PURE__ */ jsx("div", {
741
+ className: "flex items-center justify-between",
742
+ children: field.label && /* @__PURE__ */ jsx("label", {
743
+ className: "font-semibold",
744
+ children: field.label
745
+ })
746
+ }),
747
+ fields.map((item, index) => /* @__PURE__ */ jsxs("div", {
748
+ className: "relative formkit-array-item border p-4 rounded-md",
749
+ children: [/* @__PURE__ */ jsx(GridRenderer, {
750
+ fields: field.itemFields?.map((f) => ({
751
+ ...f,
752
+ name: `${field.name}.${index}.${f.name}`
753
+ })),
754
+ control,
755
+ disabled,
756
+ variant
757
+ }), /* @__PURE__ */ jsx("button", {
758
+ type: "button",
759
+ onClick: () => remove(index),
760
+ className: "absolute top-2 right-2 text-red-500 hover:text-red-700 text-sm font-medium",
761
+ children: "Remove"
762
+ })]
763
+ }, item.id)),
764
+ /* @__PURE__ */ jsx("button", {
765
+ type: "button",
766
+ onClick: () => append({}),
767
+ 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",
769
+ children: "+ Add Item"
770
+ })
771
+ ]
772
+ });
773
+ }
774
+
775
+ //#endregion
776
+ //#region src/builders.ts
777
+ /**
778
+ * Type-safe field builder helpers for schema-driven forms.
779
+ *
780
+ * Provides shorthand methods for common field types with sensible defaults,
781
+ * reducing boilerplate while maintaining full type safety.
782
+ *
783
+ * @example
784
+ * ```ts
785
+ * import { field, section } from '@classytic/formkit';
786
+ *
787
+ * const schema = {
788
+ * sections: [
789
+ * section("personal", "Personal Info", [
790
+ * field.text("firstName", "First Name", { required: true }),
791
+ * field.email("email", "Email"),
792
+ * field.select("role", "Role", [
793
+ * { label: "Admin", value: "admin" },
794
+ * { label: "User", value: "user" },
795
+ * ]),
796
+ * ], { cols: 2 }),
797
+ * ],
798
+ * };
799
+ * ```
800
+ */
801
+ const field = {
802
+ text: (name, label, props = {}) => ({
803
+ type: "text",
804
+ name,
805
+ label,
806
+ ...props
807
+ }),
808
+ email: (name, label, props = {}) => ({
809
+ type: "email",
810
+ name,
811
+ label,
812
+ placeholder: "example@email.com",
813
+ ...props
814
+ }),
815
+ url: (name, label, props = {}) => ({
816
+ type: "url",
817
+ name,
818
+ label,
819
+ placeholder: "https://example.com",
820
+ ...props
821
+ }),
822
+ tel: (name, label, props = {}) => ({
823
+ type: "tel",
824
+ name,
825
+ label,
826
+ placeholder: "+1 (555) 000-0000",
827
+ ...props
828
+ }),
829
+ password: (name, label, props = {}) => ({
830
+ type: "password",
831
+ name,
832
+ label,
833
+ ...props
834
+ }),
835
+ number: (name, label, props = {}) => ({
836
+ type: "number",
837
+ name,
838
+ label,
839
+ min: 0,
840
+ ...props
841
+ }),
842
+ textarea: (name, label, props = {}) => ({
843
+ type: "textarea",
844
+ name,
845
+ label,
846
+ rows: 3,
847
+ ...props
848
+ }),
849
+ select: (name, label, options, props = {}) => ({
850
+ type: "select",
851
+ name,
852
+ label,
853
+ options,
854
+ ...props
855
+ }),
856
+ combobox: (name, label, options, props = {}) => ({
857
+ type: "combobox",
858
+ name,
859
+ label,
860
+ options,
861
+ ...props
862
+ }),
863
+ multiselect: (name, label, options, props = {}) => ({
864
+ type: "multiselect",
865
+ name,
866
+ label,
867
+ options,
868
+ placeholder: "Select options...",
869
+ ...props
870
+ }),
871
+ dependentSelect: (name, label, props = {}) => ({
872
+ type: "dependentSelect",
873
+ name,
874
+ label,
875
+ ...props
876
+ }),
877
+ switch: (name, label, props = {}) => ({
878
+ type: "switch",
879
+ name,
880
+ label,
881
+ ...props
882
+ }),
883
+ boolean: (name, label, props = {}) => ({
884
+ type: "switch",
885
+ name,
886
+ label,
887
+ ...props
888
+ }),
889
+ checkbox: (name, label, props = {}) => ({
890
+ type: "checkbox",
891
+ name,
892
+ label,
893
+ ...props
894
+ }),
895
+ radio: (name, label, options, props = {}) => ({
896
+ type: "radio",
897
+ name,
898
+ label,
899
+ options,
900
+ ...props
901
+ }),
902
+ date: (name, label, props = {}) => ({
903
+ type: "date",
904
+ name,
905
+ label,
906
+ ...props
907
+ }),
908
+ tags: (name, label, props = {}) => ({
909
+ type: "tags",
910
+ name,
911
+ label,
912
+ placeholder: "Add tags...",
913
+ ...props
914
+ }),
915
+ slug: (name, label, props = {}) => ({
916
+ type: "slug",
917
+ name,
918
+ label,
919
+ placeholder: "my-page-slug",
920
+ ...props
921
+ }),
922
+ file: (name, label, props = {}) => ({
923
+ type: "file",
924
+ name,
925
+ label,
926
+ ...props
927
+ }),
928
+ hidden: (name, props = {}) => ({
929
+ type: "hidden",
930
+ name,
931
+ ...props
932
+ }),
933
+ group: (name, label, itemFields, props = {}) => ({
934
+ type: "group",
935
+ name,
936
+ label,
937
+ itemFields,
938
+ ...props
939
+ }),
940
+ array: (name, label, itemFields, props = {}) => ({
941
+ type: "array",
942
+ name,
943
+ label,
944
+ itemFields,
945
+ ...props
946
+ }),
947
+ custom: (name, label, render, props = {}) => ({
948
+ type: "custom",
949
+ name,
950
+ label,
951
+ render,
952
+ ...props
953
+ })
954
+ };
955
+ /**
956
+ * Create a section definition with sensible defaults.
957
+ *
958
+ * @param id - Unique section identifier
959
+ * @param title - Section title
960
+ * @param fields - Array of field definitions
961
+ * @param props - Additional section configuration
962
+ *
963
+ * @example
964
+ * ```ts
965
+ * section("personal", "Personal Info", [
966
+ * field.text("name", "Name", { required: true }),
967
+ * field.email("email", "Email"),
968
+ * ], { cols: 2, variant: "card" })
969
+ * ```
970
+ */
971
+ function section(id, title, fields, props = {}) {
972
+ const { cols = 2, ...rest } = props;
973
+ return {
974
+ id,
975
+ title,
976
+ fields,
977
+ cols,
978
+ ...rest
979
+ };
980
+ }
981
+ /**
982
+ * Create a section without a title (transparent section).
983
+ * Useful for grouping fields without visual separation.
984
+ */
985
+ function sectionUntitled(fields, props = {}) {
986
+ const { cols = 1, ...rest } = props;
987
+ return {
988
+ fields,
989
+ cols,
990
+ variant: "transparent",
991
+ ...rest
992
+ };
993
+ }
994
+
995
+ //#endregion
996
+ //#region src/useFormKit.ts
997
+ /**
998
+ * Convenience hook that combines schema default extraction with react-hook-form setup.
999
+ * Returns all useForm methods plus ready-to-spread `generatorProps`.
1000
+ *
1001
+ * @example
1002
+ * ```tsx
1003
+ * const { handleSubmit, generatorProps } = useFormKit({
1004
+ * schema: formSchema,
1005
+ * resolver: zodResolver(validationSchema),
1006
+ * });
1007
+ *
1008
+ * return (
1009
+ * <form onSubmit={handleSubmit(onSubmit)}>
1010
+ * <FormGenerator {...generatorProps} />
1011
+ * <button type="submit">Submit</button>
1012
+ * </form>
1013
+ * );
1014
+ * ```
1015
+ */
1016
+ function useFormKit(options) {
1017
+ const { schema, disabled, variant, className, defaultValues, ...formOptions } = options;
1018
+ const mergedDefaults = {
1019
+ ...extractDefaultValues(schema),
1020
+ ...typeof defaultValues === "object" && defaultValues !== null ? defaultValues : {}
1021
+ };
1022
+ const form = useForm({
1023
+ ...formOptions,
1024
+ defaultValues: mergedDefaults
1025
+ });
1026
+ const generatorProps = useMemo(() => ({
1027
+ schema,
1028
+ control: form.control,
1029
+ disabled,
1030
+ variant,
1031
+ className
1032
+ }), [
1033
+ schema,
1034
+ form.control,
1035
+ disabled,
1036
+ variant,
1037
+ className
1038
+ ]);
1039
+ return Object.assign(form, { generatorProps });
1040
+ }
1041
+
1042
+ //#endregion
1043
+ export { FieldWrapper, FormGenerator, FormSystemProvider, GridRenderer, SectionRenderer, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extractDefaultValues, extractWatchNames, field, section, sectionUntitled, shallowEqual, useFieldComponent, useFormKit, useFormSystem, useLayoutComponent };
1044
+ //# sourceMappingURL=index.mjs.map