@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/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
- if (field.defaultValue !== void 0) defaults[`${prefix}${field.name}`] = field.defaultValue;
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[`${prefix}${field.name}.${sub.name}`] = sub.defaultValue;
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 = `${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`
190
+ if (field.required) rules.required = {
191
+ value: true,
192
+ message: `${field.label || field.name} is required`
184
193
  };
185
- if (field.min !== void 0) rules.min = {
186
- value: field.min,
187
- message: `Must be at least ${field.min}`
188
- };
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"
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
- } catch {
199
- console.warn(`[FormKit] Invalid regex pattern "${field.pattern}" in field "${field.name}", skipping.`);
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
- * Provides shorthand methods for common field types with sensible defaults,
211
- * reducing boilerplate while maintaining full type safety.
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
- * import { field, section } from '@classytic/formkit';
390
+ * // Untyped any string accepted (backwards compatible)
391
+ * field.text("email", "Email")
216
392
  *
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
- * };
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
- text: (name, label, props = {}) => ({
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
- email: (name, label, props = {}) => ({
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
- url: (name, label, props = {}) => ({
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: (name, label, props = {}) => ({
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
- password: (name, label, props = {}) => ({
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
- number: (name, label, props = {}) => ({
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
- textarea: (name, label, props = {}) => ({
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
- select: (name, label, options, props = {}) => ({
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
- combobox: (name, label, options, props = {}) => ({
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
- multiselect: (name, label, options, props = {}) => ({
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
- dependentSelect: (name, label, props = {}) => ({
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
- switch: (name, label, props = {}) => ({
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
- boolean: (name, label, props = {}) => ({
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
- checkbox: (name, label, props = {}) => ({
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
- radio: (name, label, options, props = {}) => ({
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
- date: (name, label, props = {}) => ({
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
- tags: (name, label, props = {}) => ({
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
- slug: (name, label, props = {}) => ({
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
- file: (name, label, props = {}) => ({
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
- hidden: (name, props = {}) => ({
548
+ /** Hidden field (no UI). */
549
+ hidden: (name, props) => ({
359
550
  type: "hidden",
360
551
  name,
361
- ...props
552
+ ...props ?? {}
362
553
  }),
363
- group: (name, label, itemFields, props = {}) => ({
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
- array: (name, label, itemFields, props = {}) => ({
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
- custom: (name, label, render, props = {}) => ({
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, variant: "card" })
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 };