@classytic/formkit 1.3.1 → 1.5.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/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
@@ -145,22 +154,228 @@ function defineSection(section) {
145
154
  function extractDefaultValues(schema) {
146
155
  const defaults = {};
147
156
  for (const section of schema.sections) {
148
- const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
149
157
  if (!section.fields) continue;
150
- for (const field of section.fields) {
151
- if (field.defaultValue !== void 0) defaults[`${prefix}${field.name}`] = field.defaultValue;
152
- if (field.itemFields && field.type !== "array") {
153
- for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${prefix}${field.name}.${sub.name}`] = sub.defaultValue;
154
- }
155
- }
158
+ collectExplicitDefaults(section.fields, section.nameSpace ?? "", defaults);
156
159
  }
157
160
  return defaults;
158
161
  }
159
162
  /**
163
+ * Recursively assign a field list's EXPLICIT defaults (sparse — only fields
164
+ * that declare a `defaultValue`, plus `array` fields seeded to `[]`) into
165
+ * `target` at the given dot-path prefix. Recurses into nested `group` children
166
+ * so deeply-nested defaults aren't dropped; `array` children are left dynamic.
167
+ */
168
+ function collectExplicitDefaults(fields, prefix, target) {
169
+ for (const field of fields) {
170
+ const name = field.name;
171
+ const key = prefix ? `${prefix}.${name}` : name;
172
+ if (field.defaultValue !== void 0) assignPath(target, key, field.defaultValue);
173
+ else if (field.type === "array") assignPath(target, key, []);
174
+ else if (field.itemFields && field.itemFields.length > 0) collectExplicitDefaults(field.itemFields, key, target);
175
+ }
176
+ }
177
+ const VALID_OPERATORS = new Set([
178
+ "===",
179
+ "!==",
180
+ "in",
181
+ "not-in",
182
+ "truthy",
183
+ "falsy"
184
+ ]);
185
+ const CONTAINER_TYPES = new Set(["group", "array"]);
186
+ /**
187
+ * Structurally validate a form schema and return a list of issues (empty ⇒ OK).
188
+ * Server-safe (no hooks/DOM), so you can run it when a schema is loaded from a
189
+ * DB, in a test, or in a dev boot check. It validates SHAPE, not your component
190
+ * registry — an unknown field `type` is a registry concern, not a schema error.
191
+ *
192
+ * Checks: missing `name`/`type`, duplicate names (namespace-aware), `itemFields`
193
+ * on a non-container type, containers with no `itemFields`, and unknown DSL
194
+ * condition operators.
195
+ */
196
+ function validateSchema(schema) {
197
+ const issues = [];
198
+ const seen = /* @__PURE__ */ new Map();
199
+ const checkCondition = (condition, path) => {
200
+ if (!condition || typeof condition === "function") return;
201
+ const asObj = condition;
202
+ const rules = Array.isArray(condition) ? condition : Array.isArray(asObj.rules) ? asObj.rules : [condition];
203
+ for (const rule of rules) {
204
+ const op = rule?.operator;
205
+ if (op && !VALID_OPERATORS.has(op)) issues.push({
206
+ path,
207
+ code: "unknown-operator",
208
+ severity: "error",
209
+ message: `Unknown condition operator "${op}". Valid: ${[...VALID_OPERATORS].join(", ")}.`
210
+ });
211
+ }
212
+ };
213
+ const walkFields = (fields, prefix, locPath, dedup) => {
214
+ fields.forEach((field, i) => {
215
+ const loc = `${locPath}.fields[${i}]`;
216
+ const name = field.name;
217
+ if (!name) issues.push({
218
+ path: loc,
219
+ code: "missing-name",
220
+ severity: "error",
221
+ message: "Field is missing a `name`."
222
+ });
223
+ if (!field.type) issues.push({
224
+ path: loc,
225
+ code: "missing-type",
226
+ severity: "error",
227
+ message: `Field "${name ?? "?"}" is missing a \`type\`.`
228
+ });
229
+ if (name && dedup) {
230
+ const full = prefix ? `${prefix}.${name}` : name;
231
+ const prior = seen.get(full);
232
+ if (prior) issues.push({
233
+ path: loc,
234
+ code: "duplicate-name",
235
+ severity: "error",
236
+ message: `Duplicate field name "${full}" (also at ${prior}).`
237
+ });
238
+ else seen.set(full, loc);
239
+ }
240
+ const hasItems = !!field.itemFields && field.itemFields.length > 0;
241
+ const isContainer = CONTAINER_TYPES.has(field.type);
242
+ if (hasItems && !isContainer) issues.push({
243
+ path: loc,
244
+ code: "itemfields-on-noncontainer",
245
+ severity: "error",
246
+ message: `Field "${name}" has \`itemFields\` but type "${field.type}" is not a container (group/array).`
247
+ });
248
+ if (isContainer && !hasItems) issues.push({
249
+ path: loc,
250
+ code: "empty-container",
251
+ severity: "warning",
252
+ message: `Container field "${name}" (${field.type}) has no \`itemFields\`.`
253
+ });
254
+ checkCondition(field.condition, loc);
255
+ if (hasItems) {
256
+ const childDedup = field.type !== "array" && dedup;
257
+ const childPrefix = field.type === "array" ? "" : name ? prefix ? `${prefix}.${name}` : name : prefix;
258
+ walkFields(field.itemFields, childPrefix, loc, childDedup);
259
+ }
260
+ });
261
+ };
262
+ schema.sections.forEach((section, i) => {
263
+ const loc = `sections[${i}]`;
264
+ checkCondition(section.condition, loc);
265
+ if (section.fields) walkFields(section.fields, section.nameSpace ?? "", loc, true);
266
+ });
267
+ return issues;
268
+ }
269
+ /**
270
+ * Build a default-value object for a flat list of fields — used to seed a new
271
+ * array item (or a group) from its `itemFields`.
272
+ *
273
+ * Recurses into nested `group` children (→ nested object) and seeds nested
274
+ * `array` children as `[]`, so appending an item never leaves a deep sub-field
275
+ * `undefined`. That matters because a missing deep field can trip a resolver
276
+ * (zod et al.) into a spurious "required" error the moment the row is added.
277
+ * Leaf fields without an explicit `defaultValue` seed to `""` (a controlled
278
+ * empty value RHF is happy with).
279
+ */
280
+ function buildFieldDefaults(fields) {
281
+ const item = {};
282
+ if (!fields) return item;
283
+ for (const f of fields) {
284
+ const name = f.name;
285
+ if (f.defaultValue !== void 0) item[name] = f.defaultValue;
286
+ else if (f.type === "array") item[name] = [];
287
+ else if (f.itemFields && f.itemFields.length > 0) item[name] = buildFieldDefaults(f.itemFields);
288
+ else item[name] = emptyForType(f.type);
289
+ }
290
+ return item;
291
+ }
292
+ /**
293
+ * Type-appropriate "empty" seed for a leaf field that declares no
294
+ * `defaultValue`. A blanket `""` is wrong for non-text fields — a checkbox
295
+ * seeded to `""` is neither on nor off, and a numeric field coerces `""` to
296
+ * `NaN`. Text-like fields fall through to `""`.
297
+ */
298
+ function emptyForType(type) {
299
+ switch (type) {
300
+ case "checkbox":
301
+ case "switch":
302
+ case "boolean": return false;
303
+ case "number":
304
+ case "money": return;
305
+ case "multiselect":
306
+ case "tags": return [];
307
+ default: return "";
308
+ }
309
+ }
310
+ /**
311
+ * Assign a possibly dot-notated key into a NESTED object shape:
312
+ * `assignPath(obj, "address.city", "NYC")` → `obj.address.city = "NYC"`.
313
+ *
314
+ * Nested (not flat `{"address.city": ...}`) is required for react-hook-form:
315
+ * its `get(defaultValues, "address.city")` resolves through the object graph,
316
+ * so a flat dotted key would never be found and the field's default would
317
+ * silently not apply. Top-level keys (no dot) are assigned directly.
318
+ */
319
+ function assignPath(target, path, value) {
320
+ if (!path.includes(".")) {
321
+ target[path] = value;
322
+ return;
323
+ }
324
+ const parts = path.split(".");
325
+ const last = parts.length - 1;
326
+ let node = target;
327
+ for (let i = 0; i < last; i++) {
328
+ const key = parts[i];
329
+ const next = node[key];
330
+ if (typeof next !== "object" || next === null || Array.isArray(next)) node[key] = {};
331
+ node = node[key];
332
+ }
333
+ node[parts[last]] = value;
334
+ }
335
+ /**
336
+ * True for objects that are safe to spread-merge: plain `{}` records (or
337
+ * `Object.create(null)`). A Date / File / class instance is `typeof "object"`
338
+ * but must be REPLACED wholesale, never spread-merged — `{ ...new Date() }` is
339
+ * `{}`, which would silently destroy the value.
340
+ */
341
+ function isPlainObject(v) {
342
+ if (typeof v !== "object" || v === null) return false;
343
+ const proto = Object.getPrototypeOf(v);
344
+ return proto === Object.prototype || proto === null;
345
+ }
346
+ /**
347
+ * Deep-merge `override` onto `base` with default-values semantics: nested
348
+ * plain objects merge recursively; arrays, primitives, and class instances
349
+ * (Date, File, …) from `override` replace wholesale.
350
+ *
351
+ * This is the merge `useFormKit` applies between schema-extracted defaults and
352
+ * caller-provided `defaultValues`. Exported so wrappers that re-seed a form at
353
+ * runtime (e.g. an edit sheet swapping entities) can reproduce the exact same
354
+ * merge for `form.reset(...)`:
355
+ *
356
+ * @example
357
+ * ```ts
358
+ * const merged = mergeDefaultValues(extractDefaultValues(schema), entity);
359
+ * form.reset(merged);
360
+ * ```
361
+ */
362
+ function mergeDefaultValues(base, override) {
363
+ const out = { ...base };
364
+ for (const key of Object.keys(override)) {
365
+ const b = out[key];
366
+ const o = override[key];
367
+ out[key] = isPlainObject(b) && isPlainObject(o) ? mergeDefaultValues(b, o) : o;
368
+ }
369
+ return out;
370
+ }
371
+ /**
160
372
  * Generates react-hook-form `RegisterOptions`-compatible validation rules
161
373
  * from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
162
374
  * `maxLength`, `pattern`, and `validate` to RHF rules.
163
375
  *
376
+ * Supports both shorthand scalars and `{ value, message }` objects for all
377
+ * numeric/length rules, and `{ regex, message }` for pattern.
378
+ *
164
379
  * @example
165
380
  * ```tsx
166
381
  * import { buildValidationRules } from '@classytic/formkit';
@@ -173,229 +388,506 @@ function extractDefaultValues(schema) {
173
388
  */
174
389
  function buildValidationRules(field) {
175
390
  const rules = {};
176
- if (field.required) rules.required = `${field.label || field.name} is required`;
177
- if (field.minLength !== void 0) rules.minLength = {
178
- value: field.minLength,
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`
184
- };
185
- if (field.min !== void 0) rules.min = {
186
- value: field.min,
187
- message: `Must be at least ${field.min}`
391
+ if (field.required) rules.required = {
392
+ value: true,
393
+ message: `${field.label || field.name} is required`
188
394
  };
189
- if (field.max !== void 0) rules.max = {
190
- value: field.max,
191
- message: `Must be at most ${field.max}`
192
- };
193
- if (field.pattern) try {
194
- rules.pattern = {
195
- value: new RegExp(field.pattern),
196
- message: "Invalid format"
395
+ if (field.minLength !== void 0) {
396
+ const { value, message } = resolveRuleObject(field.minLength, (v) => `At least ${v} characters`);
397
+ rules.minLength = {
398
+ value,
399
+ message
400
+ };
401
+ }
402
+ if (field.maxLength !== void 0) {
403
+ const { value, message } = resolveRuleObject(field.maxLength, (v) => `At most ${v} characters`);
404
+ rules.maxLength = {
405
+ value,
406
+ message
407
+ };
408
+ }
409
+ if (field.min !== void 0) {
410
+ const { value, message } = resolveRuleObject(field.min, (v) => `Must be at least ${v}`);
411
+ rules.min = {
412
+ value,
413
+ message
414
+ };
415
+ }
416
+ if (field.max !== void 0) {
417
+ const { value, message } = resolveRuleObject(field.max, (v) => `Must be at most ${v}`);
418
+ rules.max = {
419
+ value,
420
+ message
197
421
  };
198
- } catch {
199
- console.warn(`[FormKit] Invalid regex pattern "${field.pattern}" in field "${field.name}", skipping.`);
422
+ }
423
+ if (field.pattern) {
424
+ const isObject = typeof field.pattern === "object";
425
+ const regexStr = isObject ? field.pattern.regex : field.pattern;
426
+ const message = isObject ? field.pattern.message : "Invalid format";
427
+ try {
428
+ rules.pattern = {
429
+ value: new RegExp(regexStr),
430
+ message
431
+ };
432
+ } catch {
433
+ console.warn(`[FormKit] Invalid regex pattern "${regexStr}" in field "${field.name}", skipping.`);
434
+ }
200
435
  }
201
436
  if (field.validate) rules.validate = field.validate;
202
437
  return rules;
203
438
  }
439
+ /** Returns true for fields that carry an `options` array (select, radio, etc.) */
440
+ function isChoiceField(field) {
441
+ return [
442
+ "select",
443
+ "combobox",
444
+ "multiselect",
445
+ "dependentSelect",
446
+ "radio",
447
+ "checkbox"
448
+ ].includes(field.type);
449
+ }
450
+ /** Returns true for free-text input fields */
451
+ function isTextField(field) {
452
+ return [
453
+ "text",
454
+ "email",
455
+ "password",
456
+ "tel",
457
+ "phone",
458
+ "url",
459
+ "slug",
460
+ "textarea",
461
+ "rich-text"
462
+ ].includes(field.type);
463
+ }
464
+ /** Returns true for numeric input fields */
465
+ function isNumericField(field) {
466
+ return ["number", "rating"].includes(field.type);
467
+ }
468
+ /** Returns true for date / time fields */
469
+ function isDateField(field) {
470
+ return [
471
+ "date",
472
+ "time",
473
+ "datetime"
474
+ ].includes(field.type);
475
+ }
476
+ /** Returns true for structural fields that contain sub-fields (`itemFields`) */
477
+ function isContainerField(field) {
478
+ return ["group", "array"].includes(field.type);
479
+ }
480
+ /** Returns true for array fields that render a repeatable list */
481
+ function isArrayField(field) {
482
+ return field.type === "array";
483
+ }
484
+ /** Returns true for fields that load options asynchronously */
485
+ function isDynamicField(field) {
486
+ return !!field.loadOptions;
487
+ }
488
+ /** Returns true for fields with conditional rendering */
489
+ function isConditionalField(field) {
490
+ return field.condition !== void 0;
491
+ }
492
+ /**
493
+ * Merge two or more schemas into one, concatenating their sections.
494
+ *
495
+ * @example
496
+ * ```ts
497
+ * const full = mergeSchemas(personalSchema, addressSchema, billingSchema);
498
+ * ```
499
+ */
500
+ function mergeSchemas(...schemas) {
501
+ return { sections: schemas.flatMap((s) => s.sections) };
502
+ }
503
+ /**
504
+ * Add fields to a section identified by `sectionId`.
505
+ * Returns a new schema — the original is not mutated.
506
+ *
507
+ * @example
508
+ * ```ts
509
+ * const extended = extendSection(schema, "personal", [
510
+ * field.text("middleName", "Middle Name"),
511
+ * ]);
512
+ * ```
513
+ */
514
+ function extendSection(schema, sectionId, fields, position = "end") {
515
+ return {
516
+ ...schema,
517
+ sections: schema.sections.map((section) => {
518
+ if (section.id !== sectionId) return section;
519
+ const existing = section.fields ?? [];
520
+ return {
521
+ ...section,
522
+ fields: position === "start" ? [...fields, ...existing] : [...existing, ...fields]
523
+ };
524
+ })
525
+ };
526
+ }
527
+ /**
528
+ * Create a new schema that includes only the named fields.
529
+ *
530
+ * @example
531
+ * ```ts
532
+ * const slim = pickFields(schema, ["email", "password"]);
533
+ * ```
534
+ */
535
+ function pickFields(schema, names) {
536
+ const nameSet = new Set(names);
537
+ return {
538
+ ...schema,
539
+ sections: schema.sections.map((section) => ({
540
+ ...section,
541
+ fields: (section.fields ?? []).filter((f) => nameSet.has(f.name))
542
+ })).filter((section) => (section.fields?.length ?? 0) > 0)
543
+ };
544
+ }
545
+ /**
546
+ * Create a new schema that excludes the named fields.
547
+ *
548
+ * @example
549
+ * ```ts
550
+ * const withoutInternal = omitFields(schema, ["__id", "__createdAt"]);
551
+ * ```
552
+ */
553
+ function omitFields(schema, names) {
554
+ const nameSet = new Set(names);
555
+ return {
556
+ ...schema,
557
+ sections: schema.sections.map((section) => ({
558
+ ...section,
559
+ fields: (section.fields ?? []).filter((f) => !nameSet.has(f.name))
560
+ }))
561
+ };
562
+ }
563
+ /**
564
+ * Collect every field from every section into a flat array.
565
+ * Useful for validation, documentation, and AI schema introspection.
566
+ *
567
+ * @example
568
+ * ```ts
569
+ * const allFields = flattenSchema(schema);
570
+ * const required = allFields.filter(f => f.required);
571
+ * ```
572
+ */
573
+ function flattenSchema(schema) {
574
+ return schema.sections.flatMap((s) => s.fields ?? []);
575
+ }
204
576
 
205
577
  //#endregion
206
578
  //#region src/builders.ts
207
579
  /**
208
580
  * Type-safe field builder helpers for schema-driven forms.
209
581
  *
210
- * Provides shorthand methods for common field types with sensible defaults,
211
- * reducing boilerplate while maintaining full type safety.
582
+ * All methods are generic over TFieldValues, defaulting to FieldValues (any string)
583
+ * when no type argument is provided. Specify the generic to enforce that field
584
+ * names are valid paths in your form values type.
585
+ *
586
+ * For fully-typed schemas where every field name is checked, prefer
587
+ * `field.for<MyForm>()` which fixes the generic once for the whole schema:
212
588
  *
213
589
  * @example
214
590
  * ```ts
215
- * import { field, section } from '@classytic/formkit';
591
+ * // Untyped any string accepted (backwards compatible)
592
+ * field.text("email", "Email")
593
+ *
594
+ * // Per-call generic — name is checked against MyForm
595
+ * field.text<MyForm>("email", "Email")
216
596
  *
217
- * const schema = {
218
- * sections: [
219
- * section("personal", "Personal Info", [
220
- * field.text("firstName", "First Name", { required: true }),
221
- * field.email("email", "Email"),
222
- * field.select("role", "Role", [
223
- * { label: "Admin", value: "admin" },
224
- * { label: "User", value: "user" },
225
- * ]),
226
- * ], { cols: 2 }),
227
- * ],
228
- * };
597
+ * // Typed factory — name checked on every call without repeating the generic
598
+ * const f = field.for<MyForm>()
599
+ * f.text("email", "Email") //
600
+ * f.text("typo", "Email") // TypeScript error
229
601
  * ```
230
602
  */
231
603
  const field = {
232
- text: (name, label, props = {}) => ({
604
+ /** Text input field. */
605
+ text: (name, label, props) => ({
233
606
  type: "text",
234
607
  name,
235
608
  label,
236
- ...props
609
+ ...props ?? {}
237
610
  }),
238
- email: (name, label, props = {}) => ({
611
+ /** Email input field with default placeholder. */
612
+ email: (name, label, props) => ({
239
613
  type: "email",
240
614
  name,
241
615
  label,
242
616
  placeholder: "example@email.com",
243
- ...props
617
+ ...props ?? {}
244
618
  }),
245
- url: (name, label, props = {}) => ({
619
+ /** URL input field with default placeholder. */
620
+ url: (name, label, props) => ({
246
621
  type: "url",
247
622
  name,
248
623
  label,
249
624
  placeholder: "https://example.com",
250
- ...props
625
+ ...props ?? {}
251
626
  }),
252
- tel: (name, label, props = {}) => ({
627
+ /** Phone/tel input field with default placeholder. */
628
+ tel: (name, label, props) => ({
253
629
  type: "tel",
254
630
  name,
255
631
  label,
256
632
  placeholder: "+1 (555) 000-0000",
257
- ...props
633
+ ...props ?? {}
258
634
  }),
259
- password: (name, label, props = {}) => ({
635
+ /** Password input field. */
636
+ password: (name, label, props) => ({
260
637
  type: "password",
261
638
  name,
262
639
  label,
263
- ...props
640
+ ...props ?? {}
264
641
  }),
265
- number: (name, label, props = {}) => ({
642
+ /** Number input field. No implicit `min` — pass `{ min }` to add one, so the
643
+ * builder never injects validation the author didn't write (signed
644
+ * quantities like deltas / temperatures stay valid). */
645
+ number: (name, label, props) => ({
266
646
  type: "number",
267
647
  name,
268
648
  label,
269
- min: 0,
270
- ...props
649
+ ...props ?? {}
271
650
  }),
272
- textarea: (name, label, props = {}) => ({
651
+ /** Textarea field with default 3 rows. */
652
+ textarea: (name, label, props) => ({
273
653
  type: "textarea",
274
654
  name,
275
655
  label,
276
656
  rows: 3,
277
- ...props
657
+ ...props ?? {}
278
658
  }),
279
- select: (name, label, options, props = {}) => ({
659
+ /** Select dropdown field. */
660
+ select: (name, label, options, props) => ({
280
661
  type: "select",
281
662
  name,
282
663
  label,
283
664
  options,
284
- ...props
665
+ ...props ?? {}
285
666
  }),
286
- combobox: (name, label, options, props = {}) => ({
667
+ /** Searchable combobox field. */
668
+ combobox: (name, label, options, props) => ({
287
669
  type: "combobox",
288
670
  name,
289
671
  label,
290
672
  options,
291
- ...props
673
+ ...props ?? {}
292
674
  }),
293
- multiselect: (name, label, options, props = {}) => ({
675
+ /** Multi-select field. */
676
+ multiselect: (name, label, options, props) => ({
294
677
  type: "multiselect",
295
678
  name,
296
679
  label,
297
680
  options,
298
681
  placeholder: "Select options...",
299
- ...props
682
+ ...props ?? {}
300
683
  }),
301
- dependentSelect: (name, label, props = {}) => ({
684
+ /** Dependent select field that reacts to parent field changes. */
685
+ dependentSelect: (name, label, props) => ({
302
686
  type: "dependentSelect",
303
687
  name,
304
688
  label,
305
- ...props
689
+ ...props ?? {}
306
690
  }),
307
- switch: (name, label, props = {}) => ({
691
+ /** Switch/toggle field. */
692
+ switch: (name, label, props) => ({
308
693
  type: "switch",
309
694
  name,
310
695
  label,
311
- ...props
696
+ ...props ?? {}
312
697
  }),
313
- boolean: (name, label, props = {}) => ({
698
+ /** Boolean field (alias for switch). */
699
+ boolean: (name, label, props) => ({
314
700
  type: "switch",
315
701
  name,
316
702
  label,
317
- ...props
703
+ ...props ?? {}
318
704
  }),
319
- checkbox: (name, label, props = {}) => ({
705
+ /** Checkbox field. */
706
+ checkbox: (name, label, props) => ({
320
707
  type: "checkbox",
321
708
  name,
322
709
  label,
323
- ...props
710
+ ...props ?? {}
324
711
  }),
325
- radio: (name, label, options, props = {}) => ({
712
+ /** Radio button group field. */
713
+ radio: (name, label, options, props) => ({
326
714
  type: "radio",
327
715
  name,
328
716
  label,
329
717
  options,
330
- ...props
718
+ ...props ?? {}
331
719
  }),
332
- date: (name, label, props = {}) => ({
720
+ /** Date picker field. */
721
+ date: (name, label, props) => ({
333
722
  type: "date",
334
723
  name,
335
724
  label,
336
- ...props
725
+ ...props ?? {}
337
726
  }),
338
- tags: (name, label, props = {}) => ({
727
+ /** Tag input field. */
728
+ tags: (name, label, props) => ({
339
729
  type: "tags",
340
730
  name,
341
731
  label,
342
732
  placeholder: "Add tags...",
343
- ...props
733
+ ...props ?? {}
344
734
  }),
345
- slug: (name, label, props = {}) => ({
735
+ /** Slug field. */
736
+ slug: (name, label, props) => ({
346
737
  type: "slug",
347
738
  name,
348
739
  label,
349
740
  placeholder: "my-page-slug",
350
- ...props
741
+ ...props ?? {}
351
742
  }),
352
- file: (name, label, props = {}) => ({
743
+ /** File upload field. */
744
+ file: (name, label, props) => ({
353
745
  type: "file",
354
746
  name,
355
747
  label,
356
- ...props
748
+ ...props ?? {}
357
749
  }),
358
- hidden: (name, props = {}) => ({
750
+ /** Hidden field (no UI). */
751
+ hidden: (name, props) => ({
359
752
  type: "hidden",
360
753
  name,
361
- ...props
754
+ ...props ?? {}
362
755
  }),
363
- group: (name, label, itemFields, props = {}) => ({
756
+ /**
757
+ * Group field for nested objects.
758
+ * Renders itemFields as a sub-grid. Child names are relative (e.g. "street"),
759
+ * FormGenerator prefixes them with the group name at render time.
760
+ *
761
+ * @example
762
+ * ```ts
763
+ * field.group("address", "Address", [
764
+ * field.text("street", "Street"),
765
+ * field.text("city", "City"),
766
+ * ], { cols: 2 })
767
+ * ```
768
+ */
769
+ group: (name, label, itemFields, props) => ({
364
770
  type: "group",
365
771
  name,
366
772
  label,
367
773
  itemFields,
368
- ...props
774
+ ...props ?? {}
369
775
  }),
370
- array: (name, label, itemFields, props = {}) => ({
776
+ /**
777
+ * Array/repeatable field backed by react-hook-form's useFieldArray.
778
+ *
779
+ * @example
780
+ * ```ts
781
+ * field.array("contacts", "Contacts", [
782
+ * field.text("name", "Name"),
783
+ * field.email("email", "Email"),
784
+ * ])
785
+ * ```
786
+ */
787
+ array: (name, label, itemFields, props) => ({
371
788
  type: "array",
372
789
  name,
373
790
  label,
374
791
  itemFields,
375
- ...props
792
+ ...props ?? {}
376
793
  }),
377
- custom: (name, label, render, props = {}) => ({
794
+ /**
795
+ * Custom field with a render function.
796
+ * Bypasses the component registry — full control over rendering.
797
+ *
798
+ * The render callback receives the complete `FieldComponentProps` including
799
+ * `fieldId`, `errorId`, `shouldShowError`, `error`, `rules`, and `control`.
800
+ *
801
+ * Use `shouldShowError` (not `!!error`) to drive `aria-invalid` and error
802
+ * visibility so timing mirrors the CSS `:user-invalid` pseudo-class.
803
+ *
804
+ * @example
805
+ * ```tsx
806
+ * field.custom("skills", "Skills", ({ control, shouldShowError, errorId, error, fieldId }) => (
807
+ * <div>
808
+ * <SkillSelector
809
+ * id={fieldId}
810
+ * control={control}
811
+ * aria-invalid={shouldShowError || undefined}
812
+ * aria-errormessage={shouldShowError ? errorId : undefined}
813
+ * />
814
+ * {shouldShowError && (
815
+ * <p id={errorId} role="alert" className="text-sm text-destructive">
816
+ * {error?.message}
817
+ * </p>
818
+ * )}
819
+ * </div>
820
+ * ))
821
+ * ```
822
+ */
823
+ custom: (name, label, render, props) => ({
378
824
  type: "custom",
379
825
  name,
380
826
  label,
381
827
  render,
382
- ...props
828
+ ...props ?? {}
829
+ }),
830
+ /**
831
+ * Returns a typed field builder with `TFieldValues` fixed.
832
+ * Every field name is validated against `Path<TFieldValues>` at the call site —
833
+ * no need to repeat the generic on each individual builder call.
834
+ *
835
+ * @example
836
+ * ```ts
837
+ * interface ContactForm {
838
+ * firstName: string;
839
+ * email: string;
840
+ * address: { street: string; city: string };
841
+ * }
842
+ *
843
+ * const f = field.for<ContactForm>()
844
+ *
845
+ * const schema = defineSchema<ContactForm>({
846
+ * sections: [{
847
+ * fields: [
848
+ * f.text("firstName", "First Name"), // ✓
849
+ * f.email("email", "Email"), // ✓
850
+ * f.text("typo", "Label"), // ✗ TypeScript error
851
+ * ],
852
+ * }],
853
+ * })
854
+ * ```
855
+ */
856
+ for: () => ({
857
+ text: (name, label, props) => field.text(name, label, props),
858
+ email: (name, label, props) => field.email(name, label, props),
859
+ url: (name, label, props) => field.url(name, label, props),
860
+ tel: (name, label, props) => field.tel(name, label, props),
861
+ password: (name, label, props) => field.password(name, label, props),
862
+ number: (name, label, props) => field.number(name, label, props),
863
+ textarea: (name, label, props) => field.textarea(name, label, props),
864
+ select: (name, label, options, props) => field.select(name, label, options, props),
865
+ combobox: (name, label, options, props) => field.combobox(name, label, options, props),
866
+ multiselect: (name, label, options, props) => field.multiselect(name, label, options, props),
867
+ dependentSelect: (name, label, props) => field.dependentSelect(name, label, props),
868
+ switch: (name, label, props) => field.switch(name, label, props),
869
+ boolean: (name, label, props) => field.boolean(name, label, props),
870
+ checkbox: (name, label, props) => field.checkbox(name, label, props),
871
+ radio: (name, label, options, props) => field.radio(name, label, options, props),
872
+ date: (name, label, props) => field.date(name, label, props),
873
+ tags: (name, label, props) => field.tags(name, label, props),
874
+ slug: (name, label, props) => field.slug(name, label, props),
875
+ file: (name, label, props) => field.file(name, label, props),
876
+ hidden: (name, props) => field.hidden(name, props),
877
+ group: (name, label, itemFields, props) => field.group(name, label, itemFields, props),
878
+ array: (name, label, itemFields, props) => field.array(name, label, itemFields, props),
879
+ custom: (name, label, render, builderProps) => field.custom(name, label, render, builderProps)
383
880
  })
384
881
  };
385
882
  /**
386
883
  * Create a section definition with sensible defaults.
387
884
  *
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
885
  * @example
394
886
  * ```ts
395
887
  * section("personal", "Personal Info", [
396
888
  * field.text("name", "Name", { required: true }),
397
889
  * field.email("email", "Email"),
398
- * ], { cols: 2, variant: "card" })
890
+ * ], { cols: 2 })
399
891
  * ```
400
892
  */
401
893
  function section(id, title, fields, props = {}) {
@@ -411,6 +903,9 @@ function section(id, title, fields, props = {}) {
411
903
  /**
412
904
  * Create a section without a title (transparent section).
413
905
  * Useful for grouping fields without visual separation.
906
+ *
907
+ * Accepts `BaseField[]` (no generic) so mixed-type field arrays don't trigger
908
+ * conflicting type inference across different field name generics.
414
909
  */
415
910
  function sectionUntitled(fields, props = {}) {
416
911
  const { cols = 1, ...rest } = props;
@@ -423,4 +918,4 @@ function sectionUntitled(fields, props = {}) {
423
918
  }
424
919
 
425
920
  //#endregion
426
- export { buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extractDefaultValues, extractWatchNames, field, section, sectionUntitled };
921
+ export { buildFieldDefaults, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extendSection, extractDefaultValues, extractWatchNames, field, flattenSchema, isArrayField, isChoiceField, isConditionalField, isContainerField, isDateField, isDynamicField, isNumericField, isTextField, mergeDefaultValues, mergeSchemas, omitFields, pickFields, section, sectionUntitled, validateSchema };