@classytic/formkit 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +31 -12
- package/dist/index.d.mts +447 -161
- package/dist/index.mjs +665 -179
- package/dist/server.d.mts +393 -150
- package/dist/server.mjs +385 -92
- package/package.json +115 -113
package/dist/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.
|
|
@@ -135,6 +143,7 @@ function defineSection(section) {
|
|
|
135
143
|
/**
|
|
136
144
|
* Extracts default values from a form schema.
|
|
137
145
|
* Walks all sections and fields, respecting nameSpace prefixes and group nesting.
|
|
146
|
+
* Array fields default to `[]` when no explicit `defaultValue` is provided.
|
|
138
147
|
*
|
|
139
148
|
* @example
|
|
140
149
|
* ```ts
|
|
@@ -148,9 +157,11 @@ function extractDefaultValues(schema) {
|
|
|
148
157
|
const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
|
|
149
158
|
if (!section.fields) continue;
|
|
150
159
|
for (const field of section.fields) {
|
|
151
|
-
|
|
160
|
+
const key = `${prefix}${field.name}`;
|
|
161
|
+
if (field.defaultValue !== void 0) defaults[key] = field.defaultValue;
|
|
162
|
+
else if (field.type === "array") defaults[key] = [];
|
|
152
163
|
if (field.itemFields && field.type !== "array") {
|
|
153
|
-
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;
|
|
154
165
|
}
|
|
155
166
|
}
|
|
156
167
|
}
|
|
@@ -161,6 +172,9 @@ function extractDefaultValues(schema) {
|
|
|
161
172
|
* from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
|
|
162
173
|
* `maxLength`, `pattern`, and `validate` to RHF rules.
|
|
163
174
|
*
|
|
175
|
+
* Supports both shorthand scalars and `{ value, message }` objects for all
|
|
176
|
+
* numeric/length rules, and `{ regex, message }` for pattern.
|
|
177
|
+
*
|
|
164
178
|
* @example
|
|
165
179
|
* ```tsx
|
|
166
180
|
* import { buildValidationRules } from '@classytic/formkit';
|
|
@@ -173,229 +187,505 @@ function extractDefaultValues(schema) {
|
|
|
173
187
|
*/
|
|
174
188
|
function buildValidationRules(field) {
|
|
175
189
|
const rules = {};
|
|
176
|
-
if (field.required) rules.required =
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
message: `At least ${field.minLength} characters`
|
|
180
|
-
};
|
|
181
|
-
if (field.maxLength !== void 0) rules.maxLength = {
|
|
182
|
-
value: field.maxLength,
|
|
183
|
-
message: `At most ${field.maxLength} characters`
|
|
190
|
+
if (field.required) rules.required = {
|
|
191
|
+
value: true,
|
|
192
|
+
message: `${field.label || field.name} is required`
|
|
184
193
|
};
|
|
185
|
-
if (field.
|
|
186
|
-
value
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
rules.
|
|
195
|
-
value
|
|
196
|
-
message
|
|
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
|
|
197
206
|
};
|
|
198
|
-
}
|
|
199
|
-
|
|
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
|
+
}
|
|
200
234
|
}
|
|
201
235
|
if (field.validate) rules.validate = field.validate;
|
|
202
236
|
return rules;
|
|
203
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
|
+
})
|
|
324
|
+
};
|
|
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)
|
|
342
|
+
};
|
|
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
|
+
}))
|
|
360
|
+
};
|
|
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 ?? []);
|
|
374
|
+
}
|
|
204
375
|
|
|
205
376
|
//#endregion
|
|
206
377
|
//#region src/builders.ts
|
|
207
378
|
/**
|
|
208
379
|
* Type-safe field builder helpers for schema-driven forms.
|
|
209
380
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
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:
|
|
212
387
|
*
|
|
213
388
|
* @example
|
|
214
389
|
* ```ts
|
|
215
|
-
*
|
|
390
|
+
* // Untyped — any string accepted (backwards compatible)
|
|
391
|
+
* field.text("email", "Email")
|
|
216
392
|
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* { label: "User", value: "user" },
|
|
225
|
-
* ]),
|
|
226
|
-
* ], { cols: 2 }),
|
|
227
|
-
* ],
|
|
228
|
-
* };
|
|
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
|
|
229
400
|
* ```
|
|
230
401
|
*/
|
|
231
402
|
const field = {
|
|
232
|
-
|
|
403
|
+
/** Text input field. */
|
|
404
|
+
text: (name, label, props) => ({
|
|
233
405
|
type: "text",
|
|
234
406
|
name,
|
|
235
407
|
label,
|
|
236
|
-
...props
|
|
408
|
+
...props ?? {}
|
|
237
409
|
}),
|
|
238
|
-
|
|
410
|
+
/** Email input field with default placeholder. */
|
|
411
|
+
email: (name, label, props) => ({
|
|
239
412
|
type: "email",
|
|
240
413
|
name,
|
|
241
414
|
label,
|
|
242
415
|
placeholder: "example@email.com",
|
|
243
|
-
...props
|
|
416
|
+
...props ?? {}
|
|
244
417
|
}),
|
|
245
|
-
|
|
418
|
+
/** URL input field with default placeholder. */
|
|
419
|
+
url: (name, label, props) => ({
|
|
246
420
|
type: "url",
|
|
247
421
|
name,
|
|
248
422
|
label,
|
|
249
423
|
placeholder: "https://example.com",
|
|
250
|
-
...props
|
|
424
|
+
...props ?? {}
|
|
251
425
|
}),
|
|
252
|
-
tel
|
|
426
|
+
/** Phone/tel input field with default placeholder. */
|
|
427
|
+
tel: (name, label, props) => ({
|
|
253
428
|
type: "tel",
|
|
254
429
|
name,
|
|
255
430
|
label,
|
|
256
431
|
placeholder: "+1 (555) 000-0000",
|
|
257
|
-
...props
|
|
432
|
+
...props ?? {}
|
|
258
433
|
}),
|
|
259
|
-
|
|
434
|
+
/** Password input field. */
|
|
435
|
+
password: (name, label, props) => ({
|
|
260
436
|
type: "password",
|
|
261
437
|
name,
|
|
262
438
|
label,
|
|
263
|
-
...props
|
|
439
|
+
...props ?? {}
|
|
264
440
|
}),
|
|
265
|
-
|
|
441
|
+
/** Number input field with min: 0 default (overrideable via props). */
|
|
442
|
+
number: (name, label, props) => ({
|
|
266
443
|
type: "number",
|
|
267
444
|
name,
|
|
268
445
|
label,
|
|
269
446
|
min: 0,
|
|
270
|
-
...props
|
|
447
|
+
...props ?? {}
|
|
271
448
|
}),
|
|
272
|
-
|
|
449
|
+
/** Textarea field with default 3 rows. */
|
|
450
|
+
textarea: (name, label, props) => ({
|
|
273
451
|
type: "textarea",
|
|
274
452
|
name,
|
|
275
453
|
label,
|
|
276
454
|
rows: 3,
|
|
277
|
-
...props
|
|
455
|
+
...props ?? {}
|
|
278
456
|
}),
|
|
279
|
-
|
|
457
|
+
/** Select dropdown field. */
|
|
458
|
+
select: (name, label, options, props) => ({
|
|
280
459
|
type: "select",
|
|
281
460
|
name,
|
|
282
461
|
label,
|
|
283
462
|
options,
|
|
284
|
-
...props
|
|
463
|
+
...props ?? {}
|
|
285
464
|
}),
|
|
286
|
-
|
|
465
|
+
/** Searchable combobox field. */
|
|
466
|
+
combobox: (name, label, options, props) => ({
|
|
287
467
|
type: "combobox",
|
|
288
468
|
name,
|
|
289
469
|
label,
|
|
290
470
|
options,
|
|
291
|
-
...props
|
|
471
|
+
...props ?? {}
|
|
292
472
|
}),
|
|
293
|
-
|
|
473
|
+
/** Multi-select field. */
|
|
474
|
+
multiselect: (name, label, options, props) => ({
|
|
294
475
|
type: "multiselect",
|
|
295
476
|
name,
|
|
296
477
|
label,
|
|
297
478
|
options,
|
|
298
479
|
placeholder: "Select options...",
|
|
299
|
-
...props
|
|
480
|
+
...props ?? {}
|
|
300
481
|
}),
|
|
301
|
-
|
|
482
|
+
/** Dependent select field that reacts to parent field changes. */
|
|
483
|
+
dependentSelect: (name, label, props) => ({
|
|
302
484
|
type: "dependentSelect",
|
|
303
485
|
name,
|
|
304
486
|
label,
|
|
305
|
-
...props
|
|
487
|
+
...props ?? {}
|
|
306
488
|
}),
|
|
307
|
-
|
|
489
|
+
/** Switch/toggle field. */
|
|
490
|
+
switch: (name, label, props) => ({
|
|
308
491
|
type: "switch",
|
|
309
492
|
name,
|
|
310
493
|
label,
|
|
311
|
-
...props
|
|
494
|
+
...props ?? {}
|
|
312
495
|
}),
|
|
313
|
-
|
|
496
|
+
/** Boolean field (alias for switch). */
|
|
497
|
+
boolean: (name, label, props) => ({
|
|
314
498
|
type: "switch",
|
|
315
499
|
name,
|
|
316
500
|
label,
|
|
317
|
-
...props
|
|
501
|
+
...props ?? {}
|
|
318
502
|
}),
|
|
319
|
-
|
|
503
|
+
/** Checkbox field. */
|
|
504
|
+
checkbox: (name, label, props) => ({
|
|
320
505
|
type: "checkbox",
|
|
321
506
|
name,
|
|
322
507
|
label,
|
|
323
|
-
...props
|
|
508
|
+
...props ?? {}
|
|
324
509
|
}),
|
|
325
|
-
|
|
510
|
+
/** Radio button group field. */
|
|
511
|
+
radio: (name, label, options, props) => ({
|
|
326
512
|
type: "radio",
|
|
327
513
|
name,
|
|
328
514
|
label,
|
|
329
515
|
options,
|
|
330
|
-
...props
|
|
516
|
+
...props ?? {}
|
|
331
517
|
}),
|
|
332
|
-
|
|
518
|
+
/** Date picker field. */
|
|
519
|
+
date: (name, label, props) => ({
|
|
333
520
|
type: "date",
|
|
334
521
|
name,
|
|
335
522
|
label,
|
|
336
|
-
...props
|
|
523
|
+
...props ?? {}
|
|
337
524
|
}),
|
|
338
|
-
|
|
525
|
+
/** Tag input field. */
|
|
526
|
+
tags: (name, label, props) => ({
|
|
339
527
|
type: "tags",
|
|
340
528
|
name,
|
|
341
529
|
label,
|
|
342
530
|
placeholder: "Add tags...",
|
|
343
|
-
...props
|
|
531
|
+
...props ?? {}
|
|
344
532
|
}),
|
|
345
|
-
|
|
533
|
+
/** Slug field. */
|
|
534
|
+
slug: (name, label, props) => ({
|
|
346
535
|
type: "slug",
|
|
347
536
|
name,
|
|
348
537
|
label,
|
|
349
538
|
placeholder: "my-page-slug",
|
|
350
|
-
...props
|
|
539
|
+
...props ?? {}
|
|
351
540
|
}),
|
|
352
|
-
|
|
541
|
+
/** File upload field. */
|
|
542
|
+
file: (name, label, props) => ({
|
|
353
543
|
type: "file",
|
|
354
544
|
name,
|
|
355
545
|
label,
|
|
356
|
-
...props
|
|
546
|
+
...props ?? {}
|
|
357
547
|
}),
|
|
358
|
-
|
|
548
|
+
/** Hidden field (no UI). */
|
|
549
|
+
hidden: (name, props) => ({
|
|
359
550
|
type: "hidden",
|
|
360
551
|
name,
|
|
361
|
-
...props
|
|
552
|
+
...props ?? {}
|
|
362
553
|
}),
|
|
363
|
-
|
|
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) => ({
|
|
364
568
|
type: "group",
|
|
365
569
|
name,
|
|
366
570
|
label,
|
|
367
571
|
itemFields,
|
|
368
|
-
...props
|
|
572
|
+
...props ?? {}
|
|
369
573
|
}),
|
|
370
|
-
|
|
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) => ({
|
|
371
586
|
type: "array",
|
|
372
587
|
name,
|
|
373
588
|
label,
|
|
374
589
|
itemFields,
|
|
375
|
-
...props
|
|
590
|
+
...props ?? {}
|
|
376
591
|
}),
|
|
377
|
-
|
|
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) => ({
|
|
378
622
|
type: "custom",
|
|
379
623
|
name,
|
|
380
624
|
label,
|
|
381
625
|
render,
|
|
382
|
-
...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)
|
|
383
678
|
})
|
|
384
679
|
};
|
|
385
680
|
/**
|
|
386
681
|
* Create a section definition with sensible defaults.
|
|
387
682
|
*
|
|
388
|
-
* @param id - Unique section identifier
|
|
389
|
-
* @param title - Section title
|
|
390
|
-
* @param fields - Array of field definitions
|
|
391
|
-
* @param props - Additional section configuration
|
|
392
|
-
*
|
|
393
683
|
* @example
|
|
394
684
|
* ```ts
|
|
395
685
|
* section("personal", "Personal Info", [
|
|
396
686
|
* field.text("name", "Name", { required: true }),
|
|
397
687
|
* field.email("email", "Email"),
|
|
398
|
-
* ], { cols: 2
|
|
688
|
+
* ], { cols: 2 })
|
|
399
689
|
* ```
|
|
400
690
|
*/
|
|
401
691
|
function section(id, title, fields, props = {}) {
|
|
@@ -411,6 +701,9 @@ function section(id, title, fields, props = {}) {
|
|
|
411
701
|
/**
|
|
412
702
|
* Create a section without a title (transparent section).
|
|
413
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.
|
|
414
707
|
*/
|
|
415
708
|
function sectionUntitled(fields, props = {}) {
|
|
416
709
|
const { cols = 1, ...rest } = props;
|
|
@@ -423,4 +716,4 @@ function sectionUntitled(fields, props = {}) {
|
|
|
423
716
|
}
|
|
424
717
|
|
|
425
718
|
//#endregion
|
|
426
|
-
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 };
|