@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 +1122 -0
- package/dist/index.d.mts +857 -0
- package/dist/index.d.ts +857 -0
- package/dist/index.js +1633 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1592 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +66 -0
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)
|