@classytic/formkit 1.0.3 → 1.2.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 +78 -56
- package/README.md +423 -112
- package/dist/index.d.mts +914 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1040 -0
- package/dist/index.mjs.map +1 -0
- package/dist/server.d.mts +625 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +418 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +24 -31
- package/dist/index.cjs +0 -233
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -511
- package/dist/index.d.ts +0 -511
- package/dist/index.js +0 -223
- package/dist/index.js.map +0 -1
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1040 @@
|
|
|
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
|
+
return {
|
|
1027
|
+
...form,
|
|
1028
|
+
generatorProps: {
|
|
1029
|
+
schema,
|
|
1030
|
+
control: form.control,
|
|
1031
|
+
disabled,
|
|
1032
|
+
variant,
|
|
1033
|
+
className
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
//#endregion
|
|
1039
|
+
export { FieldWrapper, FormGenerator, FormSystemProvider, GridRenderer, SectionRenderer, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extractDefaultValues, extractWatchNames, field, section, sectionUntitled, shallowEqual, useFieldComponent, useFormKit, useFormSystem, useLayoutComponent };
|
|
1040
|
+
//# sourceMappingURL=index.mjs.map
|