@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 +42 -1118
- package/dist/index.d.mts +465 -319
- package/dist/index.d.ts +465 -319
- package/dist/index.js +220 -131
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +215 -132
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
##
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
917
|
-
const formRef = useRef<AutoFormHandle<typeof schema>>(null)
|
|
42
|
+
## Key Concepts
|
|
918
43
|
|
|
919
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
65
|
+
## Core Props
|
|
1024
66
|
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
1159
|
-
- **
|
|
1160
|
-
- **
|
|
1161
|
-
- **
|
|
1162
|
-
- **
|
|
1163
|
-
- **
|
|
1164
|
-
- **
|
|
1165
|
-
-
|
|
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
|
-
##
|
|
94
|
+
## Documentation
|
|
1168
95
|
|
|
1169
|
-
|
|
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
|
-
|
|
100
|
+
MIT
|