@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/server.mjs
CHANGED
|
@@ -85,6 +85,14 @@ function toRules(condition) {
|
|
|
85
85
|
logic: "and"
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
function resolveRuleObject(rule, defaultMessage) {
|
|
89
|
+
if (rule !== null && typeof rule === "object" && "value" in rule && "message" in rule) return rule;
|
|
90
|
+
const v = rule;
|
|
91
|
+
return {
|
|
92
|
+
value: v,
|
|
93
|
+
message: defaultMessage(v)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
88
96
|
/**
|
|
89
97
|
* Evaluates a conditional rule, array of rules, or a ConditionConfig against form values.
|
|
90
98
|
* Supports AND (default) and OR logic via ConditionConfig.
|
|
@@ -95,7 +103,12 @@ function toRules(condition) {
|
|
|
95
103
|
*/
|
|
96
104
|
function evaluateCondition(condition, formValues) {
|
|
97
105
|
if (!condition) return true;
|
|
98
|
-
if (typeof condition === "function")
|
|
106
|
+
if (typeof condition === "function") try {
|
|
107
|
+
return condition(formValues);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.warn("[FormKit] Condition function threw:", err);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
99
112
|
const { rules, logic } = toRules(condition);
|
|
100
113
|
const evalFn = (rule) => evaluateRule(rule, formValues);
|
|
101
114
|
return logic === "or" ? rules.some(evalFn) : rules.every(evalFn);
|
|
@@ -130,6 +143,7 @@ function defineSection(section) {
|
|
|
130
143
|
/**
|
|
131
144
|
* Extracts default values from a form schema.
|
|
132
145
|
* Walks all sections and fields, respecting nameSpace prefixes and group nesting.
|
|
146
|
+
* Array fields default to `[]` when no explicit `defaultValue` is provided.
|
|
133
147
|
*
|
|
134
148
|
* @example
|
|
135
149
|
* ```ts
|
|
@@ -143,9 +157,11 @@ function extractDefaultValues(schema) {
|
|
|
143
157
|
const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
|
|
144
158
|
if (!section.fields) continue;
|
|
145
159
|
for (const field of section.fields) {
|
|
146
|
-
|
|
160
|
+
const key = `${prefix}${field.name}`;
|
|
161
|
+
if (field.defaultValue !== void 0) defaults[key] = field.defaultValue;
|
|
162
|
+
else if (field.type === "array") defaults[key] = [];
|
|
147
163
|
if (field.itemFields && field.type !== "array") {
|
|
148
|
-
for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${
|
|
164
|
+
for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${key}.${sub.name}`] = sub.defaultValue;
|
|
149
165
|
}
|
|
150
166
|
}
|
|
151
167
|
}
|
|
@@ -156,6 +172,9 @@ function extractDefaultValues(schema) {
|
|
|
156
172
|
* from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
|
|
157
173
|
* `maxLength`, `pattern`, and `validate` to RHF rules.
|
|
158
174
|
*
|
|
175
|
+
* Supports both shorthand scalars and `{ value, message }` objects for all
|
|
176
|
+
* numeric/length rules, and `{ regex, message }` for pattern.
|
|
177
|
+
*
|
|
159
178
|
* @example
|
|
160
179
|
* ```tsx
|
|
161
180
|
* import { buildValidationRules } from '@classytic/formkit';
|
|
@@ -168,29 +187,190 @@ function extractDefaultValues(schema) {
|
|
|
168
187
|
*/
|
|
169
188
|
function buildValidationRules(field) {
|
|
170
189
|
const rules = {};
|
|
171
|
-
if (field.required) rules.required =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
message: `At least ${field.minLength} characters`
|
|
175
|
-
};
|
|
176
|
-
if (field.maxLength !== void 0) rules.maxLength = {
|
|
177
|
-
value: field.maxLength,
|
|
178
|
-
message: `At most ${field.maxLength} characters`
|
|
190
|
+
if (field.required) rules.required = {
|
|
191
|
+
value: true,
|
|
192
|
+
message: `${field.label || field.name} is required`
|
|
179
193
|
};
|
|
180
|
-
if (field.
|
|
181
|
-
value
|
|
182
|
-
|
|
194
|
+
if (field.minLength !== void 0) {
|
|
195
|
+
const { value, message } = resolveRuleObject(field.minLength, (v) => `At least ${v} characters`);
|
|
196
|
+
rules.minLength = {
|
|
197
|
+
value,
|
|
198
|
+
message
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (field.maxLength !== void 0) {
|
|
202
|
+
const { value, message } = resolveRuleObject(field.maxLength, (v) => `At most ${v} characters`);
|
|
203
|
+
rules.maxLength = {
|
|
204
|
+
value,
|
|
205
|
+
message
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (field.min !== void 0) {
|
|
209
|
+
const { value, message } = resolveRuleObject(field.min, (v) => `Must be at least ${v}`);
|
|
210
|
+
rules.min = {
|
|
211
|
+
value,
|
|
212
|
+
message
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (field.max !== void 0) {
|
|
216
|
+
const { value, message } = resolveRuleObject(field.max, (v) => `Must be at most ${v}`);
|
|
217
|
+
rules.max = {
|
|
218
|
+
value,
|
|
219
|
+
message
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (field.pattern) {
|
|
223
|
+
const isObject = typeof field.pattern === "object";
|
|
224
|
+
const regexStr = isObject ? field.pattern.regex : field.pattern;
|
|
225
|
+
const message = isObject ? field.pattern.message : "Invalid format";
|
|
226
|
+
try {
|
|
227
|
+
rules.pattern = {
|
|
228
|
+
value: new RegExp(regexStr),
|
|
229
|
+
message
|
|
230
|
+
};
|
|
231
|
+
} catch {
|
|
232
|
+
console.warn(`[FormKit] Invalid regex pattern "${regexStr}" in field "${field.name}", skipping.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (field.validate) rules.validate = field.validate;
|
|
236
|
+
return rules;
|
|
237
|
+
}
|
|
238
|
+
/** Returns true for fields that carry an `options` array (select, radio, etc.) */
|
|
239
|
+
function isChoiceField(field) {
|
|
240
|
+
return [
|
|
241
|
+
"select",
|
|
242
|
+
"combobox",
|
|
243
|
+
"multiselect",
|
|
244
|
+
"dependentSelect",
|
|
245
|
+
"radio",
|
|
246
|
+
"checkbox"
|
|
247
|
+
].includes(field.type);
|
|
248
|
+
}
|
|
249
|
+
/** Returns true for free-text input fields */
|
|
250
|
+
function isTextField(field) {
|
|
251
|
+
return [
|
|
252
|
+
"text",
|
|
253
|
+
"email",
|
|
254
|
+
"password",
|
|
255
|
+
"tel",
|
|
256
|
+
"phone",
|
|
257
|
+
"url",
|
|
258
|
+
"slug",
|
|
259
|
+
"textarea",
|
|
260
|
+
"rich-text"
|
|
261
|
+
].includes(field.type);
|
|
262
|
+
}
|
|
263
|
+
/** Returns true for numeric input fields */
|
|
264
|
+
function isNumericField(field) {
|
|
265
|
+
return ["number", "rating"].includes(field.type);
|
|
266
|
+
}
|
|
267
|
+
/** Returns true for date / time fields */
|
|
268
|
+
function isDateField(field) {
|
|
269
|
+
return [
|
|
270
|
+
"date",
|
|
271
|
+
"time",
|
|
272
|
+
"datetime"
|
|
273
|
+
].includes(field.type);
|
|
274
|
+
}
|
|
275
|
+
/** Returns true for structural fields that contain sub-fields (`itemFields`) */
|
|
276
|
+
function isContainerField(field) {
|
|
277
|
+
return ["group", "array"].includes(field.type);
|
|
278
|
+
}
|
|
279
|
+
/** Returns true for array fields that render a repeatable list */
|
|
280
|
+
function isArrayField(field) {
|
|
281
|
+
return field.type === "array";
|
|
282
|
+
}
|
|
283
|
+
/** Returns true for fields that load options asynchronously */
|
|
284
|
+
function isDynamicField(field) {
|
|
285
|
+
return !!field.loadOptions;
|
|
286
|
+
}
|
|
287
|
+
/** Returns true for fields with conditional rendering */
|
|
288
|
+
function isConditionalField(field) {
|
|
289
|
+
return field.condition !== void 0;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Merge two or more schemas into one, concatenating their sections.
|
|
293
|
+
*
|
|
294
|
+
* @example
|
|
295
|
+
* ```ts
|
|
296
|
+
* const full = mergeSchemas(personalSchema, addressSchema, billingSchema);
|
|
297
|
+
* ```
|
|
298
|
+
*/
|
|
299
|
+
function mergeSchemas(...schemas) {
|
|
300
|
+
return { sections: schemas.flatMap((s) => s.sections) };
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Add fields to a section identified by `sectionId`.
|
|
304
|
+
* Returns a new schema — the original is not mutated.
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* ```ts
|
|
308
|
+
* const extended = extendSection(schema, "personal", [
|
|
309
|
+
* field.text("middleName", "Middle Name"),
|
|
310
|
+
* ]);
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
function extendSection(schema, sectionId, fields, position = "end") {
|
|
314
|
+
return {
|
|
315
|
+
...schema,
|
|
316
|
+
sections: schema.sections.map((section) => {
|
|
317
|
+
if (section.id !== sectionId) return section;
|
|
318
|
+
const existing = section.fields ?? [];
|
|
319
|
+
return {
|
|
320
|
+
...section,
|
|
321
|
+
fields: position === "start" ? [...fields, ...existing] : [...existing, ...fields]
|
|
322
|
+
};
|
|
323
|
+
})
|
|
183
324
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Create a new schema that includes only the named fields.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```ts
|
|
331
|
+
* const slim = pickFields(schema, ["email", "password"]);
|
|
332
|
+
* ```
|
|
333
|
+
*/
|
|
334
|
+
function pickFields(schema, names) {
|
|
335
|
+
const nameSet = new Set(names);
|
|
336
|
+
return {
|
|
337
|
+
...schema,
|
|
338
|
+
sections: schema.sections.map((section) => ({
|
|
339
|
+
...section,
|
|
340
|
+
fields: (section.fields ?? []).filter((f) => nameSet.has(f.name))
|
|
341
|
+
})).filter((section) => (section.fields?.length ?? 0) > 0)
|
|
187
342
|
};
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Create a new schema that excludes the named fields.
|
|
346
|
+
*
|
|
347
|
+
* @example
|
|
348
|
+
* ```ts
|
|
349
|
+
* const withoutInternal = omitFields(schema, ["__id", "__createdAt"]);
|
|
350
|
+
* ```
|
|
351
|
+
*/
|
|
352
|
+
function omitFields(schema, names) {
|
|
353
|
+
const nameSet = new Set(names);
|
|
354
|
+
return {
|
|
355
|
+
...schema,
|
|
356
|
+
sections: schema.sections.map((section) => ({
|
|
357
|
+
...section,
|
|
358
|
+
fields: (section.fields ?? []).filter((f) => !nameSet.has(f.name))
|
|
359
|
+
}))
|
|
191
360
|
};
|
|
192
|
-
|
|
193
|
-
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Collect every field from every section into a flat array.
|
|
364
|
+
* Useful for validation, documentation, and AI schema introspection.
|
|
365
|
+
*
|
|
366
|
+
* @example
|
|
367
|
+
* ```ts
|
|
368
|
+
* const allFields = flattenSchema(schema);
|
|
369
|
+
* const required = allFields.filter(f => f.required);
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
function flattenSchema(schema) {
|
|
373
|
+
return schema.sections.flatMap((s) => s.fields ?? []);
|
|
194
374
|
}
|
|
195
375
|
|
|
196
376
|
//#endregion
|
|
@@ -198,226 +378,314 @@ function buildValidationRules(field) {
|
|
|
198
378
|
/**
|
|
199
379
|
* Type-safe field builder helpers for schema-driven forms.
|
|
200
380
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
381
|
+
* All methods are generic over TFieldValues, defaulting to FieldValues (any string)
|
|
382
|
+
* when no type argument is provided. Specify the generic to enforce that field
|
|
383
|
+
* names are valid paths in your form values type.
|
|
384
|
+
*
|
|
385
|
+
* For fully-typed schemas where every field name is checked, prefer
|
|
386
|
+
* `field.for<MyForm>()` which fixes the generic once for the whole schema:
|
|
203
387
|
*
|
|
204
388
|
* @example
|
|
205
389
|
* ```ts
|
|
206
|
-
*
|
|
390
|
+
* // Untyped — any string accepted (backwards compatible)
|
|
391
|
+
* field.text("email", "Email")
|
|
207
392
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* { label: "User", value: "user" },
|
|
216
|
-
* ]),
|
|
217
|
-
* ], { cols: 2 }),
|
|
218
|
-
* ],
|
|
219
|
-
* };
|
|
393
|
+
* // Per-call generic — name is checked against MyForm
|
|
394
|
+
* field.text<MyForm>("email", "Email")
|
|
395
|
+
*
|
|
396
|
+
* // Typed factory — name checked on every call without repeating the generic
|
|
397
|
+
* const f = field.for<MyForm>()
|
|
398
|
+
* f.text("email", "Email") // ✓
|
|
399
|
+
* f.text("typo", "Email") // ✗ TypeScript error
|
|
220
400
|
* ```
|
|
221
401
|
*/
|
|
222
402
|
const field = {
|
|
223
|
-
|
|
403
|
+
/** Text input field. */
|
|
404
|
+
text: (name, label, props) => ({
|
|
224
405
|
type: "text",
|
|
225
406
|
name,
|
|
226
407
|
label,
|
|
227
|
-
...props
|
|
408
|
+
...props ?? {}
|
|
228
409
|
}),
|
|
229
|
-
|
|
410
|
+
/** Email input field with default placeholder. */
|
|
411
|
+
email: (name, label, props) => ({
|
|
230
412
|
type: "email",
|
|
231
413
|
name,
|
|
232
414
|
label,
|
|
233
415
|
placeholder: "example@email.com",
|
|
234
|
-
...props
|
|
416
|
+
...props ?? {}
|
|
235
417
|
}),
|
|
236
|
-
|
|
418
|
+
/** URL input field with default placeholder. */
|
|
419
|
+
url: (name, label, props) => ({
|
|
237
420
|
type: "url",
|
|
238
421
|
name,
|
|
239
422
|
label,
|
|
240
423
|
placeholder: "https://example.com",
|
|
241
|
-
...props
|
|
424
|
+
...props ?? {}
|
|
242
425
|
}),
|
|
243
|
-
tel
|
|
426
|
+
/** Phone/tel input field with default placeholder. */
|
|
427
|
+
tel: (name, label, props) => ({
|
|
244
428
|
type: "tel",
|
|
245
429
|
name,
|
|
246
430
|
label,
|
|
247
431
|
placeholder: "+1 (555) 000-0000",
|
|
248
|
-
...props
|
|
432
|
+
...props ?? {}
|
|
249
433
|
}),
|
|
250
|
-
|
|
434
|
+
/** Password input field. */
|
|
435
|
+
password: (name, label, props) => ({
|
|
251
436
|
type: "password",
|
|
252
437
|
name,
|
|
253
438
|
label,
|
|
254
|
-
...props
|
|
439
|
+
...props ?? {}
|
|
255
440
|
}),
|
|
256
|
-
|
|
441
|
+
/** Number input field with min: 0 default (overrideable via props). */
|
|
442
|
+
number: (name, label, props) => ({
|
|
257
443
|
type: "number",
|
|
258
444
|
name,
|
|
259
445
|
label,
|
|
260
446
|
min: 0,
|
|
261
|
-
...props
|
|
447
|
+
...props ?? {}
|
|
262
448
|
}),
|
|
263
|
-
|
|
449
|
+
/** Textarea field with default 3 rows. */
|
|
450
|
+
textarea: (name, label, props) => ({
|
|
264
451
|
type: "textarea",
|
|
265
452
|
name,
|
|
266
453
|
label,
|
|
267
454
|
rows: 3,
|
|
268
|
-
...props
|
|
455
|
+
...props ?? {}
|
|
269
456
|
}),
|
|
270
|
-
|
|
457
|
+
/** Select dropdown field. */
|
|
458
|
+
select: (name, label, options, props) => ({
|
|
271
459
|
type: "select",
|
|
272
460
|
name,
|
|
273
461
|
label,
|
|
274
462
|
options,
|
|
275
|
-
...props
|
|
463
|
+
...props ?? {}
|
|
276
464
|
}),
|
|
277
|
-
|
|
465
|
+
/** Searchable combobox field. */
|
|
466
|
+
combobox: (name, label, options, props) => ({
|
|
278
467
|
type: "combobox",
|
|
279
468
|
name,
|
|
280
469
|
label,
|
|
281
470
|
options,
|
|
282
|
-
...props
|
|
471
|
+
...props ?? {}
|
|
283
472
|
}),
|
|
284
|
-
|
|
473
|
+
/** Multi-select field. */
|
|
474
|
+
multiselect: (name, label, options, props) => ({
|
|
285
475
|
type: "multiselect",
|
|
286
476
|
name,
|
|
287
477
|
label,
|
|
288
478
|
options,
|
|
289
479
|
placeholder: "Select options...",
|
|
290
|
-
...props
|
|
291
|
-
}),
|
|
292
|
-
tagChoice: (name, label, options, props = {}) => ({
|
|
293
|
-
type: "tagChoice",
|
|
294
|
-
name,
|
|
295
|
-
label,
|
|
296
|
-
options,
|
|
297
|
-
...props
|
|
480
|
+
...props ?? {}
|
|
298
481
|
}),
|
|
299
|
-
|
|
482
|
+
/** Dependent select field that reacts to parent field changes. */
|
|
483
|
+
dependentSelect: (name, label, props) => ({
|
|
300
484
|
type: "dependentSelect",
|
|
301
485
|
name,
|
|
302
486
|
label,
|
|
303
|
-
...props
|
|
487
|
+
...props ?? {}
|
|
304
488
|
}),
|
|
305
|
-
|
|
489
|
+
/** Switch/toggle field. */
|
|
490
|
+
switch: (name, label, props) => ({
|
|
306
491
|
type: "switch",
|
|
307
492
|
name,
|
|
308
493
|
label,
|
|
309
|
-
...props
|
|
494
|
+
...props ?? {}
|
|
310
495
|
}),
|
|
311
|
-
|
|
496
|
+
/** Boolean field (alias for switch). */
|
|
497
|
+
boolean: (name, label, props) => ({
|
|
312
498
|
type: "switch",
|
|
313
499
|
name,
|
|
314
500
|
label,
|
|
315
|
-
...props
|
|
501
|
+
...props ?? {}
|
|
316
502
|
}),
|
|
317
|
-
|
|
503
|
+
/** Checkbox field. */
|
|
504
|
+
checkbox: (name, label, props) => ({
|
|
318
505
|
type: "checkbox",
|
|
319
506
|
name,
|
|
320
507
|
label,
|
|
321
|
-
...props
|
|
508
|
+
...props ?? {}
|
|
322
509
|
}),
|
|
323
|
-
|
|
510
|
+
/** Radio button group field. */
|
|
511
|
+
radio: (name, label, options, props) => ({
|
|
324
512
|
type: "radio",
|
|
325
513
|
name,
|
|
326
514
|
label,
|
|
327
515
|
options,
|
|
328
|
-
...props
|
|
516
|
+
...props ?? {}
|
|
329
517
|
}),
|
|
330
|
-
|
|
518
|
+
/** Date picker field. */
|
|
519
|
+
date: (name, label, props) => ({
|
|
331
520
|
type: "date",
|
|
332
521
|
name,
|
|
333
522
|
label,
|
|
334
|
-
...props
|
|
523
|
+
...props ?? {}
|
|
335
524
|
}),
|
|
336
|
-
|
|
525
|
+
/** Tag input field. */
|
|
526
|
+
tags: (name, label, props) => ({
|
|
337
527
|
type: "tags",
|
|
338
528
|
name,
|
|
339
529
|
label,
|
|
340
530
|
placeholder: "Add tags...",
|
|
341
|
-
...props
|
|
531
|
+
...props ?? {}
|
|
342
532
|
}),
|
|
343
|
-
|
|
533
|
+
/** Slug field. */
|
|
534
|
+
slug: (name, label, props) => ({
|
|
344
535
|
type: "slug",
|
|
345
536
|
name,
|
|
346
537
|
label,
|
|
347
538
|
placeholder: "my-page-slug",
|
|
348
|
-
...props
|
|
539
|
+
...props ?? {}
|
|
349
540
|
}),
|
|
350
|
-
|
|
541
|
+
/** File upload field. */
|
|
542
|
+
file: (name, label, props) => ({
|
|
351
543
|
type: "file",
|
|
352
544
|
name,
|
|
353
545
|
label,
|
|
354
|
-
...props
|
|
355
|
-
}),
|
|
356
|
-
otp: (name, label, props = {}) => ({
|
|
357
|
-
type: "otp",
|
|
358
|
-
name,
|
|
359
|
-
label,
|
|
360
|
-
...props
|
|
361
|
-
}),
|
|
362
|
-
asyncCombobox: (name, label, props = {}) => ({
|
|
363
|
-
type: "asyncCombobox",
|
|
364
|
-
name,
|
|
365
|
-
label,
|
|
366
|
-
...props
|
|
367
|
-
}),
|
|
368
|
-
asyncMultiselect: (name, label, props = {}) => ({
|
|
369
|
-
type: "asyncMultiselect",
|
|
370
|
-
name,
|
|
371
|
-
label,
|
|
372
|
-
...props
|
|
373
|
-
}),
|
|
374
|
-
dateTime: (name, label, props = {}) => ({
|
|
375
|
-
type: "dateTime",
|
|
376
|
-
name,
|
|
377
|
-
label,
|
|
378
|
-
...props
|
|
546
|
+
...props ?? {}
|
|
379
547
|
}),
|
|
380
|
-
|
|
548
|
+
/** Hidden field (no UI). */
|
|
549
|
+
hidden: (name, props) => ({
|
|
381
550
|
type: "hidden",
|
|
382
551
|
name,
|
|
383
|
-
...props
|
|
552
|
+
...props ?? {}
|
|
384
553
|
}),
|
|
385
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Group field for nested objects.
|
|
556
|
+
* Renders itemFields as a sub-grid. Child names are relative (e.g. "street"),
|
|
557
|
+
* FormGenerator prefixes them with the group name at render time.
|
|
558
|
+
*
|
|
559
|
+
* @example
|
|
560
|
+
* ```ts
|
|
561
|
+
* field.group("address", "Address", [
|
|
562
|
+
* field.text("street", "Street"),
|
|
563
|
+
* field.text("city", "City"),
|
|
564
|
+
* ], { cols: 2 })
|
|
565
|
+
* ```
|
|
566
|
+
*/
|
|
567
|
+
group: (name, label, itemFields, props) => ({
|
|
386
568
|
type: "group",
|
|
387
569
|
name,
|
|
388
570
|
label,
|
|
389
571
|
itemFields,
|
|
390
|
-
...props
|
|
572
|
+
...props ?? {}
|
|
391
573
|
}),
|
|
392
|
-
|
|
574
|
+
/**
|
|
575
|
+
* Array/repeatable field backed by react-hook-form's useFieldArray.
|
|
576
|
+
*
|
|
577
|
+
* @example
|
|
578
|
+
* ```ts
|
|
579
|
+
* field.array("contacts", "Contacts", [
|
|
580
|
+
* field.text("name", "Name"),
|
|
581
|
+
* field.email("email", "Email"),
|
|
582
|
+
* ])
|
|
583
|
+
* ```
|
|
584
|
+
*/
|
|
585
|
+
array: (name, label, itemFields, props) => ({
|
|
393
586
|
type: "array",
|
|
394
587
|
name,
|
|
395
588
|
label,
|
|
396
589
|
itemFields,
|
|
397
|
-
...props
|
|
590
|
+
...props ?? {}
|
|
398
591
|
}),
|
|
399
|
-
|
|
592
|
+
/**
|
|
593
|
+
* Custom field with a render function.
|
|
594
|
+
* Bypasses the component registry — full control over rendering.
|
|
595
|
+
*
|
|
596
|
+
* The render callback receives the complete `FieldComponentProps` including
|
|
597
|
+
* `fieldId`, `errorId`, `shouldShowError`, `error`, `rules`, and `control`.
|
|
598
|
+
*
|
|
599
|
+
* Use `shouldShowError` (not `!!error`) to drive `aria-invalid` and error
|
|
600
|
+
* visibility so timing mirrors the CSS `:user-invalid` pseudo-class.
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* ```tsx
|
|
604
|
+
* field.custom("skills", "Skills", ({ control, shouldShowError, errorId, error, fieldId }) => (
|
|
605
|
+
* <div>
|
|
606
|
+
* <SkillSelector
|
|
607
|
+
* id={fieldId}
|
|
608
|
+
* control={control}
|
|
609
|
+
* aria-invalid={shouldShowError || undefined}
|
|
610
|
+
* aria-errormessage={shouldShowError ? errorId : undefined}
|
|
611
|
+
* />
|
|
612
|
+
* {shouldShowError && (
|
|
613
|
+
* <p id={errorId} role="alert" className="text-sm text-destructive">
|
|
614
|
+
* {error?.message}
|
|
615
|
+
* </p>
|
|
616
|
+
* )}
|
|
617
|
+
* </div>
|
|
618
|
+
* ))
|
|
619
|
+
* ```
|
|
620
|
+
*/
|
|
621
|
+
custom: (name, label, render, props) => ({
|
|
400
622
|
type: "custom",
|
|
401
623
|
name,
|
|
402
624
|
label,
|
|
403
625
|
render,
|
|
404
|
-
...props
|
|
626
|
+
...props ?? {}
|
|
627
|
+
}),
|
|
628
|
+
/**
|
|
629
|
+
* Returns a typed field builder with `TFieldValues` fixed.
|
|
630
|
+
* Every field name is validated against `Path<TFieldValues>` at the call site —
|
|
631
|
+
* no need to repeat the generic on each individual builder call.
|
|
632
|
+
*
|
|
633
|
+
* @example
|
|
634
|
+
* ```ts
|
|
635
|
+
* interface ContactForm {
|
|
636
|
+
* firstName: string;
|
|
637
|
+
* email: string;
|
|
638
|
+
* address: { street: string; city: string };
|
|
639
|
+
* }
|
|
640
|
+
*
|
|
641
|
+
* const f = field.for<ContactForm>()
|
|
642
|
+
*
|
|
643
|
+
* const schema = defineSchema<ContactForm>({
|
|
644
|
+
* sections: [{
|
|
645
|
+
* fields: [
|
|
646
|
+
* f.text("firstName", "First Name"), // ✓
|
|
647
|
+
* f.email("email", "Email"), // ✓
|
|
648
|
+
* f.text("typo", "Label"), // ✗ TypeScript error
|
|
649
|
+
* ],
|
|
650
|
+
* }],
|
|
651
|
+
* })
|
|
652
|
+
* ```
|
|
653
|
+
*/
|
|
654
|
+
for: () => ({
|
|
655
|
+
text: (name, label, props) => field.text(name, label, props),
|
|
656
|
+
email: (name, label, props) => field.email(name, label, props),
|
|
657
|
+
url: (name, label, props) => field.url(name, label, props),
|
|
658
|
+
tel: (name, label, props) => field.tel(name, label, props),
|
|
659
|
+
password: (name, label, props) => field.password(name, label, props),
|
|
660
|
+
number: (name, label, props) => field.number(name, label, props),
|
|
661
|
+
textarea: (name, label, props) => field.textarea(name, label, props),
|
|
662
|
+
select: (name, label, options, props) => field.select(name, label, options, props),
|
|
663
|
+
combobox: (name, label, options, props) => field.combobox(name, label, options, props),
|
|
664
|
+
multiselect: (name, label, options, props) => field.multiselect(name, label, options, props),
|
|
665
|
+
dependentSelect: (name, label, props) => field.dependentSelect(name, label, props),
|
|
666
|
+
switch: (name, label, props) => field.switch(name, label, props),
|
|
667
|
+
boolean: (name, label, props) => field.boolean(name, label, props),
|
|
668
|
+
checkbox: (name, label, props) => field.checkbox(name, label, props),
|
|
669
|
+
radio: (name, label, options, props) => field.radio(name, label, options, props),
|
|
670
|
+
date: (name, label, props) => field.date(name, label, props),
|
|
671
|
+
tags: (name, label, props) => field.tags(name, label, props),
|
|
672
|
+
slug: (name, label, props) => field.slug(name, label, props),
|
|
673
|
+
file: (name, label, props) => field.file(name, label, props),
|
|
674
|
+
hidden: (name, props) => field.hidden(name, props),
|
|
675
|
+
group: (name, label, itemFields, props) => field.group(name, label, itemFields, props),
|
|
676
|
+
array: (name, label, itemFields, props) => field.array(name, label, itemFields, props),
|
|
677
|
+
custom: (name, label, render, builderProps) => field.custom(name, label, render, builderProps)
|
|
405
678
|
})
|
|
406
679
|
};
|
|
407
680
|
/**
|
|
408
681
|
* Create a section definition with sensible defaults.
|
|
409
682
|
*
|
|
410
|
-
* @param id - Unique section identifier
|
|
411
|
-
* @param title - Section title
|
|
412
|
-
* @param fields - Array of field definitions
|
|
413
|
-
* @param props - Additional section configuration
|
|
414
|
-
*
|
|
415
683
|
* @example
|
|
416
684
|
* ```ts
|
|
417
685
|
* section("personal", "Personal Info", [
|
|
418
686
|
* field.text("name", "Name", { required: true }),
|
|
419
687
|
* field.email("email", "Email"),
|
|
420
|
-
* ], { cols: 2
|
|
688
|
+
* ], { cols: 2 })
|
|
421
689
|
* ```
|
|
422
690
|
*/
|
|
423
691
|
function section(id, title, fields, props = {}) {
|
|
@@ -433,6 +701,9 @@ function section(id, title, fields, props = {}) {
|
|
|
433
701
|
/**
|
|
434
702
|
* Create a section without a title (transparent section).
|
|
435
703
|
* Useful for grouping fields without visual separation.
|
|
704
|
+
*
|
|
705
|
+
* Accepts `BaseField[]` (no generic) so mixed-type field arrays don't trigger
|
|
706
|
+
* conflicting type inference across different field name generics.
|
|
436
707
|
*/
|
|
437
708
|
function sectionUntitled(fields, props = {}) {
|
|
438
709
|
const { cols = 1, ...rest } = props;
|
|
@@ -445,4 +716,4 @@ function sectionUntitled(fields, props = {}) {
|
|
|
445
716
|
}
|
|
446
717
|
|
|
447
718
|
//#endregion
|
|
448
|
-
export { buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extractDefaultValues, extractWatchNames, field, section, sectionUntitled };
|
|
719
|
+
export { buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extendSection, extractDefaultValues, extractWatchNames, field, flattenSchema, isArrayField, isChoiceField, isConditionalField, isContainerField, isDateField, isDynamicField, isNumericField, isTextField, mergeSchemas, omitFields, pickFields, section, sectionUntitled };
|