@uniform-ts/core 0.0.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/README.md ADDED
@@ -0,0 +1,1122 @@
1
+ # UniForm
2
+
3
+ > Headless React + Zod V4 form library. Zero styles — bring your own components.
4
+
5
+ UniForm takes a Zod schema and automatically renders a fully customizable form. It handles introspection, validation, coercion, and layout — you provide the components and styling.
6
+
7
+ ## Features
8
+
9
+ - **Schema-driven** — define your form once with Zod V4, get inputs, labels, validation, and types for free
10
+ - **Headless** — zero CSS, zero opinions; bring your own design system
11
+ - **Full Zod V4 support** — scalars, enums, objects, arrays, optionals, nullables, defaults, pipes/transforms, unions, discriminated unions
12
+ - **react-hook-form** under the hood — performant, uncontrolled forms with `zodResolver`
13
+ - **`createForm()` / `UniForm`** — type-safe form definition object that lives outside React; attach typed `setOnChange` handlers per field with access to all form methods
14
+ - **Per-field `onChange` in `fields` prop** — react to individual field changes inline, with typed values and full form control methods
15
+ - **Per-field custom components** — pass any `React.ComponentType<FieldProps>` directly as `meta.component` (inline, no registry) or register under a custom string key; direct components bypass the registry _and_ the default `ArrayField`/`ObjectField` routing, allowing fully custom multi-value widgets for `array`-typed fields
16
+ - **Layout hooks** — `classNames`, `fieldWrapper`, `layout.formWrapper`, `layout.sectionWrapper`, `layout.submitButton`
17
+ - **Section grouping** — group fields into named sections via `meta.section`
18
+ - **Conditional fields** — show/hide fields based on form values with `meta.condition`; hidden fields automatically reset to their default value
19
+ - **Field ordering** — control render order with `meta.order`
20
+ - **`createAutoForm()` factory** — bake in your design system defaults once, use everywhere
21
+ - **Deep field overrides** — dot-notated `fields` prop for nested object/array overrides
22
+ - **Pluggable coercion** — automatic string→number, string→Date with customizable coercion map
23
+ - **Custom validation messages** — global, per-field, and per-field-per-error-code message overrides
24
+ - **Programmatic control via ref** — `reset()`, `submit()`, `setValues()`, `getValues()`, `setErrors()`, `clearErrors()`, `focus()`, `isSubmitting` via `AutoFormHandle`
25
+ - **Form state persistence** — auto-save form values to `localStorage` (or custom storage) with configurable debounce; restored on mount, cleared on submit
26
+ - **Enhanced array fields** — opt-in row reordering (move up/down), duplicate, collapsible object rows with summary, `minItems`/`maxItems` constraints from Zod `.min()`/`.max()`, via `movable`/`duplicable`/`collapsible` meta flags
27
+ - **Array button styling** — `classNames.arrayAdd`, `arrayRemove`, `arrayMove`, `arrayDuplicate`, `arrayCollapse`
28
+ - **Custom array row layout** — `layout.arrayRowLayout` lets you fully control button placement within each array row
29
+ - **Field index & depth CSS vars** — `--field-index` and `--field-depth` on every field wrapper for advanced CSS targeting
30
+ - **Value cascade** — `onValuesChange` fires on every change with the full form values; use with `ref.setValues()` to imperatively sync field values
31
+ - **i18n / custom labels** — `labels` prop (and factory-level `labels` config) replaces every hard-coded UI string (`"Submit"`, `"Add"`, `"Remove"`, move/duplicate/collapse buttons) without touching layout slots
32
+ - **Async `setOnChange`** — `UniForm.setOnChange` handlers can be `async`; use them to fetch dependent data (e.g. cascading dropdowns, availability checks) and apply the results via `ctx.setFieldMeta` / `ctx.setValue`
33
+ - **Async `defaultValues`** — pass `() => Promise<Partial<TValues>>` as `defaultValues`; the form renders a `loadingFallback` while the promise is in flight, then resets with the loaded values
34
+ - **Tree-shakeable** — ESM + CJS builds via tsup with `sideEffects: false`
35
+
36
+ ## Quick Start
37
+
38
+ ### Installation
39
+
40
+ ```bash
41
+ npm install @uniform-ts/core react react-hook-form zod
42
+ ```
43
+
44
+ ### Basic Usage
45
+
46
+ ```tsx
47
+ import * as z from 'zod/v4'
48
+ import { createForm, AutoForm } from '@uniform-ts/core'
49
+
50
+ const schema = z.object({
51
+ name: z.string().min(1, 'Name is required'),
52
+ email: z.email('Invalid email'),
53
+ age: z.number().min(0).optional(),
54
+ role: z.enum(['user', 'admin', 'editor']),
55
+ subscribe: z.boolean(),
56
+ })
57
+
58
+ // createForm wraps your schema — pass the result to <AutoForm form={...}>
59
+ const myForm = createForm(schema)
60
+
61
+ function MyForm() {
62
+ return (
63
+ <AutoForm
64
+ form={myForm}
65
+ defaultValues={{ role: 'user', subscribe: false }}
66
+ onSubmit={(values) => {
67
+ // values is fully typed as z.infer<typeof schema>
68
+ console.log(values)
69
+ }}
70
+ />
71
+ )
72
+ }
73
+ ```
74
+
75
+ That's it — UniForm introspects the schema, renders appropriate inputs, validates with Zod, and calls `onSubmit` with typed values.
76
+
77
+ ## API Reference
78
+
79
+ ### `<AutoForm>` Props
80
+
81
+ | Prop | Type | Default | Description |
82
+ | ----------------- | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------- |
83
+ | `form` | `UniForm<TSchema>` | _required_ | A `UniForm` / `createForm` instance carrying the schema and setOnChange handlers |
84
+ | `onSubmit` | `(values: z.infer<TSchema>) => void \| Promise<void>` | _required_ | Called with fully typed, validated values on successful submit |
85
+ | `defaultValues` | `Partial<z.infer<TSchema>>` or `() => Promise<Partial<z.infer<TSchema>>>` | `{}` | Pre-fill form fields. Pass an async function to load from an API (see [Async Default Values](#async-default-values)) |
86
+ | `components` | `ComponentRegistry` | `defaultRegistry` | Override field type → component mapping |
87
+ | `fields` | `Record<string, Partial<FieldOverride>>` | `{}` | Per-field metadata overrides (supports dot-notated paths for nested fields) |
88
+ | `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | `DefaultFieldWrapper` | Wrap each scalar field in a custom container |
89
+ | `layout` | `LayoutSlots` | `{}` | Replace form wrapper, section wrapper, submit button, array row layout, or `loadingFallback` |
90
+ | `classNames` | `FormClassNames` | `{}` | CSS class names for form, field wrappers, labels, errors, descriptions |
91
+ | `disabled` | `boolean` | `false` | Disable all form fields and the submit button |
92
+ | `coercions` | `CoercionMap` | `defaultCoercionMap` | Custom per-type value coercion functions |
93
+ | `messages` | `ValidationMessages` | `undefined` | Custom validation error messages |
94
+ | `ref` | `React.Ref<AutoFormHandle>` | `undefined` | Imperative handle for programmatic control |
95
+ | `persistKey` | `string` | `undefined` | When set, form values auto-save to storage under this key |
96
+ | `persistDebounce` | `number` | `300` | Debounce interval in ms for persistence writes |
97
+ | `persistStorage` | `PersistStorage` | `localStorage` | Custom storage adapter (must implement `getItem`/`setItem`/`removeItem`) |
98
+ | `onValuesChange` | `(values: z.infer<TSchema>) => void` | `undefined` | Called on every field change with the full current form values |
99
+ | `labels` | `FormLabels` | `{}` | Override hard-coded UI text (submit button, array buttons) for i18n |
100
+
101
+ ### `createForm(schema)` / `UniForm`
102
+
103
+ `createForm` wraps a Zod schema in a `UniForm` instance. Pass the result to `<AutoForm form={...}>`.
104
+
105
+ The main reason to use `UniForm` over passing a bare schema is typed `setOnChange` handlers: you can react to individual field changes, read the new value (typed to the schema), and call any form method — all outside React.
106
+
107
+ ```tsx
108
+ import { createForm, AutoForm } from '@uniform-ts/core'
109
+
110
+ const addressForm = createForm(addressSchema)
111
+ .setOnChange('country', (value, ctx) => {
112
+ // value is typed as the 'country' field type
113
+ ctx.setFieldMeta('state', { hidden: value !== 'US' })
114
+ ctx.setValue('state', undefined)
115
+ })
116
+
117
+ // In component:
118
+ <AutoForm form={addressForm} onSubmit={handleSubmit} />
119
+ ```
120
+
121
+ #### `UniForm.setOnChange(field, handler)`
122
+
123
+ Set the typed onChange handler for a specific field. Returns `this` for chaining. Calling `setOnChange` again for the same field **replaces** the previous handler — only one handler per field is active at a time. This prevents accidental handler accumulation when called inside a React render cycle.
124
+
125
+ **Handler receives:**
126
+
127
+ - `value` — the new field value, typed to the schema
128
+ - `ctx: UniFormContext` — all `FormMethods` plus `setFieldMeta`
129
+
130
+ #### `UniFormContext`
131
+
132
+ The context passed to every `setOnChange` handler. Extends `FormMethods` with:
133
+
134
+ | Method | Description |
135
+ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
136
+ | `setFieldMeta(field, meta)` | Dynamically override per-field UI properties (`hidden`, `disabled`, `options`, `label`, `placeholder`, `description`). Pass `value` to immediately call `setValue` on the field. |
137
+
138
+ All standard `FormMethods` are also available: `setValue`, `setValues`, `getValues`, `resetField`, `reset`, `setError`, `setErrors`, `clearErrors`, `submit`, `focus`.
139
+
140
+ #### `UniForm.condition(field, predicate)`
141
+
142
+ Attach a typed visibility condition for a specific field. The field is shown when `predicate(values)` returns `true`. Takes precedence over any `condition` set via the `fields` prop.
143
+
144
+ ```ts
145
+ const form = createForm(schema).condition(
146
+ 'companyName',
147
+ (values) => values.type === 'business',
148
+ )
149
+ ```
150
+
151
+ ### `createAutoForm(config)`
152
+
153
+ Factory function that returns a pre-configured `<AutoForm>` component with baked-in defaults.
154
+
155
+ ```tsx
156
+ import { createAutoForm } from '@uniform-ts/core'
157
+
158
+ const MyAutoForm = createAutoForm({
159
+ components: { string: MyTextInput, number: MyNumberInput },
160
+ fieldWrapper: MyFieldWrapper,
161
+ layout: { submitButton: MySubmitButton },
162
+ classNames: { form: 'my-form', label: 'my-label' },
163
+ disabled: false,
164
+ coercions: { number: (v) => (v === '' ? undefined : Number(v)) },
165
+ messages: { required: 'This field is required' },
166
+ })
167
+
168
+ // Use it — no need to pass components/layout/classNames every time
169
+ <MyAutoForm form={myForm} onSubmit={handleSubmit} />
170
+
171
+ // Instance props merge with and override factory defaults
172
+ <MyAutoForm form={myForm} onSubmit={handleSubmit} classNames={{ form: 'override' }} />
173
+ ```
174
+
175
+ **Config type:** `AutoFormConfig`
176
+
177
+ | Key | Type | Merge behavior |
178
+ | -------------- | ---------------------------------------- | ------------------------------------------------ |
179
+ | `components` | `ComponentRegistry` | Deep merge (instance overrides specific keys) |
180
+ | `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Instance replaces factory |
181
+ | `layout` | `LayoutSlots` | Shallow merge |
182
+ | `classNames` | `FormClassNames` | Shallow merge |
183
+ | `disabled` | `boolean` | OR logic (either `true` → disabled) |
184
+ | `coercions` | `CoercionMap` | Shallow merge |
185
+ | `messages` | `ValidationMessages` | Shallow merge |
186
+ | `labels` | `FormLabels` | Shallow merge (instance overrides specific keys) |
187
+
188
+ ### Types
189
+
190
+ #### `FieldMeta`
191
+
192
+ Metadata attached to each field, extracted from Zod's `.meta()` or set via the `fields` prop:
193
+
194
+ ```ts
195
+ type FieldMeta = {
196
+ label?: string
197
+ placeholder?: string
198
+ description?: string
199
+ section?: string // Group field into a named section
200
+ order?: number // Control render order
201
+ span?: number // Grid column hint (set as --field-span CSS var)
202
+ hidden?: boolean // Hide the field
203
+ disabled?: boolean // Disable the field
204
+ options?: SelectOption[] // Override options for select fields
205
+ condition?: (values: Record<string, unknown>) => boolean // Show/hide conditionally
206
+ component?: string | React.ComponentType<FieldProps>
207
+ // ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
208
+ // registry key direct component (bypasses registry)
209
+ onChange?: (value: unknown, form: FormMethods) => void | Promise<void> // Per-field change handler (may be async)
210
+ [key: string]: unknown // Extensible
211
+ }
212
+ ```
213
+
214
+ #### `FieldOverride`
215
+
216
+ The type for entries in the `fields` prop. Like `FieldMeta`, but with typed `condition` and `onChange`:
217
+
218
+ ```ts
219
+ type FieldOverride<TSchema, TValue> = Partial<FieldMetaBase> & {
220
+ condition?: (values: z.infer<TSchema>) => boolean
221
+ onChange?: (
222
+ value: TValue,
223
+ form: FormMethods<z.infer<TSchema>>,
224
+ ) => void | Promise<void>
225
+ [key: string]: unknown
226
+ }
227
+ ```
228
+
229
+ #### `ComponentRegistry`
230
+
231
+ Map field types to React components:
232
+
233
+ ```ts
234
+ type ComponentRegistry = {
235
+ string?: React.ComponentType<FieldProps>
236
+ number?: React.ComponentType<FieldProps>
237
+ boolean?: React.ComponentType<FieldProps>
238
+ date?: React.ComponentType<FieldProps>
239
+ select?: React.ComponentType<FieldProps>
240
+ textarea?: React.ComponentType<FieldProps>
241
+ [key: string]: React.ComponentType<FieldProps> | undefined
242
+ }
243
+ ```
244
+
245
+ #### `FieldProps`
246
+
247
+ Props received by every field component:
248
+
249
+ ```ts
250
+ type FieldProps = {
251
+ name: string
252
+ value: unknown
253
+ onChange: (value: unknown) => void
254
+ onBlur: () => void
255
+ ref: RefCallBack // react-hook-form ref for DOM registration
256
+ label: string
257
+ placeholder?: string
258
+ description?: string
259
+ error?: string
260
+ required: boolean
261
+ disabled?: boolean
262
+ options?: SelectOption[] // For select fields
263
+ meta: FieldMeta
264
+ }
265
+ ```
266
+
267
+ #### `FieldWrapperProps`
268
+
269
+ Props received by the field wrapper component:
270
+
271
+ ```ts
272
+ type FieldWrapperProps = {
273
+ children: React.ReactNode
274
+ field: FieldConfig
275
+ error?: string
276
+ span?: number
277
+ index?: number // Zero-based render index → --field-index CSS var
278
+ depth?: number // Nesting depth (0 = top-level) → --field-depth CSS var
279
+ }
280
+ ```
281
+
282
+ #### `FormMethods`
283
+
284
+ All programmatic form control methods — available on `UniFormContext`, in per-field `onChange` callbacks, and as `AutoFormHandle` via `ref`:
285
+
286
+ ```ts
287
+ type FormMethods<TValues> = {
288
+ setValue: (name, value) => void
289
+ setValues: (values: Partial<TValues>) => void
290
+ getValues: () => TValues
291
+ watch: (() => TValues) & (<K extends keyof TValues>(name: K) => TValues[K])
292
+ resetField: (name) => void
293
+ reset: (values?: Partial<TValues>) => void
294
+ setError: (name, message: string) => void
295
+ setErrors: (errors: Partial<Record<string, string>>) => void
296
+ clearErrors: (names?) => void
297
+ submit: () => void
298
+ focus: (fieldName) => void
299
+ }
300
+ ```
301
+
302
+ #### `LayoutSlots`
303
+
304
+ ```ts
305
+ type LayoutSlots = {
306
+ formWrapper?: React.ComponentType<{ children: React.ReactNode }>
307
+ sectionWrapper?: React.ComponentType<{
308
+ children: React.ReactNode
309
+ title: string
310
+ }>
311
+ submitButton?: React.ComponentType<{ isSubmitting: boolean }>
312
+ arrayRowLayout?: React.ComponentType<ArrayRowLayoutProps>
313
+ /** Shown while async `defaultValues` are resolving. Default: `<p>Loading…</p>` */
314
+ loadingFallback?: React.ReactNode
315
+ }
316
+ ```
317
+
318
+ > **Tip:** Since `loadingFallback` is part of `LayoutSlots`, you can set a global loading UI once in `createAutoForm({ layout: { loadingFallback: <AppSpinner /> } })` and every form using that factory will automatically use it.
319
+
320
+ #### `ArrayRowLayoutProps`
321
+
322
+ ```ts
323
+ type ArrayRowLayoutProps = {
324
+ children: React.ReactNode // The rendered form fields for this row
325
+ buttons: {
326
+ moveUp: React.ReactNode | null
327
+ moveDown: React.ReactNode | null
328
+ duplicate: React.ReactNode | null
329
+ remove: React.ReactNode
330
+ collapse: React.ReactNode | null
331
+ }
332
+ index: number
333
+ rowCount: number
334
+ }
335
+ ```
336
+
337
+ #### `FieldDependencyResult`
338
+
339
+ Return type of `ctx.setFieldMeta()` inside UniForm `setOnChange` handlers. All fields are optional — return only what you want to override:
340
+
341
+ ```ts
342
+ type FieldDependencyResult = {
343
+ options?: SelectOption[] // Override available options (for select fields)
344
+ hidden?: boolean // Show or hide the field
345
+ disabled?: boolean // Enable or disable the field
346
+ label?: string // Override the field label
347
+ placeholder?: string // Override the placeholder
348
+ description?: string // Override the description
349
+ }
350
+ ```
351
+
352
+ ```ts
353
+ type FormClassNames = {
354
+ form?: string
355
+ fieldWrapper?: string
356
+ label?: string
357
+ description?: string
358
+ error?: string
359
+ arrayAdd?: string
360
+ arrayRemove?: string
361
+ arrayMove?: string
362
+ arrayDuplicate?: string
363
+ arrayCollapse?: string
364
+ }
365
+ ```
366
+
367
+ #### `CoercionMap`
368
+
369
+ ```ts
370
+ type CoercionMap = Record<string, (value: unknown) => unknown>
371
+ ```
372
+
373
+ Default coercions: `number` (empty→`undefined`, else `Number()`), `date` (empty→`undefined`, else `new Date()`), `boolean` (`Boolean()`), `string` (`null`→`''`).
374
+
375
+ #### `ValidationMessages`
376
+
377
+ ```ts
378
+ type ValidationMessages = {
379
+ required?: string // Global required override
380
+ [fieldName: string]: string | Record<string, string> | undefined
381
+ // ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
382
+ // catch-all per-error-code map
383
+ }
384
+ ```
385
+
386
+ ## Recipes
387
+
388
+ ### Custom Components
389
+
390
+ Replace the default input for any field type:
391
+
392
+ ```tsx
393
+ function MyTextInput(props: FieldProps) {
394
+ return (
395
+ <input
396
+ ref={props.ref}
397
+ id={props.name}
398
+ value={String(props.value ?? '')}
399
+ onChange={(e) => props.onChange(e.target.value)}
400
+ onBlur={props.onBlur}
401
+ placeholder={props.placeholder}
402
+ disabled={props.disabled}
403
+ className='my-input'
404
+ />
405
+ )
406
+ }
407
+
408
+ ;<AutoForm
409
+ form={myForm}
410
+ onSubmit={handleSubmit}
411
+ components={{ string: MyTextInput }}
412
+ />
413
+ ```
414
+
415
+ ### Per-field Custom Components
416
+
417
+ You can override the component for a **single field** in two ways:
418
+
419
+ #### Option 1 — Direct React component (inline, no registry needed)
420
+
421
+ Pass a `React.ComponentType<FieldProps>` directly as `meta.component` — either in the Zod schema or via the `fields` prop:
422
+
423
+ ```tsx
424
+ // In the Zod schema
425
+ function StarRating(props: FieldProps) { /* ... */ }
426
+
427
+ const schema = z.object({
428
+ title: z.string(),
429
+ rating: z.number().min(1).max(5).meta({ component: StarRating }),
430
+ })
431
+
432
+ <AutoForm form={createForm(schema)} onSubmit={handleSubmit} />
433
+ ```
434
+
435
+ ```tsx
436
+ // Or via the fields prop (no schema change needed)
437
+ <AutoForm
438
+ form={myForm}
439
+ onSubmit={handleSubmit}
440
+ fields={{ rating: { component: StarRating } }}
441
+ />
442
+ ```
443
+
444
+ The direct component **bypasses the registry entirely** and takes highest priority in the resolution chain.
445
+
446
+ #### Array fields with a direct component
447
+
448
+ A direct `meta.component` also bypasses the default `ArrayField` row-by-row UI. This lets you use a fully custom multi-value widget (e.g. a tag picker, multi-select) on a `z.array(z.string())` field — the component owns the whole array value:
449
+
450
+ ```tsx
451
+ function TagPicker(props: FieldProps) {
452
+ const selected = Array.isArray(props.value) ? (props.value as string[]) : []
453
+ // ... render your chip UI, call props.onChange(newArray) on changes
454
+ }
455
+
456
+ const schema = z.object({
457
+ tags: z
458
+ .array(z.string())
459
+ .min(1, 'Pick at least one tag')
460
+ .meta({
461
+ component: TagPicker,
462
+ suggestions: ['React', 'TypeScript', 'Zod'],
463
+ }),
464
+ })
465
+
466
+ <AutoForm form={createForm(schema)} onSubmit={handleSubmit} />
467
+ ```
468
+
469
+ Zod still validates the array (`.min(1)` etc.) — only the _render_ is taken over by your component.
470
+
471
+ #### Option 2 — Named key in the registry
472
+
473
+ Register a component under a custom string key — either in `createAutoForm` or the `components` prop — then reference it with `meta.component: 'yourKey'`:
474
+
475
+ ```tsx
476
+ // Register at factory level, available to all forms
477
+ const AppAutoForm = createAutoForm({
478
+ components: {
479
+ colorpicker: ColorPicker,
480
+ autocomplete: AutocompleteInput,
481
+ },
482
+ })
483
+
484
+ const schema = z.object({
485
+ theme: z.string().meta({ component: 'colorpicker' }),
486
+ city: z.string().meta({ component: 'autocomplete' }),
487
+ })
488
+
489
+ <AppAutoForm form={createForm(schema)} onSubmit={handleSubmit} />
490
+ ```
491
+
492
+ ```tsx
493
+ // Or register per-instance via the components prop
494
+ <AutoForm
495
+ form={myForm}
496
+ onSubmit={handleSubmit}
497
+ components={{ colorpicker: ColorPicker }}
498
+ fields={{ theme: { component: 'colorpicker' } }}
499
+ />
500
+ ```
501
+
502
+ **Resolution priority** (highest → lowest):
503
+
504
+ 1. Direct React component in `meta.component`
505
+ 2. String key in `meta.component` → merged registry
506
+ 3. Field type key in merged registry (e.g. `string`, `number`)
507
+ 4. Field type key in default registry
508
+ 5. Warn + render nothing
509
+
510
+ ### Field onChange Handlers
511
+
512
+ React to individual field changes — inline via the `fields` prop (typed to the schema), or statically via `UniForm.setOnChange`.
513
+
514
+ #### Inline via `fields` prop
515
+
516
+ ```tsx
517
+ <AutoForm
518
+ form={myForm}
519
+ onSubmit={handleSubmit}
520
+ fields={{
521
+ country: {
522
+ onChange: (value, form) => {
523
+ // value is typed as the 'country' field type
524
+ // form provides setValue, setValues, getValues, reset, etc.
525
+ form.setValue('state', undefined)
526
+ },
527
+ },
528
+ }}
529
+ />
530
+ ```
531
+
532
+ #### Statically via `createForm` / `UniForm` (outside the component)
533
+
534
+ ```tsx
535
+ // Define once at module level — handlers are stable, no React rules apply
536
+ const addressForm = createForm(addressSchema).setOnChange(
537
+ 'country',
538
+ (value, ctx) => {
539
+ ctx.setFieldMeta('state', { hidden: value !== 'US' })
540
+ ctx.setValue('state', undefined)
541
+ },
542
+ )
543
+
544
+ function MyForm() {
545
+ return <AutoForm form={addressForm} onSubmit={handleSubmit} />
546
+ }
547
+ ```
548
+
549
+ `UniForm.setOnChange` also supports `ctx.setFieldMeta` for dynamic field overrides — not available in the inline `fields` version.
550
+
551
+ #### Async handlers
552
+
553
+ `setOnChange` handlers (both inline and on `UniForm`) can be `async`. This is useful for server-side lookups that update other fields:
554
+
555
+ ```tsx
556
+ const productForm = createForm(productSchema).setOnChange(
557
+ 'sku',
558
+ async (sku, ctx) => {
559
+ // Disable the derived field while loading
560
+ ctx.setFieldMeta('productName', { disabled: true, placeholder: 'Loading…' })
561
+
562
+ const { name } = await fetchProduct(sku)
563
+
564
+ ctx.setValue('productName', name)
565
+ ctx.setFieldMeta('productName', { disabled: false, placeholder: '' })
566
+ },
567
+ )
568
+ ```
569
+
570
+ Async handlers are **fire-and-forget** — the field value is already committed to RHF before the async work runs. Cancel in-flight requests yourself with `AbortController` if needed.
571
+
572
+ ### Grid Layout with `classNames` and `span`
573
+
574
+ ```tsx
575
+ <AutoForm
576
+ form={myForm}
577
+ onSubmit={handleSubmit}
578
+ classNames={{
579
+ form: 'grid grid-cols-12 gap-4',
580
+ fieldWrapper: 'p-2',
581
+ label: 'font-semibold block mb-1',
582
+ error: 'text-red-500 text-sm',
583
+ }}
584
+ fields={{
585
+ firstName: { span: 6 },
586
+ lastName: { span: 6 },
587
+ email: { span: 12 },
588
+ }}
589
+ />
590
+ ```
591
+
592
+ The `span` value is set as `--field-span` CSS custom property on each field wrapper. Each wrapper also receives `--field-index` (zero-based render order) and `--field-depth` (nesting depth). Use CSS Grid to consume them:
593
+
594
+ ```css
595
+ .grid > * {
596
+ grid-column: span var(--field-span, 12);
597
+ }
598
+ /* Style every other top-level field */
599
+ .grid > *:nth-child(even) {
600
+ background: var(--field-index);
601
+ }
602
+ ```
603
+
604
+ ### Section Grouping
605
+
606
+ ```tsx
607
+ <AutoForm
608
+ form={myForm}
609
+ onSubmit={handleSubmit}
610
+ fields={{
611
+ firstName: { section: 'Personal', order: 1 },
612
+ lastName: { section: 'Personal', order: 2 },
613
+ street: { section: 'Address', order: 3 },
614
+ city: { section: 'Address', order: 4 },
615
+ }}
616
+ layout={{
617
+ sectionWrapper: ({ children, title }) => (
618
+ <fieldset>
619
+ <legend>{title}</legend>
620
+ {children}
621
+ </fieldset>
622
+ ),
623
+ }}
624
+ />
625
+ ```
626
+
627
+ ### Conditional Fields
628
+
629
+ Show a field only when another field has a specific value. Conditional fields are fully lifecycle-managed:
630
+
631
+ - **Hidden → not submitted:** fields whose condition starts `false` are never pre-registered in the form store, so they don't appear in `onSubmit` data.
632
+ - **Shown → hidden:** when a condition becomes `false` and the field unmounts, its value is discarded — it starts fresh the next time it appears.
633
+
634
+ ```tsx
635
+ const schema = z.object({
636
+ type: z.enum(['personal', 'business']),
637
+ companyName: z.string().optional(),
638
+ })
639
+
640
+ const myForm = createForm(schema)
641
+ // Attach condition on the UniForm instance (takes precedence over fields prop):
642
+ .condition('companyName', (values) => values.type === 'business')
643
+
644
+ // Or via the fields prop:
645
+ <AutoForm
646
+ form={createForm(schema)}
647
+ onSubmit={handleSubmit}
648
+ fields={{
649
+ companyName: {
650
+ condition: (values) => values.type === 'business',
651
+ },
652
+ }}
653
+ />
654
+ ```
655
+
656
+ ### Discriminated Unions
657
+
658
+ Pass a `z.discriminatedUnion` directly to `createForm` — no flat schema needed:
659
+
660
+ ```tsx
661
+ import * as z from 'zod/v4'
662
+ import { createForm, AutoForm } from '@uniform-ts/core'
663
+
664
+ const notificationUnion = z.discriminatedUnion('channel', [
665
+ z.object({
666
+ channel: z.literal('email'),
667
+ recipientEmail: z.string().email('Must be a valid email'),
668
+ subject: z.string().min(1, 'Subject is required'),
669
+ }),
670
+ z.object({
671
+ channel: z.literal('sms'),
672
+ phoneNumber: z
673
+ .string()
674
+ .regex(/^\+?[1-9]\d{7,14}$/, 'Must be a valid phone number'),
675
+ messageBody: z.string().max(160, 'SMS body must be ≤ 160 chars'),
676
+ }),
677
+ z.object({
678
+ channel: z.literal('webhook'),
679
+ endpointUrl: z.string().url('Must be a valid URL'),
680
+ secret: z.string().min(16, 'Secret must be at least 16 characters'),
681
+ }),
682
+ ])
683
+
684
+ const notificationForm = createForm(notificationUnion)
685
+
686
+ function NotificationForm() {
687
+ return (
688
+ <AutoForm
689
+ form={notificationForm}
690
+ defaultValues={{ channel: 'email' }}
691
+ onSubmit={(values) => console.log(values)}
692
+ />
693
+ )
694
+ }
695
+ ```
696
+
697
+ **How it works:**
698
+
699
+ - The discriminator field (`channel`) renders as a `select` with one option per variant
700
+ - When the discriminator changes, AutoForm swaps to the matching variant and renders only that variant's fields
701
+ - Validation uses the original union schema via `zodResolver` — only the active variant's fields are validated
702
+ - **Shared fields** (same key in multiple variants) persist their values when switching variants, since react-hook-form retains unregistered field values by default
703
+ - Variant-specific field values from a previous variant remain in the form store but are stripped by Zod during parsing
704
+
705
+ ### Custom Validation Messages
706
+
707
+ ```tsx
708
+ <AutoForm
709
+ form={myForm}
710
+ onSubmit={handleSubmit}
711
+ messages={{
712
+ required: 'This field is required', // Global
713
+ email: 'Please provide an email', // Per-field catch-all
714
+ age: { too_small: 'Must be at least 18' }, // Per-field per-code
715
+ }}
716
+ />
717
+ ```
718
+
719
+ Resolution order: per-field per-code → per-field string → global `required` → Zod's original message.
720
+
721
+ #### `AutoFormHandle`
722
+
723
+ Imperative handle exposed via `ref`:
724
+
725
+ ```ts
726
+ type AutoFormHandle<TSchema> = FormMethods<z.infer<TSchema>> & {
727
+ /** `true` while an async `onSubmit` handler is in flight. */
728
+ isSubmitting: boolean
729
+ }
730
+ // FormMethods: reset, submit, setValue, setValues, getValues, watch,
731
+ // resetField, setError, setErrors, clearErrors, focus
732
+ ```
733
+
734
+ #### `PersistStorage`
735
+
736
+ Adapter interface for form persistence (defaults to `localStorage`):
737
+
738
+ ```ts
739
+ type PersistStorage = {
740
+ getItem: (key: string) => string | null
741
+ setItem: (key: string, value: string) => void
742
+ removeItem: (key: string) => void
743
+ }
744
+ ```
745
+
746
+ ### Factory Pattern with `createAutoForm`
747
+
748
+ ```tsx
749
+ import { createAutoForm, createForm } from '@uniform-ts/core'
750
+
751
+ const AppAutoForm = createAutoForm({
752
+ components: {
753
+ string: MyTextInput,
754
+ number: MyNumberInput,
755
+ boolean: MyToggle,
756
+ select: MyDropdown,
757
+ },
758
+ fieldWrapper: MyFieldWrapper,
759
+ layout: { submitButton: MySubmitButton },
760
+ classNames: { form: 'app-form', label: 'app-label' },
761
+ })
762
+
763
+ // Then use it everywhere — no prop repetition
764
+ <AppAutoForm form={createForm(userSchema)} onSubmit={saveUser} />
765
+ <AppAutoForm form={createForm(settingsSchema)} onSubmit={saveSettings} />
766
+ ```
767
+
768
+ ### Deep Field Overrides
769
+
770
+ Override metadata for nested fields using dot-notated paths:
771
+
772
+ ```tsx
773
+ const schema = z.object({
774
+ address: z.object({
775
+ street: z.string(),
776
+ city: z.string(),
777
+ zip: z.string(),
778
+ }),
779
+ })
780
+
781
+ <AutoForm
782
+ form={createForm(schema)}
783
+ onSubmit={handleSubmit}
784
+ fields={{
785
+ 'address.street': { placeholder: '123 Main St' },
786
+ 'address.city': { label: 'City / Town' },
787
+ 'address.zip': { span: 6 },
788
+ }}
789
+ />
790
+ ```
791
+
792
+ ### Programmatic Control via Ref
793
+
794
+ Use `ref` to control the form from outside — ideal for wizards, external save buttons, and multi-step flows:
795
+
796
+ ```tsx
797
+ import { useRef } from 'react'
798
+ import { AutoForm } from '@uniform-ts/core'
799
+ import type { AutoFormHandle } from '@uniform-ts/core'
800
+
801
+ function WizardForm() {
802
+ const formRef = useRef<AutoFormHandle<typeof schema>>(null)
803
+
804
+ return (
805
+ <div>
806
+ <AutoForm ref={formRef} form={myForm} onSubmit={handleSubmit} />
807
+
808
+ <button onClick={() => formRef.current?.reset()}>Reset</button>
809
+ <button onClick={() => formRef.current?.submit()}>Save (external)</button>
810
+ <button onClick={() => formRef.current?.setValues({ name: 'Alice' })}>
811
+ Pre-fill
812
+ </button>
813
+ </div>
814
+ )
815
+ }
816
+ ```
817
+
818
+ All `AutoFormHandle` methods: `reset()`, `submit()`, `setValue()`, `setValues()`, `getValues()`, `watch()`, `resetField()`, `setError()`, `setErrors()`, `clearErrors()`, `focus()`, plus `isSubmitting` (boolean, `true` while async `onSubmit` is in flight).
819
+
820
+ ### Async Default Values
821
+
822
+ Pass an async function as `defaultValues` to pre-fill the form from an API:
823
+
824
+ ```tsx
825
+ async function loadProfile() {
826
+ const res = await fetch('/api/profile')
827
+ return res.json() // Returns Partial<ProfileValues>
828
+ }
829
+
830
+ function EditProfileForm() {
831
+ return (
832
+ <AutoForm
833
+ form={profileForm}
834
+ defaultValues={loadProfile} // async function — called once on mount
835
+ layout={{ loadingFallback: <ProfileSkeleton /> }} // shown while the promise is in flight
836
+ onSubmit={handleSubmit}
837
+ />
838
+ )
839
+ }
840
+ ```
841
+
842
+ - The form renders `loadingFallback` (or `<p>Loading…</p>` by default) until the promise resolves.
843
+ - On resolve, the form resets with the loaded values and renders normally.
844
+ - If you need to replay the loading state (e.g. when navigating between records), change the `key` prop on `<AutoForm>` to remount it.
845
+
846
+ ### Reading Live Values with `watch`
847
+
848
+ `watch` reads the current live value of a field (or all fields) from outside the form. Unlike `getValues`, it subscribes to react-hook-form's render cycle, so it always reflects the latest value at call time.
849
+
850
+ ```tsx
851
+ import { useRef } from 'react'
852
+ import { AutoForm } from '@uniform-ts/core'
853
+ import type { AutoFormHandle } from '@uniform-ts/core'
854
+
855
+ const schema = z.object({
856
+ plan: z.enum(['free', 'pro', 'enterprise']),
857
+ seats: z.number().min(1),
858
+ })
859
+
860
+ const myForm = createForm(schema)
861
+
862
+ function PricingForm() {
863
+ const formRef = useRef<AutoFormHandle<typeof schema>>(null)
864
+
865
+ return (
866
+ <div>
867
+ <AutoForm ref={formRef} form={myForm} onSubmit={handleSubmit} />
868
+
869
+ {/* Read a single field */}
870
+ <button
871
+ onClick={() => {
872
+ const plan = formRef.current?.watch('plan')
873
+ console.log('Current plan:', plan)
874
+ }}
875
+ >
876
+ Log current plan
877
+ </button>
878
+
879
+ {/* Read all fields */}
880
+ <button
881
+ onClick={() => {
882
+ const values = formRef.current?.watch()
883
+ console.log('All values:', values)
884
+ }}
885
+ >
886
+ Log all values
887
+ </button>
888
+ </div>
889
+ )
890
+ }
891
+ ```
892
+
893
+ > **Tip:** `watch` is most useful for reading values imperatively in event handlers. To react to changes as they happen, prefer `onValuesChange` or `UniForm.setOnChange`.
894
+
895
+ ### Form State Persistence
896
+
897
+ Auto-save form values to storage so users don't lose progress on page reload:
898
+
899
+ ```tsx
900
+ <AutoForm
901
+ form={myForm}
902
+ onSubmit={handleSubmit}
903
+ persistKey='my-form'
904
+ persistDebounce={500}
905
+ />
906
+ ```
907
+
908
+ Values are restored on mount and cleared after a successful submit. Use `persistStorage` for a custom adapter (e.g. `sessionStorage`):
909
+
910
+ ```tsx
911
+ <AutoForm
912
+ form={myForm}
913
+ onSubmit={handleSubmit}
914
+ persistKey='my-form'
915
+ persistStorage={sessionStorage}
916
+ />
917
+ ```
918
+
919
+ ### Enhanced Array Fields
920
+
921
+ Array fields support reordering, duplication, and collapsible rows — all **opt-in** via meta flags:
922
+
923
+ ```tsx
924
+ const schema = z.object({
925
+ members: z.array(
926
+ z.object({
927
+ name: z.string().min(1),
928
+ email: z.string().email(),
929
+ }),
930
+ ).min(1).max(5), // Enforced: can't remove below 1, can't add above 5
931
+ })
932
+
933
+ <AutoForm
934
+ form={createForm(schema)}
935
+ onSubmit={handleSubmit}
936
+ fields={{
937
+ members: {
938
+ movable: true, // Show ↑/↓ move buttons
939
+ duplicable: true, // Show Duplicate button
940
+ collapsible: true, // Show collapse/expand toggle (object items only)
941
+ },
942
+ }}
943
+ />
944
+ ```
945
+
946
+ - **`movable`**: Renders Move Up / Move Down buttons (only when >1 row)
947
+ - **`duplicable`**: Renders a Duplicate button (hidden when at maxItems)
948
+ - **`collapsible`**: Renders a collapse/expand toggle for object rows with summary text
949
+ - **Add** and **Remove** are always shown
950
+ - Constraints from `.min()` / `.max()` are enforced — "Add" is disabled at max, "Remove" is disabled at min
951
+
952
+ Style the array buttons via `classNames`:
953
+
954
+ ```tsx
955
+ <AutoForm
956
+ form={myForm}
957
+ onSubmit={handleSubmit}
958
+ fields={{ members: { movable: true, duplicable: true, collapsible: true } }}
959
+ classNames={{
960
+ arrayAdd: 'btn btn-primary',
961
+ arrayRemove: 'btn btn-danger',
962
+ arrayMove: 'btn btn-secondary',
963
+ arrayDuplicate: 'btn btn-outline',
964
+ arrayCollapse: 'btn btn-ghost',
965
+ }}
966
+ />
967
+ ```
968
+
969
+ ### Custom Array Row Layout
970
+
971
+ Use `layout.arrayRowLayout` to control where buttons appear within each array row:
972
+
973
+ ```tsx
974
+ import type { ArrayRowLayoutProps } from '@uniform-ts/core'
975
+
976
+ function HorizontalRowLayout({
977
+ children,
978
+ buttons,
979
+ index,
980
+ rowCount,
981
+ }: ArrayRowLayoutProps) {
982
+ return (
983
+ <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
984
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
985
+ {buttons.moveUp}
986
+ {buttons.moveDown}
987
+ </div>
988
+ <div style={{ flex: 1 }}>{children}</div>
989
+ <div style={{ display: 'flex', gap: '0.25rem' }}>
990
+ {buttons.duplicate}
991
+ {buttons.remove}
992
+ </div>
993
+ </div>
994
+ )
995
+ }
996
+
997
+ ;<AutoForm
998
+ form={myForm}
999
+ onSubmit={handleSubmit}
1000
+ fields={{ tasks: { movable: true, duplicable: true } }}
1001
+ layout={{ arrayRowLayout: HorizontalRowLayout }}
1002
+ />
1003
+ ```
1004
+
1005
+ The default layout renders collapse toggle, then children, then all action buttons in a row.
1006
+
1007
+ ### Customizing UI Text (i18n)
1008
+
1009
+ Use the `labels` prop to replace every hard-coded string in the default UI — submit button, all array action buttons — without needing to replace entire layout slot components:
1010
+
1011
+ ```tsx
1012
+ <AutoForm
1013
+ form={myForm}
1014
+ onSubmit={handleSubmit}
1015
+ labels={{
1016
+ submit: 'Enviar',
1017
+ arrayAdd: 'Agregar fila',
1018
+ arrayRemove: 'Eliminar',
1019
+ arrayMoveUp: '⬆ Subir',
1020
+ arrayMoveDown: '⬇ Bajar',
1021
+ arrayDuplicate: 'Duplicar',
1022
+ arrayCollapse: '▼ Ocultar', // shown when row is expanded
1023
+ arrayExpand: '▶ Mostrar', // shown when row is collapsed
1024
+ }}
1025
+ />
1026
+ ```
1027
+
1028
+ Set factory-level defaults with `labels` in `createAutoForm` — per-instance `labels` props shallow-merge and override:
1029
+
1030
+ ```tsx
1031
+ const AppAutoForm = createAutoForm({
1032
+ labels: { submit: 'Save' },
1033
+ })
1034
+
1035
+ // Uses factory default "Save"
1036
+ <AppAutoForm form={myForm} onSubmit={handleSubmit} />
1037
+
1038
+ // Per-instance override wins → "Save & Close"
1039
+ <AppAutoForm form={myForm} onSubmit={handleSubmit} labels={{ submit: 'Save & Close' }} />
1040
+ ```
1041
+
1042
+ **`FormLabels` type reference:**
1043
+
1044
+ ```ts
1045
+ type FormLabels = {
1046
+ submit?: string // default: "Submit"
1047
+ arrayAdd?: string // default: "Add"
1048
+ arrayRemove?: string // default: "Remove"
1049
+ arrayMoveUp?: string // default: "↑"
1050
+ arrayMoveDown?: string // default: "↓"
1051
+ arrayDuplicate?: string // default: "Duplicate"
1052
+ arrayCollapse?: string // shown when row is expanded (default: "▼")
1053
+ arrayExpand?: string // shown when row is collapsed (default: "▶")
1054
+ }
1055
+ ```
1056
+
1057
+ All unspecified keys fall back to their built-in English defaults. `labels` only affects the **default** submit button and array controls — if you supply a custom `layout.submitButton` component, that component owns its own text.
1058
+
1059
+ ### Value Cascade (`onValuesChange`)
1060
+
1061
+ Use `onValuesChange` together with a `ref` to set one field based on another:
1062
+
1063
+ ```tsx
1064
+ const formRef = useRef<AutoFormHandle<typeof schema>>(null)
1065
+
1066
+ <AutoForm
1067
+ ref={formRef}
1068
+ form={myForm}
1069
+ onSubmit={handleSubmit}
1070
+ onValuesChange={(values) => {
1071
+ const seats = { free: 1, starter: 5, pro: 20, enterprise: 100 }[values.plan]
1072
+ if (seats !== undefined && values.seats !== seats) {
1073
+ formRef.current?.setValues({ seats })
1074
+ }
1075
+ }}
1076
+ />
1077
+ ```
1078
+
1079
+ **Always guard with an equality check** to prevent an infinite update loop.
1080
+
1081
+ > **Tip:** For simple field-to-field reactions (resetting, toggling visibility), prefer `UniForm.setOnChange` or the `fields` prop `onChange` — they're more ergonomic and fully typed. Use `onValuesChange` when you need to observe the entire form state holistically.
1082
+
1083
+ ## Development
1084
+
1085
+ ```bash
1086
+ pnpm install # Install dependencies
1087
+ pnpm build # Build @uniform-ts/core
1088
+ pnpm test # Run all tests
1089
+ pnpm dev # Start the playground dev server
1090
+ ```
1091
+
1092
+ ### Monorepo Structure
1093
+
1094
+ ```
1095
+ uniform/
1096
+ ├── packages/
1097
+ │ └── core/ # The library (@uniform-ts/core)
1098
+ └── apps/
1099
+ └── playground/ # Vite + React dev app
1100
+ ```
1101
+
1102
+ ### Tech Stack
1103
+
1104
+ - **pnpm workspaces** — monorepo management
1105
+ - **tsup** — library bundler (ESM + CJS + `.d.ts`)
1106
+ - **Vite** — playground dev server
1107
+ - **Vitest** — unit and integration tests
1108
+ - **TypeScript** — strict mode throughout
1109
+ - **Zod V4** (`zod@>=3.25`, imported from `zod/v4`)
1110
+ - **react-hook-form** — form state management
1111
+ - **@hookform/resolvers** (`^5.2`) — Zod v4-aware resolver
1112
+
1113
+ ## Contributing
1114
+
1115
+ 1. Fork the repository
1116
+ 2. Create a feature branch (`git checkout -b feature/my-feature`)
1117
+ 3. Run tests (`pnpm test`) and ensure they pass
1118
+ 4. Submit a pull request
1119
+
1120
+ ## License
1121
+
1122
+ [MIT](LICENSE)