@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 CHANGED
@@ -5,6 +5,79 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-07-02
9
+
10
+ Stability / hardening release. Two intentional **behavioral changes** are called
11
+ out below — read them before upgrading.
12
+
13
+ ### Behavioral changes
14
+
15
+ - **`field.number()` no longer injects `min: 0`.** The builder previously added
16
+ an implicit non-negative constraint; forms relying on it must now pass
17
+ `{ min: 0 }` explicitly. Signed quantities (deltas, temperatures) are valid by
18
+ default.
19
+ - **Missing components render a visible placeholder in production** (previously
20
+ a silent null). The wrong-widget `text` fallback for unregistered types was
21
+ removed entirely. Use the new `onMissingComponent` provider callback for
22
+ telemetry. Exception: `hidden` fields with no registered component render
23
+ nothing by design.
24
+
25
+ ### Added
26
+
27
+ - `validateSchema(schema)` — structural schema linting (missing name/type,
28
+ duplicate names, `itemFields` on non-containers, unknown DSL operators),
29
+ exported from both entries and auto-run in dev by `FormGenerator`.
30
+ - `focusFirstError(root?)` — focuses the first visibly-invalid field in layout
31
+ order; retries across two animation frames so it works from
32
+ `handleSubmit(onValid, () => focusFirstError())` on the very first submit.
33
+ - `loadOptions` now receives `{ signal: AbortSignal }` as a second argument;
34
+ superseded/unmounted loads are aborted. New opt-in `cacheOptions` memoizes
35
+ results per watched-value signature (bounded).
36
+ - `onMissingComponent` callback on `FormSystemProvider`.
37
+ - `colSpan` field prop (surfaced as `data-col-span`; `fullWidth` now also emits
38
+ an inline `grid-column: 1 / -1` so full-width layout works without scanning
39
+ the package with Tailwind).
40
+ - `resetOnSchemaChange` option on `useFormKit` for DB-loaded schema swaps.
41
+ - Dev warning when `loadOptions` is used without `watchNames` (whole-form
42
+ refetch storms).
43
+ - `buildFieldDefaults` exported; array-item seeds are now type-aware (`false`
44
+ for checkbox/switch, `[]` for multiselect/tags, `undefined` for number).
45
+ - `mergeDefaultValues(base, override)` exported (both entries) — the exact
46
+ Date-safe deep merge `useFormKit` applies between schema defaults and caller
47
+ `defaultValues`, for wrappers that re-seed via `form.reset(...)`.
48
+
49
+ ### Fixed
50
+
51
+ - **Rules-of-hooks crash**: `useFieldComponent` and the field-props memo are
52
+ hoisted above the group/array fallback returns, so a registry that changes
53
+ after mount (code-split adapters) can no longer change the hook count.
54
+ `eslint-plugin-react-hooks` is now enforced in CI-lint.
55
+ - **Date / class-instance defaults survive merging**: `useFormKit`'s deep-merge
56
+ now replaces non-plain objects (Date, File, class instances) wholesale
57
+ instead of spread-merging them into `{}`.
58
+ - Function conditions with `watchNames` receive properly **nested** watched
59
+ values (`(v) => v.address.city` works; previously flat `"address.city"` keys
60
+ made them throw and hide the field).
61
+ - `extractDefaultValues` recurses into nested groups (deep defaults were
62
+ dropped beyond one level); shares one walker with the async variant.
63
+ - Fallback UI (array items, error boundary, missing-component placeholder) uses
64
+ structural inline styles + `currentColor` instead of Tailwind classes that
65
+ don't exist in consumer builds; `formkit-*` classes remain as theming hooks.
66
+ - `process.env` reads are guarded (`isDev()` helper), so bundler-less ESM
67
+ environments no longer throw `ReferenceError`.
68
+
69
+ ## [1.4.0] - 2026-05-26
70
+
71
+ ### Added
72
+
73
+ - New schema helpers exported from `./schema`: `extractDefaultValuesAsync`, field-type predicates (`isChoiceField`, `isTextField`, `isNumericField`, `isDateField`, `isContainerField`, `isArrayField`, `isDynamicField`, `isConditionalField`), schema composition (`mergeSchemas`, `extendSection`, `pickFields`, `omitFields`, `flattenSchema`), and `applyServerErrors`.
74
+ - New exported types: `SectionRendererProps`, `GridRendererProps`, `FieldWrapperProps`, `ValidationRuleObject`, `PatternRuleObject`.
75
+ - Conditional package exports add `module-sync` condition for better React Server Components / sync-import interop.
76
+
77
+ ### Changed
78
+
79
+ - `main` / `module` fields removed from `package.json` in favor of pure conditional `exports`.
80
+
8
81
  ## [1.3.0] - 2026-02-27
9
82
 
10
83
  ### Added
package/README.md CHANGED
@@ -8,8 +8,12 @@ Headless, type-safe form generation engine for React 19. Schema-driven with full
8
8
 
9
9
  ## Features
10
10
 
11
- - **Minimal boilerplate** - `useFormKit` hook: 5 lines to set up a complete form
12
- - **Headless** - Bring your own UI components (Shadcn, MUI, Chakra, etc.)
11
+ - **Headless** - Bring your own UI components (Shadcn, MUI, Chakra, etc.) via a
12
+ one-time component/layout adapter. Already using shadcn? Skip the adapter
13
+ entirely with the prebuilt one in **[`@classytic/fluid/formkit`](https://www.npmjs.com/package/@classytic/fluid)** —
14
+ its `SchemaForm` is genuinely a schema + `onSubmit` away.
15
+ - **Minimal boilerplate** - once an adapter is registered, `useFormKit` wires a
16
+ full form (defaults + RHF + generator props) in a few lines
13
17
  - **Schema-driven** - Define forms with JSON/TypeScript schemas, defaults extracted automatically
14
18
  - **Type-safe** - Full TypeScript support with generics
15
19
  - **React Hook Form** - Built on top of the best form library, referentially stable return values
@@ -20,7 +24,7 @@ Headless, type-safe form generation engine for React 19. Schema-driven with full
20
24
  - **Responsive layouts** - Multi-column grid layouts
21
25
  - **Accessibility** - Auto-generated `fieldId`, `error`, and `fieldState` props
22
26
  - **Validation helpers** - `buildValidationRules` generates RHF rules from schema props
23
- - **Lightweight** - ~7KB gzipped, tree-shakeable
27
+ - **Lightweight** - ~17KB gzipped (peer deps excluded, everything included), tree-shakeable
24
28
 
25
29
  ## Requirements
26
30
 
@@ -54,28 +58,41 @@ import { Label } from "@/components/ui/label";
54
58
 
55
59
  export function FormInput({
56
60
  control,
57
- field,
61
+ name,
62
+ rules, // pre-computed RHF rules from schema (required, minLength, pattern, validate…)
58
63
  label,
59
64
  placeholder,
60
65
  required,
66
+ fieldId, // use as id on <input> and htmlFor on <Label>
67
+ errorId, // use as id on error <p> and aria-errormessage on <input>
68
+ shouldShowError, // true only after touch/submit — mirrors :user-invalid timing
61
69
  error,
62
- fieldId,
63
70
  }: FieldComponentProps) {
64
71
  return (
65
72
  <Controller
66
- name={field.name}
73
+ name={name}
67
74
  control={control}
68
- render={({ field: rhfField }) => (
75
+ rules={rules}
76
+ render={({ field }) => (
69
77
  <div className="space-y-2">
70
78
  {label && (
71
79
  <Label htmlFor={fieldId}>
72
80
  {label}
73
- {required && <span className="text-red-500 ml-1">*</span>}
81
+ {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
74
82
  </Label>
75
83
  )}
76
- <Input {...rhfField} id={fieldId} placeholder={placeholder} />
77
- {error && (
78
- <p className="text-sm text-red-500">{error.message}</p>
84
+ <Input
85
+ {...field}
86
+ id={fieldId}
87
+ placeholder={placeholder}
88
+ aria-required={required || undefined}
89
+ aria-invalid={shouldShowError || undefined}
90
+ aria-errormessage={shouldShowError ? errorId : undefined}
91
+ />
92
+ {shouldShowError && (
93
+ <p id={errorId} role="alert" className="text-sm text-red-500">
94
+ {error?.message}
95
+ </p>
79
96
  )}
80
97
  </div>
81
98
  )}
@@ -114,9 +131,16 @@ const layouts: LayoutRegistry = {
114
131
  {children}
115
132
  </div>
116
133
  ),
117
- grid: ({ children, cols = 1 }) => (
118
- <div className={`grid grid-cols-${cols} gap-4`}>{children}</div>
119
- ),
134
+ grid: ({ children, cols = 1 }) => {
135
+ // Map to STATIC class names. A template like `grid-cols-${cols}` is never
136
+ // generated by Tailwind's JIT (it only sees whole class strings in source).
137
+ const COLS: Record<number, string> = {
138
+ 1: "grid-cols-1",
139
+ 2: "grid-cols-1 md:grid-cols-2",
140
+ 3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
141
+ };
142
+ return <div className={`grid gap-4 ${COLS[cols] ?? "grid-cols-1"}`}>{children}</div>;
143
+ },
120
144
  };
121
145
 
122
146
  export function FormProvider({ children }: { children: React.ReactNode }) {
@@ -185,6 +209,26 @@ export default function SignupPage() {
185
209
  }
186
210
  ```
187
211
 
212
+ ## Type safety
213
+
214
+ Field **names** are enforced against your form type at *authoring* time by the
215
+ builders. Use `field.for<T>()`, `defineField<T>()`, or `section<T>()` and a typo
216
+ is a compile error:
217
+
218
+ ```ts
219
+ const f = field.for<SignupData>();
220
+ f.text("emial", "Email"); // ✗ compile error — "emial" is not a key of SignupData
221
+ f.text("email", "Email"); // ✓
222
+ ```
223
+
224
+ The `<FormGenerator schema>` / `useFormKit({ schema })` props themselves accept
225
+ `FormSchema<any>` **by design**: a schema's condition functions (`(values) => …`)
226
+ make `FormSchema<T>` contravariant in `T`, so pinning the prop to the form's `T`
227
+ would force a cast at every call site. Author with the typed builders for name
228
+ safety; the generator stays permissive so any built schema drops in without
229
+ ceremony. `itemFields` (group/array children) use relative names and are
230
+ resolved at render time, so they are typed loosely on purpose.
231
+
188
232
  ## API Reference
189
233
 
190
234
  ### useFormKit
@@ -203,6 +247,7 @@ const form = useFormKit({
203
247
  disabled: false, // optional
204
248
  variant: "compact", // optional
205
249
  className: "my-form", // optional
250
+ resetOnSchemaChange: true, // optional — re-seed on schema swap
206
251
  mode: "onBlur", // any useForm option
207
252
  });
208
253
 
@@ -221,7 +266,9 @@ return (
221
266
  );
222
267
  ```
223
268
 
224
- Schema `defaultValue` fields are automatically extracted and merged with any explicit `defaultValues` you provide (explicit values take priority).
269
+ Schema `defaultValue` fields are automatically extracted and merged with any explicit `defaultValues` you provide (explicit values take priority). Nested/namespaced defaults are extracted as a nested object (`{ address: { city } }`), so react-hook-form's dot-path resolution applies them correctly.
270
+
271
+ Pass `resetOnSchemaChange: true` to re-seed the form when the `schema` identity changes at runtime (e.g. a schema loaded from a DB is swapped in). It's off by default because a reset discards in-progress edits.
225
272
 
226
273
  `generatorProps` is memoized — it only recomputes when `schema`, `control`, `disabled`, `variant`, or `className` change.
227
274
 
@@ -277,7 +324,11 @@ interface Section<T> {
277
324
 
278
325
  ```ts
279
326
  interface BaseField<T> {
280
- name: string; // Field name (required)
327
+ // name accepts Path<T> or any string.
328
+ // Use field.for<T>() builders for call-site enforcement of valid paths.
329
+ // Relative names are correct for nameSpace sections and itemFields children —
330
+ // FormGenerator prefixes them at render time (e.g. "street" → "address.street").
331
+ name: Path<T> | string; // Field name (required)
281
332
  type: FieldType; // Field type (required)
282
333
  label?: string; // Field label
283
334
  placeholder?: string; // Placeholder text
@@ -298,8 +349,10 @@ interface BaseField<T> {
298
349
  loadOptions?: (formValues: Partial<T>) => Promise<FieldOption[]> | FieldOption[];
299
350
  debounceMs?: number;
300
351
 
301
- // For array/grouped types
302
- itemFields?: BaseField<T>[];
352
+ // Sub-fields for group and array types.
353
+ // Children use relative names ("street") — FormGenerator prefixes with parent name.
354
+ // Intentionally untyped to T because they resolve at render time, not authoring time.
355
+ itemFields?: BaseField[];
303
356
 
304
357
  // For select/radio/checkbox
305
358
  options?: FieldOption[];
@@ -342,9 +395,14 @@ interface FieldComponentProps<T extends FieldValues = FieldValues>
342
395
  isDirty: boolean;
343
396
  isTouched: boolean;
344
397
  isValidating: boolean;
398
+ isSubmitted: boolean;
345
399
  error?: FieldError;
346
400
  };
347
401
  fieldId: string; // Generated ID for label-input association (e.g. "formkit-field-email")
402
+ errorId: string; // ID for the error node — set as aria-errormessage on the input
403
+ shouldShowError: boolean; // true only after touch/submit (mirrors :user-invalid timing)
404
+ rules: RegisterOptions; // Pre-computed RHF rules from schema (required/min/max/pattern…)
405
+ isLoading?: boolean; // true while async loadOptions is in flight
348
406
  }
349
407
  ```
350
408
 
@@ -447,6 +505,64 @@ function FormInput({ field, control, error, fieldId }: FieldComponentProps) {
447
505
 
448
506
  Maps `required`, `min`, `max`, `minLength`, `maxLength`, and `pattern` from the field schema to RHF-compatible rules with auto-generated error messages.
449
507
 
508
+ ## Validation & helpers
509
+
510
+ ### validateSchema
511
+
512
+ Structurally validate a schema (great for DB-loaded / `resetOnSchemaChange`
513
+ schemas). Server-safe; returns `SchemaIssue[]` (empty ⇒ OK). `FormGenerator`
514
+ also runs it automatically in dev and `console.warn`s any issues.
515
+
516
+ ```ts
517
+ import { validateSchema } from "@classytic/formkit"; // or /server
518
+
519
+ for (const issue of validateSchema(schema)) {
520
+ console.warn(`${issue.severity} ${issue.path}: ${issue.message}`);
521
+ }
522
+ // Detects: missing name/type, duplicate names (namespace-aware),
523
+ // itemFields on a non-container, empty containers, unknown DSL operators.
524
+ ```
525
+
526
+ It validates **shape**, not your component registry — an unregistered field
527
+ `type` is a rendering concern, surfaced separately (see `onMissingComponent`).
528
+
529
+ ### focusFirstError
530
+
531
+ Focus + scroll to the first visibly-invalid field, in layout order. Wire it into
532
+ the submit's invalid handler:
533
+
534
+ ```tsx
535
+ import { focusFirstError } from "@classytic/formkit";
536
+
537
+ <form onSubmit={handleSubmit(onValid, () => focusFirstError())}>
538
+ ```
539
+
540
+ React Hook Form calls the invalid handler *before* React commits the re-render
541
+ that marks fields invalid, so if the immediate query finds nothing,
542
+ `focusFirstError` automatically retries on the next two animation frames — the
543
+ plain wiring above works on the very first submit. It returns `true` when a
544
+ field was focused immediately, `false` when the attempt was deferred.
545
+
546
+ ### onMissingComponent
547
+
548
+ If a field's `type` has no registered component, the form renders a **visible
549
+ placeholder** (dev and prod — never a silent drop or a wrong-widget text input)
550
+ and notifies this callback so you can log the gap. `hidden` fields are the one
551
+ exception: with no registered component they render nothing by design (the
552
+ value still lives in form state); register a `hidden` component to override.
553
+
554
+ ```tsx
555
+ <FormSystemProvider
556
+ components={components}
557
+ layouts={layouts}
558
+ onMissingComponent={(type) => reportToTelemetry("formkit_missing_component", { type })}
559
+ >
560
+ ```
561
+
562
+ > **Security note:** `buildValidationRules` compiles a field's `pattern` into a
563
+ > `new RegExp(...)`. If your schemas are ever authored by untrusted users, treat
564
+ > `pattern` as a ReDoS surface — validate/limit it before persisting.
565
+
450
566
  ## Server Components
451
567
 
452
568
  The `@classytic/formkit/server` entry point exports server-safe utilities with no React hooks or client-side code:
@@ -475,6 +591,24 @@ import type {
475
591
 
476
592
  Use this entry point in React Server Components to define schemas, evaluate conditions, or use `cn` without pulling in client-side code.
477
593
 
594
+ ### Passing a schema across the RSC → Client boundary
595
+
596
+ `FormGenerator` is a Client Component (it drives react-hook-form). If you build
597
+ a schema in a Server Component and pass it as a prop into the client, React
598
+ requires that prop to be **serializable** — so **function** members won't cross
599
+ the boundary:
600
+
601
+ - ❌ function `condition: (values) => …`, custom `render`, and `loadOptions`
602
+ (functions aren't serializable)
603
+ - ✅ **DSL** conditions (`{ watch, operator, value }`), and all the plain data
604
+ fields (`name`, `type`, `label`, `options`, `required`, `min`/`max`, …)
605
+
606
+ For fully static, server-defined schemas use DSL conditions. If a schema needs
607
+ function conditions / custom renders / async options, define it **inside the
608
+ Client Component** (e.g. the file that renders `FormGenerator`) rather than
609
+ passing it down from an RSC. This is a general Next.js constraint, not specific
610
+ to formkit.
611
+
478
612
  ## Advanced Features
479
613
 
480
614
  ### Conditional Fields (Function)
@@ -488,6 +622,12 @@ Use this entry point in React Server Components to define schemas, evaluate cond
488
622
  }
489
623
  ```
490
624
 
625
+ > **Performance:** a function condition can't be statically analyzed, so the
626
+ > field subscribes to the **whole form** and re-evaluates on every change. For
627
+ > large forms prefer a DSL rule (below, whose watched paths are auto-scoped), or
628
+ > add `watchNames` to scope the subscription yourself:
629
+ > `{ condition: (v) => v.accountType === "business", watchNames: ["accountType"] }`.
630
+
491
631
  ### Conditional Fields (DSL Rules)
492
632
 
493
633
  ```ts
@@ -585,14 +725,20 @@ const components = {
585
725
  name: "city",
586
726
  type: "select",
587
727
  watchNames: ["country"],
588
- loadOptions: async (values) => {
589
- const cities = await fetchCities(values.country);
728
+ loadOptions: async (values, { signal }) => {
729
+ // Forward `signal` so a superseded/unmounted load is cancelled.
730
+ const cities = await fetchCities(values.country, { signal });
590
731
  return cities.map(c => ({ label: c.name, value: c.id }));
591
732
  },
592
733
  debounceMs: 300,
734
+ cacheOptions: true, // reuse the last fetch when a dependency is re-selected
593
735
  }
594
736
  ```
595
737
 
738
+ > `cacheOptions` (default `false`) memoizes results per watched-value signature
739
+ > for the field's lifetime, so toggling `country` back and forth doesn't re-hit
740
+ > the API. Leave it off when the options can change server-side between loads.
741
+
596
742
  ### Custom Section Render
597
743
 
598
744
  ```ts