@uniform-ts/core 0.0.4 → 0.0.5

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 CHANGED
@@ -2,46 +2,15 @@
2
2
 
3
3
  > Headless React + Zod V4 form library. Zero styles — bring your own components.
4
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.
5
+ UniForm takes a Zod schema and automatically renders a fully customizable form. It handles field introspection, validation, coercion, and layout — you provide the components and styling.
6
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`; style or swap individual section wrappers via `layout.sections`
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
7
+ ## Installation
39
8
 
40
9
  ```bash
41
10
  npm install @uniform-ts/core react react-hook-form zod
42
11
  ```
43
12
 
44
- ### Basic Usage
13
+ ## Quick Start
45
14
 
46
15
  ```tsx
47
16
  import * as z from 'zod/v4'
@@ -50,12 +19,11 @@ import { createForm, AutoForm } from '@uniform-ts/core'
50
19
  const schema = z.object({
51
20
  name: z.string().min(1, 'Name is required'),
52
21
  email: z.email('Invalid email'),
53
- age: z.number().min(0).optional(),
54
22
  role: z.enum(['user', 'admin', 'editor']),
55
23
  subscribe: z.boolean(),
56
24
  })
57
25
 
58
- // createForm wraps your schema pass the result to <AutoForm form={...}>
26
+ // createForm wraps your schema and holds typed onChange handlers
59
27
  const myForm = createForm(schema)
60
28
 
61
29
  function MyForm() {
@@ -63,1114 +31,70 @@ function MyForm() {
63
31
  <AutoForm
64
32
  form={myForm}
65
33
  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 (except `sections` — deep-merged) |
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
- className?: string
311
- }>
312
- submitButton?: React.ComponentType<{ isSubmitting: boolean }>
313
- arrayRowLayout?: React.ComponentType<ArrayRowLayoutProps>
314
- /** Shown while async `defaultValues` are resolving. Default: `<p>Loading…</p>` */
315
- loadingFallback?: React.ReactNode
316
- /** Per-section styling / component overrides keyed by section title. */
317
- sections?: Record<string, SectionConfig>
318
- }
319
- ```
320
-
321
- #### `SectionConfig`
322
-
323
- ```ts
324
- type SectionConfig = {
325
- /** CSS class name forwarded to the section wrapper. */
326
- className?: string
327
- /** Replace the section wrapper component for this section only. */
328
- component?: React.ComponentType<{
329
- children: React.ReactNode
330
- title: string
331
- className?: string
332
- }>
333
- }
334
- ```
335
-
336
- > **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.
337
-
338
- #### `ArrayRowLayoutProps`
339
-
340
- ```ts
341
- type ArrayRowLayoutProps = {
342
- children: React.ReactNode // The rendered form fields for this row
343
- buttons: {
344
- moveUp: React.ReactNode | null
345
- moveDown: React.ReactNode | null
346
- duplicate: React.ReactNode | null
347
- remove: React.ReactNode
348
- collapse: React.ReactNode | null
349
- }
350
- index: number
351
- rowCount: number
352
- }
353
- ```
354
-
355
- #### `FieldDependencyResult`
356
-
357
- Return type of `ctx.setFieldMeta()` inside UniForm `setOnChange` handlers. All fields are optional — return only what you want to override:
358
-
359
- ```ts
360
- type FieldDependencyResult = {
361
- options?: SelectOption[] // Override available options (for select fields)
362
- hidden?: boolean // Show or hide the field
363
- disabled?: boolean // Enable or disable the field
364
- label?: string // Override the field label
365
- placeholder?: string // Override the placeholder
366
- description?: string // Override the description
367
- }
368
- ```
369
-
370
- ```ts
371
- type FormClassNames = {
372
- form?: string
373
- fieldWrapper?: string
374
- label?: string
375
- description?: string
376
- error?: string
377
- arrayAdd?: string
378
- arrayRemove?: string
379
- arrayMove?: string
380
- arrayDuplicate?: string
381
- arrayCollapse?: string
382
- }
383
- ```
384
-
385
- #### `CoercionMap`
386
-
387
- ```ts
388
- type CoercionMap = Record<string, (value: unknown) => unknown>
389
- ```
390
-
391
- Default coercions: `number` (empty→`undefined`, else `Number()`), `date` (empty→`undefined`, else `new Date()`), `boolean` (`Boolean()`), `string` (`null`→`''`).
392
-
393
- #### `ValidationMessages`
394
-
395
- ```ts
396
- type ValidationMessages = {
397
- required?: string // Global required override
398
- [fieldName: string]: string | Record<string, string> | undefined
399
- // ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^
400
- // catch-all per-error-code map
401
- }
402
- ```
403
-
404
- ## Recipes
405
-
406
- ### Custom Components
407
-
408
- Replace the default input for any field type:
409
-
410
- ```tsx
411
- function MyTextInput(props: FieldProps) {
412
- return (
413
- <input
414
- ref={props.ref}
415
- id={props.name}
416
- value={String(props.value ?? '')}
417
- onChange={(e) => props.onChange(e.target.value)}
418
- onBlur={props.onBlur}
419
- placeholder={props.placeholder}
420
- disabled={props.disabled}
421
- className='my-input'
422
- />
423
- )
424
- }
425
-
426
- ;<AutoForm
427
- form={myForm}
428
- onSubmit={handleSubmit}
429
- components={{ string: MyTextInput }}
430
- />
431
- ```
432
-
433
- ### Per-field Custom Components
434
-
435
- You can override the component for a **single field** in two ways:
436
-
437
- #### Option 1 — Direct React component (inline, no registry needed)
438
-
439
- Pass a `React.ComponentType<FieldProps>` directly as `meta.component` — either in the Zod schema or via the `fields` prop:
440
-
441
- ```tsx
442
- // In the Zod schema
443
- function StarRating(props: FieldProps) { /* ... */ }
444
-
445
- const schema = z.object({
446
- title: z.string(),
447
- rating: z.number().min(1).max(5).meta({ component: StarRating }),
448
- })
449
-
450
- <AutoForm form={createForm(schema)} onSubmit={handleSubmit} />
451
- ```
452
-
453
- ```tsx
454
- // Or via the fields prop (no schema change needed)
455
- <AutoForm
456
- form={myForm}
457
- onSubmit={handleSubmit}
458
- fields={{ rating: { component: StarRating } }}
459
- />
460
- ```
461
-
462
- The direct component **bypasses the registry entirely** and takes highest priority in the resolution chain.
463
-
464
- #### Array fields with a direct component
465
-
466
- 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:
467
-
468
- ```tsx
469
- function TagPicker(props: FieldProps) {
470
- const selected = Array.isArray(props.value) ? (props.value as string[]) : []
471
- // ... render your chip UI, call props.onChange(newArray) on changes
472
- }
473
-
474
- const schema = z.object({
475
- tags: z
476
- .array(z.string())
477
- .min(1, 'Pick at least one tag')
478
- .meta({
479
- component: TagPicker,
480
- suggestions: ['React', 'TypeScript', 'Zod'],
481
- }),
482
- })
483
-
484
- <AutoForm form={createForm(schema)} onSubmit={handleSubmit} />
485
- ```
486
-
487
- Zod still validates the array (`.min(1)` etc.) — only the _render_ is taken over by your component.
488
-
489
- #### Option 2 — String field as select
490
-
491
- A `z.string()` field can be rendered as a select by setting `meta.component: 'select'` together with `meta.options`. UniForm treats it as type `"select"` during introspection:
492
-
493
- ```ts
494
- const schema = z.object({
495
- role: z.string().meta({
496
- component: 'select',
497
- options: [
498
- { label: 'User', value: 'user' },
499
- { label: 'Admin', value: 'admin' },
500
- { label: 'Editor', value: 'editor' },
501
- ],
502
- }),
503
- })
504
- ```
505
-
506
- This is an alternative to `z.enum(...)` — useful when the option list is dynamic or when you need a plain `string` output type rather than a union literal.
507
-
508
- #### Option 3 — Named key in the registry
509
-
510
- Register a component under a custom string key — either in `createAutoForm` or the `components` prop — then reference it with `meta.component: 'yourKey'`:
511
-
512
- ```tsx
513
- // Register at factory level, available to all forms
514
- const AppAutoForm = createAutoForm({
515
- components: {
516
- colorpicker: ColorPicker,
517
- autocomplete: AutocompleteInput,
518
- },
519
- })
520
-
521
- const schema = z.object({
522
- theme: z.string().meta({ component: 'colorpicker' }),
523
- city: z.string().meta({ component: 'autocomplete' }),
524
- })
525
-
526
- <AppAutoForm form={createForm(schema)} onSubmit={handleSubmit} />
527
- ```
528
-
529
- ```tsx
530
- // Or register per-instance via the components prop
531
- <AutoForm
532
- form={myForm}
533
- onSubmit={handleSubmit}
534
- components={{ colorpicker: ColorPicker }}
535
- fields={{ theme: { component: 'colorpicker' } }}
536
- />
537
- ```
538
-
539
- **Resolution priority** (highest → lowest):
540
-
541
- 1. Direct React component in `meta.component`
542
- 2. String key in `meta.component` → merged registry
543
- 3. Field type key in merged registry (e.g. `string`, `number`)
544
- 4. Field type key in default registry
545
- 5. Warn + render nothing
546
-
547
- ### Field onChange Handlers
548
-
549
- React to individual field changes — inline via the `fields` prop (typed to the schema), or statically via `UniForm.setOnChange`.
550
-
551
- #### Inline via `fields` prop
552
-
553
- ```tsx
554
- <AutoForm
555
- form={myForm}
556
- onSubmit={handleSubmit}
557
- fields={{
558
- country: {
559
- onChange: (value, form) => {
560
- // value is typed as the 'country' field type
561
- // form provides setValue, setValues, getValues, reset, etc.
562
- form.setValue('state', undefined)
563
- },
564
- },
565
- }}
566
- />
567
- ```
568
-
569
- #### Statically via `createForm` / `UniForm` (outside the component)
570
-
571
- ```tsx
572
- // Define once at module level — handlers are stable, no React rules apply
573
- const addressForm = createForm(addressSchema).setOnChange(
574
- 'country',
575
- (value, ctx) => {
576
- ctx.setFieldMeta('state', { hidden: value !== 'US' })
577
- ctx.setValue('state', undefined)
578
- },
579
- )
580
-
581
- function MyForm() {
582
- return <AutoForm form={addressForm} onSubmit={handleSubmit} />
583
- }
584
- ```
585
-
586
- `UniForm.setOnChange` also supports `ctx.setFieldMeta` for dynamic field overrides — not available in the inline `fields` version.
587
-
588
- #### Async handlers
589
-
590
- `setOnChange` handlers (both inline and on `UniForm`) can be `async`. This is useful for server-side lookups that update other fields:
591
-
592
- ```tsx
593
- const productForm = createForm(productSchema).setOnChange(
594
- 'sku',
595
- async (sku, ctx) => {
596
- // Disable the derived field while loading
597
- ctx.setFieldMeta('productName', { disabled: true, placeholder: 'Loading…' })
598
-
599
- const { name } = await fetchProduct(sku)
600
-
601
- ctx.setValue('productName', name)
602
- ctx.setFieldMeta('productName', { disabled: false, placeholder: '' })
603
- },
604
- )
605
- ```
606
-
607
- 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.
608
-
609
- ### Grid Layout with `classNames` and `span`
610
-
611
- ```tsx
612
- <AutoForm
613
- form={myForm}
614
- onSubmit={handleSubmit}
615
- classNames={{
616
- form: 'grid grid-cols-12 gap-4',
617
- fieldWrapper: 'p-2',
618
- label: 'font-semibold block mb-1',
619
- error: 'text-red-500 text-sm',
620
- }}
621
- fields={{
622
- firstName: { span: 6 },
623
- lastName: { span: 6 },
624
- email: { span: 12 },
625
- }}
626
- />
627
- ```
628
-
629
- 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:
630
-
631
- ```css
632
- .grid > * {
633
- grid-column: span var(--field-span, 12);
634
- }
635
- /* Style every other top-level field */
636
- .grid > *:nth-child(even) {
637
- background: var(--field-index);
638
- }
639
- ```
640
-
641
- ### Section Grouping
642
-
643
- ```tsx
644
- <AutoForm
645
- form={myForm}
646
- onSubmit={handleSubmit}
647
- fields={{
648
- firstName: { section: 'Personal', order: 1 },
649
- lastName: { section: 'Personal', order: 2 },
650
- street: { section: 'Address', order: 3 },
651
- city: { section: 'Address', order: 4 },
652
- }}
653
- layout={{
654
- sectionWrapper: ({ children, title, className }) => (
655
- <fieldset className={className}>
656
- <legend>{title}</legend>
657
- {children}
658
- </fieldset>
659
- ),
660
- }}
661
- />
662
- ```
663
-
664
- Use `layout.sections` to style or swap the wrapper for individual sections:
665
-
666
- ```tsx
667
- <AutoForm
668
- form={myForm}
669
- onSubmit={handleSubmit}
670
- layout={{
671
- sections: {
672
- Personal: { className: 'bg-blue-50 p-4 rounded' },
673
- Address: { component: AddressCard }, // completely different component
674
- },
675
- }}
676
- />
677
- ```
678
-
679
- `className` is forwarded as a prop to the active wrapper (global `sectionWrapper` or the per-section `component`). Factory-level and instance-level `sections` are merged — instance wins on conflicts.
680
-
681
- ### Conditional Fields
682
-
683
- Show a field only when another field has a specific value. Conditional fields are fully lifecycle-managed:
684
-
685
- - **Hidden → not submitted:** fields whose condition starts `false` are never pre-registered in the form store, so they don't appear in `onSubmit` data.
686
- - **Shown → hidden:** when a condition becomes `false` and the field unmounts, its value is discarded — it starts fresh the next time it appears.
687
-
688
- ```tsx
689
- const schema = z.object({
690
- type: z.enum(['personal', 'business']),
691
- companyName: z.string().optional(),
692
- })
693
-
694
- const myForm = createForm(schema)
695
- // Attach condition on the UniForm instance (takes precedence over fields prop):
696
- .condition('companyName', (values) => values.type === 'business')
697
-
698
- // Or via the fields prop:
699
- <AutoForm
700
- form={createForm(schema)}
701
- onSubmit={handleSubmit}
702
- fields={{
703
- companyName: {
704
- condition: (values) => values.type === 'business',
705
- },
706
- }}
707
- />
708
- ```
709
-
710
- ### Discriminated Unions
711
-
712
- Pass a `z.discriminatedUnion` directly to `createForm` — no flat schema needed:
713
-
714
- ```tsx
715
- import * as z from 'zod/v4'
716
- import { createForm, AutoForm } from '@uniform-ts/core'
717
-
718
- const notificationUnion = z.discriminatedUnion('channel', [
719
- z.object({
720
- channel: z.literal('email'),
721
- recipientEmail: z.string().email('Must be a valid email'),
722
- subject: z.string().min(1, 'Subject is required'),
723
- }),
724
- z.object({
725
- channel: z.literal('sms'),
726
- phoneNumber: z
727
- .string()
728
- .regex(/^\+?[1-9]\d{7,14}$/, 'Must be a valid phone number'),
729
- messageBody: z.string().max(160, 'SMS body must be ≤ 160 chars'),
730
- }),
731
- z.object({
732
- channel: z.literal('webhook'),
733
- endpointUrl: z.string().url('Must be a valid URL'),
734
- secret: z.string().min(16, 'Secret must be at least 16 characters'),
735
- }),
736
- ])
737
-
738
- const notificationForm = createForm(notificationUnion)
739
-
740
- function NotificationForm() {
741
- return (
742
- <AutoForm
743
- form={notificationForm}
744
- defaultValues={{ channel: 'email' }}
745
34
  onSubmit={(values) => console.log(values)}
746
35
  />
747
36
  )
748
37
  }
749
38
  ```
750
39
 
751
- **How it works:**
752
-
753
- - The discriminator field (`channel`) renders as a `select` with one option per variant
754
- - When the discriminator changes, AutoForm swaps to the matching variant and renders only that variant's fields
755
- - Validation uses the original union schema via `zodResolver` — only the active variant's fields are validated
756
- - **Shared fields** (same key in multiple variants) persist their values when switching variants, since react-hook-form retains unregistered field values by default
757
- - Variant-specific field values from a previous variant remain in the form store but are stripped by Zod during parsing
758
-
759
- ### Custom Validation Messages
760
-
761
- ```tsx
762
- <AutoForm
763
- form={myForm}
764
- onSubmit={handleSubmit}
765
- messages={{
766
- required: 'This field is required', // Global
767
- email: 'Please provide an email', // Per-field catch-all
768
- age: { too_small: 'Must be at least 18' }, // Per-field per-code
769
- }}
770
- />
771
- ```
772
-
773
- Resolution order: per-field per-code → per-field string → global `required` → Zod's original message.
774
-
775
- #### `AutoFormHandle`
776
-
777
- Imperative handle exposed via `ref`:
778
-
779
- ```ts
780
- type AutoFormHandle<TSchema> = FormMethods<z.infer<TSchema>> & {
781
- /** `true` while an async `onSubmit` handler is in flight. */
782
- isSubmitting: boolean
783
- }
784
- // FormMethods: reset, submit, setValue, setValues, getValues, watch,
785
- // resetField, setError, setErrors, clearErrors, focus
786
- ```
787
-
788
- #### `PersistStorage`
789
-
790
- Adapter interface for form persistence (defaults to `localStorage`):
791
-
792
- ```ts
793
- type PersistStorage = {
794
- getItem: (key: string) => string | null
795
- setItem: (key: string, value: string) => void
796
- removeItem: (key: string) => void
797
- }
798
- ```
799
-
800
- ### Factory Pattern with `createAutoForm`
801
-
802
- ```tsx
803
- import { createAutoForm, createForm } from '@uniform-ts/core'
804
-
805
- const AppAutoForm = createAutoForm({
806
- components: {
807
- string: MyTextInput,
808
- number: MyNumberInput,
809
- boolean: MyToggle,
810
- select: MyDropdown,
811
- },
812
- fieldWrapper: MyFieldWrapper,
813
- layout: { submitButton: MySubmitButton },
814
- classNames: { form: 'app-form', label: 'app-label' },
815
- })
816
-
817
- // Then use it everywhere — no prop repetition
818
- <AppAutoForm form={createForm(userSchema)} onSubmit={saveUser} />
819
- <AppAutoForm form={createForm(settingsSchema)} onSubmit={saveSettings} />
820
- ```
821
-
822
- ### Deep Field Overrides
823
-
824
- Override metadata for nested fields using dot-notated paths:
825
-
826
- ```tsx
827
- const schema = z.object({
828
- address: z.object({
829
- street: z.string(),
830
- city: z.string(),
831
- zip: z.string(),
832
- }),
833
- })
834
-
835
- <AutoForm
836
- form={createForm(schema)}
837
- onSubmit={handleSubmit}
838
- fields={{
839
- 'address.street': { placeholder: '123 Main St' },
840
- 'address.city': { label: 'City / Town' },
841
- 'address.zip': { span: 6 },
842
- }}
843
- />
844
- ```
845
-
846
- ### Programmatic Control via Ref
847
-
848
- Use `ref` to control the form from outside — ideal for wizards, external save buttons, and multi-step flows:
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
- function WizardForm() {
856
- const formRef = useRef<AutoFormHandle<typeof schema>>(null)
857
-
858
- return (
859
- <div>
860
- <AutoForm ref={formRef} form={myForm} onSubmit={handleSubmit} />
861
-
862
- <button onClick={() => formRef.current?.reset()}>Reset</button>
863
- <button onClick={() => formRef.current?.submit()}>Save (external)</button>
864
- <button onClick={() => formRef.current?.setValues({ name: 'Alice' })}>
865
- Pre-fill
866
- </button>
867
- </div>
868
- )
869
- }
870
- ```
871
-
872
- All `AutoFormHandle` methods: `reset()`, `submit()`, `setValue()`, `setValues()`, `getValues()`, `watch()`, `resetField()`, `setError()`, `setErrors()`, `clearErrors()`, `focus()`, plus `isSubmitting` (boolean, `true` while async `onSubmit` is in flight).
873
-
874
- ### Async Default Values
875
-
876
- Pass an async function as `defaultValues` to pre-fill the form from an API:
877
-
878
- ```tsx
879
- async function loadProfile() {
880
- const res = await fetch('/api/profile')
881
- return res.json() // Returns Partial<ProfileValues>
882
- }
883
-
884
- function EditProfileForm() {
885
- return (
886
- <AutoForm
887
- form={profileForm}
888
- defaultValues={loadProfile} // async function — called once on mount
889
- layout={{ loadingFallback: <ProfileSkeleton /> }} // shown while the promise is in flight
890
- onSubmit={handleSubmit}
891
- />
892
- )
893
- }
894
- ```
895
-
896
- - The form renders `loadingFallback` (or `<p>Loading…</p>` by default) until the promise resolves.
897
- - On resolve, the form resets with the loaded values and renders normally.
898
- - If you need to replay the loading state (e.g. when navigating between records), change the `key` prop on `<AutoForm>` to remount it.
899
-
900
- ### Reading Live Values with `watch`
901
-
902
- `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.
903
-
904
- ```tsx
905
- import { useRef } from 'react'
906
- import { AutoForm } from '@uniform-ts/core'
907
- import type { AutoFormHandle } from '@uniform-ts/core'
908
-
909
- const schema = z.object({
910
- plan: z.enum(['free', 'pro', 'enterprise']),
911
- seats: z.number().min(1),
912
- })
913
-
914
- const myForm = createForm(schema)
40
+ UniForm introspects the schema, renders appropriate inputs, validates with Zod, and calls `onSubmit` with fully typed values.
915
41
 
916
- function PricingForm() {
917
- const formRef = useRef<AutoFormHandle<typeof schema>>(null)
42
+ ## Key Concepts
918
43
 
919
- return (
920
- <div>
921
- <AutoForm ref={formRef} form={myForm} onSubmit={handleSubmit} />
922
-
923
- {/* Read a single field */}
924
- <button
925
- onClick={() => {
926
- const plan = formRef.current?.watch('plan')
927
- console.log('Current plan:', plan)
928
- }}
929
- >
930
- Log current plan
931
- </button>
932
-
933
- {/* Read all fields */}
934
- <button
935
- onClick={() => {
936
- const values = formRef.current?.watch()
937
- console.log('All values:', values)
938
- }}
939
- >
940
- Log all values
941
- </button>
942
- </div>
943
- )
944
- }
945
- ```
44
+ **`createForm(schema)`** — creates a typed form definition outside React. Use `.setOnChange(field, handler)` to attach async field-level side effects (e.g. cascading dropdowns).
946
45
 
947
- > **Tip:** `watch` is most useful for reading values imperatively in event handlers. To react to changes as they happen, prefer `onValuesChange` or `UniForm.setOnChange`.
46
+ **`createAutoForm(defaults)`** factory that bakes in your design system defaults (components, classNames, fieldWrapper) once, so you don't repeat them on every form.
948
47
 
949
- ### Form State Persistence
48
+ **`components`** a registry mapping Zod types (`string`, `number`, `boolean`, etc.) to your own input components. Pass a component directly on a field via `fields` for one-off overrides.
950
49
 
951
- Auto-save form values to storage so users don't lose progress on page reload:
50
+ **`fields`** — per-field overrides using dot-notated paths. Control labels, descriptions, ordering, sections, conditions, and custom components without touching the schema.
952
51
 
953
52
  ```tsx
954
53
  <AutoForm
955
54
  form={myForm}
956
- onSubmit={handleSubmit}
957
- persistKey='my-form'
958
- persistDebounce={500}
959
- />
960
- ```
961
-
962
- Values are restored on mount and cleared after a successful submit. Use `persistStorage` for a custom adapter (e.g. `sessionStorage`):
963
-
964
- ```tsx
965
- <AutoForm
966
- form={myForm}
967
- onSubmit={handleSubmit}
968
- persistKey='my-form'
969
- persistStorage={sessionStorage}
970
- />
971
- ```
972
-
973
- ### Enhanced Array Fields
974
-
975
- Array fields support reordering, duplication, and collapsible rows — all **opt-in** via meta flags:
976
-
977
- ```tsx
978
- const schema = z.object({
979
- members: z.array(
980
- z.object({
981
- name: z.string().min(1),
982
- email: z.string().email(),
983
- }),
984
- ).min(1).max(5), // Enforced: can't remove below 1, can't add above 5
985
- })
986
-
987
- <AutoForm
988
- form={createForm(schema)}
989
- onSubmit={handleSubmit}
55
+ components={{ string: MyTextInput, boolean: MyToggle }}
990
56
  fields={{
991
- members: {
992
- movable: true, // Show ↑/↓ move buttons
993
- duplicable: true, // Show Duplicate button
994
- collapsible: true, // Show collapse/expand toggle (object items only)
995
- },
57
+ email: { label: 'Work Email', description: 'We will never share it' },
58
+ role: { order: 0, section: 'Account' },
59
+ subscribe: { condition: (values) => values.role !== 'admin' },
996
60
  }}
997
- />
998
- ```
999
-
1000
- - **`movable`**: Renders Move Up / Move Down buttons (only when >1 row)
1001
- - **`duplicable`**: Renders a Duplicate button (hidden when at maxItems)
1002
- - **`collapsible`**: Renders a collapse/expand toggle for object rows with summary text
1003
- - **Add** and **Remove** are always shown
1004
- - Constraints from `.min()` / `.max()` are enforced — "Add" is disabled at max, "Remove" is disabled at min
1005
-
1006
- Style the array buttons via `classNames`:
1007
-
1008
- ```tsx
1009
- <AutoForm
1010
- form={myForm}
1011
61
  onSubmit={handleSubmit}
1012
- fields={{ members: { movable: true, duplicable: true, collapsible: true } }}
1013
- classNames={{
1014
- arrayAdd: 'btn btn-primary',
1015
- arrayRemove: 'btn btn-danger',
1016
- arrayMove: 'btn btn-secondary',
1017
- arrayDuplicate: 'btn btn-outline',
1018
- arrayCollapse: 'btn btn-ghost',
1019
- }}
1020
62
  />
1021
63
  ```
1022
64
 
1023
- ### Custom Array Row Layout
65
+ ## Core Props
1024
66
 
1025
- Use `layout.arrayRowLayout` to control where buttons appear within each array row:
67
+ | Prop | Type | Description |
68
+ | --------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
69
+ | `form` | `UniForm<TSchema>` | Schema + onChange handlers from `createForm()` |
70
+ | `onSubmit` | `(values) => void \| Promise<void>` | Called with typed values after successful validation |
71
+ | `defaultValues` | `Partial<...>` or `() => Promise<...>` | Initial values; async function shows `loadingFallback` |
72
+ | `components` | `ComponentRegistry` | Map Zod types to your input components |
73
+ | `fields` | `Record<string, FieldOverride>` | Per-field label, description, order, section, condition |
74
+ | `fieldWrapper` | `React.ComponentType<FieldWrapperProps>` | Custom wrapper around every scalar field |
75
+ | `layout` | `LayoutSlots` | Replace form/section/object/array wrappers, submit button, array rows |
76
+ | `classNames` | `FormClassNames` | CSS classes for form, fields, labels, errors, fieldset/legend wrappers |
77
+ | `ref` | `React.Ref<AutoFormHandle>` | Imperative `reset`, `submit`, `setValues`, `getValues` |
78
+ | `persistKey` | `string` | Auto-save form state to `localStorage` under this key |
79
+ | `labels` | `FormLabels` | Override built-in UI strings for i18n |
1026
80
 
1027
- ```tsx
1028
- import type { ArrayRowLayoutProps } from '@uniform-ts/core'
1029
-
1030
- function HorizontalRowLayout({
1031
- children,
1032
- buttons,
1033
- index,
1034
- rowCount,
1035
- }: ArrayRowLayoutProps) {
1036
- return (
1037
- <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start' }}>
1038
- <div style={{ display: 'flex', flexDirection: 'column' }}>
1039
- {buttons.moveUp}
1040
- {buttons.moveDown}
1041
- </div>
1042
- <div style={{ flex: 1 }}>{children}</div>
1043
- <div style={{ display: 'flex', gap: '0.25rem' }}>
1044
- {buttons.duplicate}
1045
- {buttons.remove}
1046
- </div>
1047
- </div>
1048
- )
1049
- }
1050
-
1051
- ;<AutoForm
1052
- form={myForm}
1053
- onSubmit={handleSubmit}
1054
- fields={{ tasks: { movable: true, duplicable: true } }}
1055
- layout={{ arrayRowLayout: HorizontalRowLayout }}
1056
- />
1057
- ```
1058
-
1059
- The default layout renders collapse toggle, then children, then all action buttons in a row.
1060
-
1061
- ### Customizing UI Text (i18n)
1062
-
1063
- 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:
1064
-
1065
- ```tsx
1066
- <AutoForm
1067
- form={myForm}
1068
- onSubmit={handleSubmit}
1069
- labels={{
1070
- submit: 'Enviar',
1071
- arrayAdd: 'Agregar fila',
1072
- arrayRemove: 'Eliminar',
1073
- arrayMoveUp: '⬆ Subir',
1074
- arrayMoveDown: '⬇ Bajar',
1075
- arrayDuplicate: 'Duplicar',
1076
- arrayCollapse: '▼ Ocultar', // shown when row is expanded
1077
- arrayExpand: '▶ Mostrar', // shown when row is collapsed
1078
- }}
1079
- />
1080
- ```
1081
-
1082
- Set factory-level defaults with `labels` in `createAutoForm` — per-instance `labels` props shallow-merge and override:
1083
-
1084
- ```tsx
1085
- const AppAutoForm = createAutoForm({
1086
- labels: { submit: 'Save' },
1087
- })
1088
-
1089
- // Uses factory default "Save"
1090
- <AppAutoForm form={myForm} onSubmit={handleSubmit} />
1091
-
1092
- // Per-instance override wins → "Save & Close"
1093
- <AppAutoForm form={myForm} onSubmit={handleSubmit} labels={{ submit: 'Save & Close' }} />
1094
- ```
1095
-
1096
- **`FormLabels` type reference:**
1097
-
1098
- ```ts
1099
- type FormLabels = {
1100
- submit?: string // default: "Submit"
1101
- arrayAdd?: string // default: "Add"
1102
- arrayRemove?: string // default: "Remove"
1103
- arrayMoveUp?: string // default: "↑"
1104
- arrayMoveDown?: string // default: "↓"
1105
- arrayDuplicate?: string // default: "Duplicate"
1106
- arrayCollapse?: string // shown when row is expanded (default: "▼")
1107
- arrayExpand?: string // shown when row is collapsed (default: "▶")
1108
- }
1109
- ```
1110
-
1111
- 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.
1112
-
1113
- ### Value Cascade (`onValuesChange`)
1114
-
1115
- Use `onValuesChange` together with a `ref` to set one field based on another:
1116
-
1117
- ```tsx
1118
- const formRef = useRef<AutoFormHandle<typeof schema>>(null)
1119
-
1120
- <AutoForm
1121
- ref={formRef}
1122
- form={myForm}
1123
- onSubmit={handleSubmit}
1124
- onValuesChange={(values) => {
1125
- const seats = { free: 1, starter: 5, pro: 20, enterprise: 100 }[values.plan]
1126
- if (seats !== undefined && values.seats !== seats) {
1127
- formRef.current?.setValues({ seats })
1128
- }
1129
- }}
1130
- />
1131
- ```
1132
-
1133
- **Always guard with an equality check** to prevent an infinite update loop.
1134
-
1135
- > **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.
1136
-
1137
- ## Development
1138
-
1139
- ```bash
1140
- pnpm install # Install dependencies
1141
- pnpm build # Build @uniform-ts/core
1142
- pnpm test # Run all tests
1143
- pnpm dev # Start the playground dev server
1144
- ```
1145
-
1146
- ### Monorepo Structure
1147
-
1148
- ```
1149
- uniform/
1150
- ├── packages/
1151
- │ └── core/ # The library (@uniform-ts/core)
1152
- └── apps/
1153
- └── playground/ # Vite + React dev app
1154
- ```
1155
-
1156
- ### Tech Stack
81
+ ## Features
1157
82
 
1158
- - **pnpm workspaces** — monorepo management
1159
- - **tsup** library bundler (ESM + CJS + `.d.ts`)
1160
- - **Vite** — playground dev server
1161
- - **Vitest** — unit and integration tests
1162
- - **TypeScript** — strict mode throughout
1163
- - **Zod V4** (`zod@>=3.25`, imported from `zod/v4`)
1164
- - **react-hook-form** — form state management
1165
- - **@hookform/resolvers** (`^5.2`)Zod v4-aware resolver
83
+ - **Full Zod V4 support** — scalars, enums, objects, arrays, optionals, defaults, unions, discriminated unions
84
+ - **react-hook-form** under the hood performant, uncontrolled forms with `zodResolver`
85
+ - **Section grouping** — group fields into named sections via `meta.section`
86
+ - **Conditional fields** — show/hide fields based on form values; hidden fields reset to default
87
+ - **Array fields** — movable, duplicable, collapsible rows; `minItems`/`maxItems` from Zod schema
88
+ - **Programmatic control** — `reset()`, `submit()`, `setValues()`, `getValues()`, `setErrors()`, `focus()` via ref
89
+ - **Form persistence** — auto-save to `localStorage` (or custom storage) with configurable debounce
90
+ - **Pluggable coercion**automatic `string → number`, `string → Date` with customizable coercion map
91
+ - **i18n** — override every hard-coded UI string via `labels` prop
92
+ - **Tree-shakeable** — ESM + CJS builds via tsup
1166
93
 
1167
- ## Contributing
94
+ ## Documentation
1168
95
 
1169
- 1. Fork the repository
1170
- 2. Create a feature branch (`git checkout -b feature/my-feature`)
1171
- 3. Run tests (`pnpm test`) and ensure they pass
1172
- 4. Submit a pull request
96
+ Full API reference, guides, and examples: **[uniformts.github.io/UniForm](https://uniformts.github.io/UniForm/)**
1173
97
 
1174
98
  ## License
1175
99
 
1176
- [MIT](LICENSE)
100
+ MIT