@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/CHANGELOG.md +73 -0
- package/README.md +166 -20
- package/dist/index.d.mts +577 -164
- package/dist/index.mjs +1131 -294
- package/dist/server.d.mts +472 -151
- package/dist/server.mjs +593 -98
- package/package.json +116 -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
|
|
@@ -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
|
-
|
|
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 =
|
|
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`
|
|
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.
|
|
190
|
-
value
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
}
|
|
199
|
-
|
|
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
|
-
*
|
|
211
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
...props
|
|
649
|
+
...props ?? {}
|
|
271
650
|
}),
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
+
/** Hidden field (no UI). */
|
|
751
|
+
hidden: (name, props) => ({
|
|
359
752
|
type: "hidden",
|
|
360
753
|
name,
|
|
361
|
-
...props
|
|
754
|
+
...props ?? {}
|
|
362
755
|
}),
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|