@classytic/formkit 1.4.0 → 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.d.mts CHANGED
@@ -161,6 +161,13 @@ interface BaseField<TFieldValues extends FieldValues = FieldValues> {
161
161
  variant?: Variant;
162
162
  /** Whether field should span full width in grid */
163
163
  fullWidth?: boolean;
164
+ /**
165
+ * Span N columns of the section grid (e.g. `2` of a 3-col section). `1` (or
166
+ * unset) is the default single cell; `fullWidth` still means "span the whole
167
+ * row" and takes precedence. Applied as an inline `grid-column: span N` so it
168
+ * works regardless of the host's Tailwind content scan.
169
+ */
170
+ colSpan?: number;
164
171
  /** Custom CSS class name */
165
172
  className?: string;
166
173
  /**
@@ -222,13 +229,33 @@ interface BaseField<TFieldValues extends FieldValues = FieldValues> {
222
229
  /**
223
230
  * Dynamic options loaded based on current form values.
224
231
  * Useful for dependent selects (e.g., state depends on country).
232
+ *
233
+ * Receives an `AbortSignal` in the second arg — forward it to `fetch` (or
234
+ * abort your own request on it) so a superseded / unmounted load is cancelled
235
+ * instead of racing to completion:
236
+ *
237
+ * ```ts
238
+ * loadOptions: (values, { signal }) =>
239
+ * fetch(`/api/cities?country=${values.country}`, { signal }).then(r => r.json())
240
+ * ```
225
241
  */
226
- loadOptions?: (formValues: Partial<TFieldValues>) => Promise<(FieldOption | FieldOptionGroup)[]> | (FieldOption | FieldOptionGroup)[];
242
+ loadOptions?: (formValues: Partial<TFieldValues>, options?: {
243
+ signal: AbortSignal;
244
+ }) => Promise<(FieldOption | FieldOptionGroup)[]> | (FieldOption | FieldOptionGroup)[];
227
245
  /**
228
246
  * Error callback for loadOptions failures.
229
247
  * Called when loadOptions rejects. Defaults to console.error.
230
248
  */
231
249
  onLoadError?: (error: unknown) => void;
250
+ /**
251
+ * Memoize async `loadOptions` results per set of watched values, so toggling
252
+ * a dependency back and forth (e.g. re-selecting a country) reuses the last
253
+ * fetch instead of hitting the API again. Off by default — enable only when
254
+ * the options are stable for a given input, since the cache lives for the
255
+ * field's lifetime and won't see server-side changes. Bounded (LRU-ish) so it
256
+ * can't grow without limit.
257
+ */
258
+ cacheOptions?: boolean;
232
259
  /**
233
260
  * Sub-fields for `group` and `array` field types.
234
261
  *
@@ -591,6 +618,54 @@ declare function defineSection<TFieldValues extends FieldValues = FieldValues>(s
591
618
  * ```
592
619
  */
593
620
  declare function extractDefaultValues<TFieldValues extends FieldValues = FieldValues>(schema: FormSchema<TFieldValues>): Partial<TFieldValues>;
621
+ interface SchemaIssue {
622
+ /** Where the issue is, e.g. `sections[0].fields[2]` or `sections[1]`. */
623
+ path: string;
624
+ code: "missing-name" | "missing-type" | "duplicate-name" | "itemfields-on-noncontainer" | "empty-container" | "unknown-operator";
625
+ /** `error` = will misbehave at runtime; `warning` = suspicious but tolerated. */
626
+ severity: "error" | "warning";
627
+ message: string;
628
+ }
629
+ /**
630
+ * Structurally validate a form schema and return a list of issues (empty ⇒ OK).
631
+ * Server-safe (no hooks/DOM), so you can run it when a schema is loaded from a
632
+ * DB, in a test, or in a dev boot check. It validates SHAPE, not your component
633
+ * registry — an unknown field `type` is a registry concern, not a schema error.
634
+ *
635
+ * Checks: missing `name`/`type`, duplicate names (namespace-aware), `itemFields`
636
+ * on a non-container type, containers with no `itemFields`, and unknown DSL
637
+ * condition operators.
638
+ */
639
+ declare function validateSchema(schema: FormSchema): SchemaIssue[];
640
+ /**
641
+ * Build a default-value object for a flat list of fields — used to seed a new
642
+ * array item (or a group) from its `itemFields`.
643
+ *
644
+ * Recurses into nested `group` children (→ nested object) and seeds nested
645
+ * `array` children as `[]`, so appending an item never leaves a deep sub-field
646
+ * `undefined`. That matters because a missing deep field can trip a resolver
647
+ * (zod et al.) into a spurious "required" error the moment the row is added.
648
+ * Leaf fields without an explicit `defaultValue` seed to `""` (a controlled
649
+ * empty value RHF is happy with).
650
+ */
651
+ declare function buildFieldDefaults(fields: BaseField[] | undefined): Record<string, unknown>;
652
+ /**
653
+ * Deep-merge `override` onto `base` with default-values semantics: nested
654
+ * plain objects merge recursively; arrays, primitives, and class instances
655
+ * (Date, File, …) from `override` replace wholesale.
656
+ *
657
+ * This is the merge `useFormKit` applies between schema-extracted defaults and
658
+ * caller-provided `defaultValues`. Exported so wrappers that re-seed a form at
659
+ * runtime (e.g. an edit sheet swapping entities) can reproduce the exact same
660
+ * merge for `form.reset(...)`:
661
+ *
662
+ * @example
663
+ * ```ts
664
+ * const merged = mergeDefaultValues(extractDefaultValues(schema), entity);
665
+ * form.reset(merged);
666
+ * ```
667
+ */
668
+ declare function mergeDefaultValues(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown>;
594
669
  /**
595
670
  * Generates react-hook-form `RegisterOptions`-compatible validation rules
596
671
  * from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
@@ -719,7 +794,10 @@ declare const field: {
719
794
  email: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>; /** URL input field with default placeholder. */
720
795
  url: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>; /** Phone/tel input field with default placeholder. */
721
796
  tel: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>; /** Password input field. */
722
- password: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>; /** Number input field with min: 0 default (overrideable via props). */
797
+ password: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>;
798
+ /** Number input field. No implicit `min` — pass `{ min }` to add one, so the
799
+ * builder never injects validation the author didn't write (signed
800
+ * quantities like deltas / temperatures stay valid). */
723
801
  number: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>; /** Textarea field with default 3 rows. */
724
802
  textarea: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, props?: FieldProps<T>) => BaseField<T>; /** Select dropdown field. */
725
803
  select: <T extends FieldValues = FieldValues>(name: Path<T>, label: string, options: (FieldOption | FieldOptionGroup)[], props?: FieldProps<T>) => BaseField<T>; /** Searchable combobox field. */
@@ -864,4 +942,4 @@ declare function section<TFieldValues extends FieldValues = FieldValues>(id: str
864
942
  */
865
943
  declare function sectionUntitled(fields: BaseField[], props?: Omit<SectionProps, "variant">): Section;
866
944
  //#endregion
867
- export { type BaseField, type ClassValue, type Condition, type ConditionConfig, type ConditionRule, type DefaultLayoutProps, type DefineField, type FieldMeta, type FieldOption, type FieldOptionGroup, type FieldType, type FormSchema, type GridLayoutProps, type InferSchemaValues, type LayoutComponentProps, type LayoutType, type PatternRuleObject, type SchemaFieldNames, type Section, type SectionLayoutProps, type SectionRenderProps, type ValidationRuleObject, type Variant, buildValidationRules, cn, defineField, defineSchema, defineSection, evaluateCondition, extendSection, extractDefaultValues, extractWatchNames, field, flattenSchema, isArrayField, isChoiceField, isConditionalField, isContainerField, isDateField, isDynamicField, isNumericField, isTextField, mergeSchemas, omitFields, pickFields, section, sectionUntitled };
945
+ export { type BaseField, type ClassValue, type Condition, type ConditionConfig, type ConditionRule, type DefaultLayoutProps, type DefineField, type FieldMeta, type FieldOption, type FieldOptionGroup, type FieldType, type FormSchema, type GridLayoutProps, type InferSchemaValues, type LayoutComponentProps, type LayoutType, type PatternRuleObject, type SchemaFieldNames, type SchemaIssue, type Section, type SectionLayoutProps, type SectionRenderProps, type ValidationRuleObject, type Variant, 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 };
package/dist/server.mjs CHANGED
@@ -154,20 +154,221 @@ function defineSection(section) {
154
154
  function extractDefaultValues(schema) {
155
155
  const defaults = {};
156
156
  for (const section of schema.sections) {
157
- const prefix = section.nameSpace ? `${section.nameSpace}.` : "";
158
157
  if (!section.fields) continue;
159
- for (const field of section.fields) {
160
- const key = `${prefix}${field.name}`;
161
- if (field.defaultValue !== void 0) defaults[key] = field.defaultValue;
162
- else if (field.type === "array") defaults[key] = [];
163
- if (field.itemFields && field.type !== "array") {
164
- for (const sub of field.itemFields) if (sub.defaultValue !== void 0) defaults[`${key}.${sub.name}`] = sub.defaultValue;
165
- }
166
- }
158
+ collectExplicitDefaults(section.fields, section.nameSpace ?? "", defaults);
167
159
  }
168
160
  return defaults;
169
161
  }
170
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
+ /**
171
372
  * Generates react-hook-form `RegisterOptions`-compatible validation rules
172
373
  * from a field's schema props. Maps `required`, `min`, `max`, `minLength`,
173
374
  * `maxLength`, `pattern`, and `validate` to RHF rules.
@@ -438,12 +639,13 @@ const field = {
438
639
  label,
439
640
  ...props ?? {}
440
641
  }),
441
- /** Number input field with min: 0 default (overrideable via 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). */
442
645
  number: (name, label, props) => ({
443
646
  type: "number",
444
647
  name,
445
648
  label,
446
- min: 0,
447
649
  ...props ?? {}
448
650
  }),
449
651
  /** Textarea field with default 3 rows. */
@@ -716,4 +918,4 @@ function sectionUntitled(fields, props = {}) {
716
918
  }
717
919
 
718
920
  //#endregion
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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/formkit",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Headless, type-safe form generation engine for React 19. Schema-driven with full TypeScript support.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -96,6 +96,7 @@
96
96
  "@vitejs/plugin-react": "^5.2.0",
97
97
  "clsx": "^2.1.1",
98
98
  "eslint": "^9.39.4",
99
+ "eslint-plugin-react-hooks": "^7.1.1",
99
100
  "happy-dom": "^20.9.0",
100
101
  "react": "^19.2.6",
101
102
  "react-dom": "^19.2.6",