@macrulez/vue-form-schema 0.1.1 → 0.1.6

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.
Files changed (51) hide show
  1. package/README.md +708 -412
  2. package/dist/MaskEngine-BqJQYybS.js +233 -0
  3. package/dist/MaskEngine-BwAs2Zb0.cjs +1 -0
  4. package/dist/__tests__/phase3.test.d.ts +2 -0
  5. package/dist/__tests__/phase3.test.d.ts.map +1 -0
  6. package/dist/__tests__/phase4.test.d.ts +2 -0
  7. package/dist/__tests__/phase4.test.d.ts.map +1 -0
  8. package/dist/__tests__/useFieldArray.test.d.ts +2 -0
  9. package/dist/__tests__/useFieldArray.test.d.ts.map +1 -0
  10. package/dist/__tests__/useMultiStepForm.test.d.ts +2 -0
  11. package/dist/__tests__/useMultiStepForm.test.d.ts.map +1 -0
  12. package/dist/core/ConditionEvaluator.d.ts +1 -1
  13. package/dist/core/ConditionEvaluator.d.ts.map +1 -1
  14. package/dist/core/ValidationEngine.d.ts +3 -1
  15. package/dist/core/ValidationEngine.d.ts.map +1 -1
  16. package/dist/core/inferTypes.d.ts +39 -0
  17. package/dist/core/inferTypes.d.ts.map +1 -0
  18. package/dist/core/registry.d.ts +17 -0
  19. package/dist/core/registry.d.ts.map +1 -0
  20. package/dist/core/schemaUtils.d.ts +20 -0
  21. package/dist/core/schemaUtils.d.ts.map +1 -0
  22. package/dist/core/types.d.ts +43 -3
  23. package/dist/core/types.d.ts.map +1 -1
  24. package/dist/core/useFieldArray.d.ts +21 -0
  25. package/dist/core/useFieldArray.d.ts.map +1 -0
  26. package/dist/core/useForm.d.ts.map +1 -1
  27. package/dist/core/useFormDebug.d.ts +29 -0
  28. package/dist/core/useFormDebug.d.ts.map +1 -0
  29. package/dist/core/useFormField.d.ts +18 -0
  30. package/dist/core/useFormField.d.ts.map +1 -0
  31. package/dist/core/useMultiStepForm.d.ts +30 -0
  32. package/dist/core/useMultiStepForm.d.ts.map +1 -0
  33. package/dist/index.cjs +1 -1
  34. package/dist/index.d.ts +14 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +386 -196
  37. package/dist/parsers/valibot.d.ts +20 -0
  38. package/dist/parsers/valibot.d.ts.map +1 -0
  39. package/dist/ui/index.d.ts +2 -0
  40. package/dist/ui/index.d.ts.map +1 -1
  41. package/dist/ui.cjs +1 -1
  42. package/dist/ui.d.ts +114 -18
  43. package/dist/ui.js +360 -193
  44. package/dist/valibot.cjs +1 -0
  45. package/dist/valibot.d.ts +84 -0
  46. package/dist/valibot.js +43 -0
  47. package/dist/yup.d.ts +26 -2
  48. package/dist/zod.d.ts +26 -2
  49. package/package.json +21 -4
  50. package/dist/MaskEngine-D22m29OM.js +0 -157
  51. package/dist/MaskEngine-hd5xHed7.cjs +0 -1
package/README.md CHANGED
@@ -1,6 +1,15 @@
1
- # vue-form-schema
2
-
3
- Reactive forms from a declarative schema (JSON, Zod, or Yup) for Vue 3. A headless, SSR-compatible alternative to VeeValidate / FormKit for forms that are generated dynamically or driven from the server.
1
+ <div align="center" style="background:#111827;border-radius:20px;padding:28px 20px 20px;margin-bottom:32px">
2
+ <h1 style="color:#f9fafb;margin:0 0 32px;font-size:2.2em;letter-spacing:-0.03em;font-weight:700;font-family:sans-serif">
3
+ @macrulez/vue-form-schema
4
+ </h1>
5
+ <img
6
+ src="https://s3.twcstorage.ru/c9a2cc89-780f97fd-311d-4a1a-b86f-c25665c9dc46/images/npm/vue-form-schema.webp"
7
+ alt="vue-virtual-scroller-kit"
8
+ style="max-width:100%;width:auto;height:300px;border-radius:12px"
9
+ />
10
+ </div>
11
+
12
+ Reactive forms from a declarative schema (JSON, Zod, Yup, or Valibot) for Vue 3. A headless, SSR-compatible alternative to VeeValidate / FormKit for forms that are generated dynamically or driven from the server.
4
13
 
5
14
  ---
6
15
 
@@ -17,13 +26,27 @@ Reactive forms from a declarative schema (JSON, Zod, or Yup) for Vue 3. A headle
17
26
  - [JSON schema](#json-schema)
18
27
  - [Zod](#zod)
19
28
  - [Yup](#yup)
29
+ - [Valibot](#valibot)
20
30
  - [Built-in validators](#built-in-validators)
21
31
  - [Custom validators](#custom-validators)
32
+ - [Cross-field validation](#cross-field-validation)
22
33
  - [Conditional fields](#conditional-fields)
34
+ - [Dynamic options](#dynamic-options)
35
+ - [Dynamic array fields](#dynamic-array-fields)
36
+ - [Multi-step forms](#multi-step-forms)
37
+ - [Field transform & parse](#field-transform--parse)
38
+ - [File upload field](#file-upload-field)
39
+ - [Custom field components](#custom-field-components)
40
+ - [Component registry](#component-registry)
23
41
  - [Input masking](#input-masking)
42
+ - [Schema composition](#schema-composition)
43
+ - [TypeScript inference](#typescript-inference)
44
+ - [Persisted forms](#persisted-forms)
45
+ - [Debug mode](#debug-mode)
24
46
  - [FormRenderer UI component](#formrenderer-ui-component)
47
+ - [Tailwind UI theme](#tailwind-ui-theme)
48
+ - [Accessibility](#accessibility)
25
49
  - [SSR compatibility](#ssr-compatibility)
26
- - [TypeScript generics](#typescript-generics)
27
50
  - [Architecture](#architecture)
28
51
  - [Bundle size & peer dependencies](#bundle-size--peer-dependencies)
29
52
 
@@ -31,52 +54,69 @@ Reactive forms from a declarative schema (JSON, Zod, or Yup) for Vue 3. A headle
31
54
 
32
55
  ## Features
33
56
 
34
- - **Any schema source** — define fields as a plain JSON array, a Zod object, or a Yup object
57
+ - **Any schema source** — `FieldDefinition[]`, JSON array, Zod, Yup, or Valibot
35
58
  - **Headless by default** — zero UI dependencies in the core; bring your own components
36
- - **Reactive conditions** — `visible`, `disabled`, and `required` accept a boolean, a function, or a string expression evaluated against live form values
37
- - **Validation** — sync and async validators, built-in rules (`required`, `email`, `minLength`, …), cross-field access
38
- - **Input masking** — phone (RU/EU), date, IBAN, INN, and custom `#`/`A` patterns; no external dependencies
39
- - **SSR-safe** — no direct browser APIs in the core; validation and state work on the server
40
- - **Tree-shakeable** — Zod/Yup adapters are separate entry points; the UI subpackage is optional
41
- - **TypeScript 5+** generic type inference flows from schema to `values`
59
+ - **Reactive conditions** — `visible`, `disabled` accept a boolean, function, or string expression
60
+ - **Dynamic options** — sync and async `options` functions with dependency tracking (`optionsDeps`)
61
+ - **Dynamic array fields** — `type: 'array'` with `useFieldArray` composable (append / remove / move / swap)
62
+ - **Multi-step wizard** — `useMultiStepForm` with per-step validation and `MultiStepFormRenderer`
63
+ - **Validation** — sync + async validators, `validateMode: 'first' | 'all'`, `validateOn: 'eager'`
64
+ - **Cross-field**`sameAs` validator; validators receive all current values as second argument
65
+ - **Transform & parse** — `transform` runs on every `setField`; `parse` runs at submit time
66
+ - **File upload** — `type: 'file'` with `fileType`, `fileSize`, `fileCount` validators; drag-and-drop UI
67
+ - **Custom components** — `field.component` + per-app and per-subtree component registry
68
+ - **Input masking** — phone (RU/EU), date, IBAN, INN, custom `#`/`A` patterns; no external deps
69
+ - **Schema composition** — `mergeSchemas`, `omitFields`, `pickFields`, `extendField`
70
+ - **TypeScript inference** — `InferValues<T>` maps schema literals to typed values
71
+ - **Persisted forms** — `persist: 'local' | 'session'` with SSR-safe storage
72
+ - **Debug mode** — `debug: true` logs state changes; `useFormDebug` returns a reactive snapshot
73
+ - **Tailwind UI theme** — `vue-form-schema/ui/tailwind` subentry with utility-class components
74
+ - **Accessibility** — `aria-required`, `aria-invalid`, `aria-describedby`, `fieldset`/`legend` for radio
75
+ - **SSR-safe** — no direct browser APIs in the core
76
+ - **Tree-shakeable** — Zod/Yup/Valibot adapters and UI are separate entry points
42
77
 
43
78
  ---
44
79
 
45
80
  ## Demo
46
81
 
47
- A local interactive demo covers all major features of the library. Clone the repo and run:
48
-
49
82
  ```bash
50
83
  npm install
51
84
  npm run demo
52
85
  ```
53
86
 
54
- Opens at **http://localhost:5174** with the following examples:
87
+ Opens at **http://localhost:5174**:
55
88
 
56
89
  | Page | What it shows |
57
90
  |---|---|
58
- | **Basic form** | `FieldDefinition[]` with built-in validators — `required`, `minLength`, `email`, `min/max` |
59
- | **JSON schema** | Server-driven schema with rule-based validators (`{ rule: 'minLength', value: 3 }`) |
60
- | **Zod schema** | `z.object()` `parseZod()`, type inference, `.describe()` for labels |
61
- | **Yup schema** | `yup.object()` `parseYup()`, Yup constraints delegated to `validateSync` |
62
- | **Conditional fields** | `visible` as a function and as a string expression; `clearOnHide` behaviour |
63
- | **Input masking** | All presets + custom `#`/`A` patterns; standalone `applyMask` / `removeMask` playground |
64
- | **FormRenderer** | Out-of-the-box rendering; slot overrides (`#field-{name}`, `#submit`); custom component map |
91
+ | **Basic form** | `FieldDefinition[]` with built-in validators |
92
+ | **JSON schema** | Server-driven schema with rule-based validators |
93
+ | **Zod schema** | `parseZod()` with type inference |
94
+ | **Yup schema** | `parseYup()` with Yup constraints |
95
+ | **Conditional fields** | `visible` / `disabled` as function and string expression |
96
+ | **Input masking** | All presets + custom patterns |
97
+ | **FormRenderer** | Slot overrides, custom component map |
98
+ | **Array fields** | `type: 'array'` + `useFieldArray` API |
99
+ | **Multi-step wizard** | `useMultiStepForm` + step progress |
100
+ | **Dependent fields** | Sync/async function options, `defaultValue` as function |
101
+ | **Custom registry** | `provideRegistry` replaces built-in checkbox with PillToggle |
102
+ | **File upload** | Drag-and-drop, `fileType`/`fileSize`/`fileCount` validators |
103
+ | **Tailwind theme** | Default `FormRenderer` vs `TailwindFormRenderer` side by side |
104
+ | **Accessibility** | `aria-*` attributes, `fieldset`/`legend` for radio groups |
65
105
 
66
106
  ---
67
107
 
68
108
  ## Installation
69
109
 
70
110
  ```bash
71
- npm install vue-form-schema
111
+ npm install @macrulez/vue-form-schema
72
112
  ```
73
113
 
74
- Peer dependencies:
114
+ Optional peer dependencies:
75
115
 
76
116
  ```bash
77
- npm install vue # required
78
- npm install zod # optional — only if you use the Zod adapter
79
- npm install yup # optional — only if you use the Yup adapter
117
+ npm install zod # Zod adapter
118
+ npm install yup # Yup adapter
119
+ npm install valibot # Valibot adapter
80
120
  ```
81
121
 
82
122
  ---
@@ -85,8 +125,8 @@ npm install yup # optional — only if you use the Yup adapter
85
125
 
86
126
  ```vue
87
127
  <script setup lang="ts">
88
- import { useForm } from 'vue-form-schema'
89
- import type { FieldDefinition } from 'vue-form-schema'
128
+ import { useForm } from '@macrulez/vue-form-schema'
129
+ import type { FieldDefinition } from '@macrulez/vue-form-schema'
90
130
 
91
131
  const schema: FieldDefinition[] = [
92
132
  { type: 'text', name: 'name', label: 'Full name', required: true },
@@ -130,19 +170,32 @@ const { values, errors, touched, isValid, isSubmitting, submit, setField } = use
130
170
  </template>
131
171
  ```
132
172
 
173
+ Or use `FormRenderer` for zero-markup rendering:
174
+
175
+ ```vue
176
+ <script setup lang="ts">
177
+ import { useForm } from '@macrulez/vue-form-schema'
178
+ import { FormRenderer } from '@macrulez/vue-form-schema/ui'
179
+
180
+ const form = useForm({ schema, onSubmit })
181
+ </script>
182
+
183
+ <template>
184
+ <FormRenderer :form="form" submit-label="Save" />
185
+ </template>
186
+ ```
187
+
133
188
  ---
134
189
 
135
190
  ## FieldDefinition reference
136
191
 
137
- Every field in your schema is described by a `FieldDefinition` object.
138
-
139
192
  ```ts
140
193
  interface FieldDefinition {
141
194
  // ─── Required ─────────────────────────────────────────────────────────────
142
195
  type: 'text' | 'number' | 'email' | 'select' | 'checkbox'
143
- | 'radio' | 'textarea' | 'date' | 'array' | 'group'
196
+ | 'radio' | 'textarea' | 'date' | 'array' | 'group' | 'file'
144
197
 
145
- /** Flat dot-path key used in the values object, e.g. "address.city" */
198
+ /** Flat dot-path key in the values object, e.g. "address.city" */
146
199
  name: string
147
200
 
148
201
  // ─── Display ──────────────────────────────────────────────────────────────
@@ -150,20 +203,14 @@ interface FieldDefinition {
150
203
  placeholder?: string
151
204
 
152
205
  // ─── Initial value ────────────────────────────────────────────────────────
153
- defaultValue?: unknown // set on mount and after reset()
206
+ /** Static value or a function called at init with already-resolved partial values */
207
+ defaultValue?: unknown | ((values: Record<string, unknown>) => unknown)
154
208
 
155
209
  // ─── Constraints ──────────────────────────────────────────────────────────
156
210
  required?: boolean
157
-
158
- /** Static boolean, or a function receiving all current values */
159
211
  disabled?: boolean | ((values: Record<string, unknown>) => boolean)
160
-
161
- /**
162
- * Static boolean, a function, or a string expression.
163
- * String expressions have access to the `values` variable and support
164
- * arithmetic, comparison, logical operators. Unsafe calls are blocked.
165
- */
166
- visible?: boolean | string | ((values: Record<string, unknown>) => boolean)
212
+ /** Boolean, function, or string expression evaluated against live values */
213
+ visible?: boolean | string | ((values: Record<string, unknown>) => boolean)
167
214
 
168
215
  // ─── Validation ───────────────────────────────────────────────────────────
169
216
  validators?: ValidatorFn[]
@@ -172,41 +219,41 @@ interface FieldDefinition {
172
219
  // ─── Masking ──────────────────────────────────────────────────────────────
173
220
  mask?: string | MaskConfig
174
221
 
175
- // ─── select / radio ───────────────────────────────────────────────────────
176
- options?: { label: string; value: unknown }[]
222
+ // ─── select / radio options ───────────────────────────────────────────────
223
+ /** Static array, sync function, or async function */
224
+ options?: FieldOption[]
225
+ | ((values: Record<string, unknown>) => FieldOption[])
226
+ | ((values: Record<string, unknown>) => Promise<FieldOption[]>)
227
+ /** Field names that trigger async options re-fetch when their values change */
228
+ optionsDeps?: string[]
177
229
 
178
230
  // ─── group / array ────────────────────────────────────────────────────────
179
231
  fields?: FieldDefinition[]
180
- }
181
- ```
182
-
183
- ### Nested fields
184
232
 
185
- Use `type: 'group'` to create a named group of fields. Child field names must use dot-path notation so values are properly scoped.
186
-
187
- ```ts
188
- const schema: FieldDefinition[] = [
189
- {
190
- type: 'group',
191
- name: 'address',
192
- label: 'Address',
193
- fields: [
194
- { type: 'text', name: 'address.city', label: 'City', required: true },
195
- { type: 'text', name: 'address.country', label: 'Country', required: true },
196
- ],
197
- },
198
- ]
233
+ // ─── transform / parse ────────────────────────────────────────────────────
234
+ /** Applied on every setField call — use for trim, coercion, formatting */
235
+ transform?: (value: unknown, values: Record<string, unknown>) => unknown
236
+ /** Applied at submit time to produce the final payload value */
237
+ parse?: (raw: unknown) => unknown
238
+
239
+ // ─── Custom component ─────────────────────────────────────────────────────
240
+ /** Vue component or registered name; receives FormFieldProps */
241
+ component?: Component | string
242
+
243
+ // ─── File field options ───────────────────────────────────────────────────
244
+ accept?: string // passed to <input accept>
245
+ multiple?: boolean
246
+ maxSize?: number // bytes (informational; use fileSize validator to enforce)
247
+ maxFiles?: number // informational; use fileCount validator to enforce
248
+ }
199
249
  ```
200
250
 
201
- The resulting `values` object will be `{ 'address.city': '...', 'address.country': '...' }`.
202
-
203
251
  ---
204
252
 
205
253
  ## useForm composable
206
254
 
207
255
  ```ts
208
- import { useForm } from 'vue-form-schema'
209
-
256
+ import { useForm } from '@macrulez/vue-form-schema'
210
257
  const form = useForm(config)
211
258
  ```
212
259
 
@@ -216,43 +263,34 @@ const form = useForm(config)
216
263
  |---|---|---|---|
217
264
  | `schema` | `FieldDefinition[] \| JSONSchema` | — | Field definitions |
218
265
  | `initialValues` | `Partial<T>` | `{}` | Seed values (override field defaults) |
219
- | `validateOn` | `'input' \| 'blur' \| 'submit'` | `'blur'` | When sync validation is triggered |
220
- | `clearOnHide` | `boolean` | `false` | Reset a field's value when it becomes hidden |
266
+ | `validateOn` | `'input' \| 'blur' \| 'submit' \| 'eager'` | `'blur'` | When validation fires |
267
+ | `validateMode` | `'first' \| 'all'` | `'first'` | Return first error only, or all errors |
268
+ | `clearOnHide` | `boolean` | `false` | Reset field value when it becomes hidden |
221
269
  | `onSubmit` | `(values: T) => void \| Promise<void>` | — | Called after successful validation |
270
+ | `persist` | `false \| 'session' \| 'local'` | `false` | Persist values to sessionStorage / localStorage |
271
+ | `persistKey` | `string` | auto | Storage key prefix |
272
+ | `debug` | `boolean` | `false` | Log state changes to `console.group` |
222
273
 
223
274
  ### Return value
224
275
 
225
276
  | Property | Type | Description |
226
277
  |---|---|---|
227
- | `fields` | `ComputedRef<FieldDefinition[]>` | Field list after conditions are evaluated |
228
- | `values` | `Ref<T>` | Current form values (reactive) |
278
+ | `fields` | `ComputedRef<FieldDefinition[]>` | Fields after conditions are evaluated |
279
+ | `values` | `Ref<T>` | Current form values |
229
280
  | `errors` | `Ref<Record<string, string[]>>` | Validation errors keyed by field name |
230
- | `touched` | `Ref<Record<string, boolean>>` | Whether a field has been interacted with |
231
- | `isDirty` | `ComputedRef<boolean>` | `true` when values differ from the initial state |
281
+ | `touched` | `Ref<Record<string, boolean>>` | Fields that have been blurred |
282
+ | `optionsLoading` | `Ref<Record<string, boolean>>` | Async options loading state per field |
283
+ | `isDirty` | `ComputedRef<boolean>` | `true` when values differ from initial state |
232
284
  | `isValid` | `ComputedRef<boolean>` | `true` when all visible fields pass validation |
233
285
  | `isSubmitting` | `Ref<boolean>` | `true` while `onSubmit` is running |
234
286
  | `submit()` | `() => Promise<void>` | Touch all fields, validate, call `onSubmit` |
235
- | `reset(values?)` | `(values?: Partial<T>) => void` | Restore initial state (or supply new values) |
236
- | `setField(path, value)` | `(path: string, value: unknown) => void` | Programmatically set a value |
237
- | `getField(path)` | `(path: string) => unknown` | Read a value by dot-path |
287
+ | `reset(values?)` | | Restore initial state or supply new values |
288
+ | `setField(path, value)` | | Set a value by dot-path |
289
+ | `getField(path)` | | Read a value by dot-path |
238
290
 
239
- ### Example — programmatic control
240
-
241
- ```ts
242
- const { setField, getField, reset } = useForm({ schema })
291
+ ### `validateOn: 'eager'`
243
292
 
244
- // Set a nested field
245
- setField('address.city', 'Berlin')
246
-
247
- // Read it back
248
- console.log(getField('address.city')) // 'Berlin'
249
-
250
- // Reset to schema defaults
251
- reset()
252
-
253
- // Reset to specific values
254
- reset({ name: 'Alice', email: 'alice@example.com' })
255
- ```
293
+ With `'eager'`, validation runs on input — but only after the field has been blurred at least once. This avoids showing errors while the user is still typing for the first time.
256
294
 
257
295
  ---
258
296
 
@@ -260,30 +298,24 @@ reset({ name: 'Alice', email: 'alice@example.com' })
260
298
 
261
299
  ### FieldDefinition array
262
300
 
263
- The native format — an array of `FieldDefinition` objects, usually created from the parser functions or manually:
264
-
265
301
  ```ts
266
- import type { FieldDefinition } from 'vue-form-schema'
302
+ import type { FieldDefinition } from '@macrulez/vue-form-schema'
267
303
 
268
304
  const schema: FieldDefinition[] = [
269
305
  { type: 'text', name: 'username', required: true },
270
306
  ]
271
-
272
307
  useForm({ schema })
273
308
  ```
274
309
 
275
310
  ### JSON schema
276
311
 
277
- A serialisable array format useful when the schema arrives from an API. Validators are expressed as named rules instead of functions. Pass directly to `useForm` or call `parseJSON` explicitly.
312
+ A serialisable format for server-driven schemas. Pass directly to `useForm` (auto-detected) or call `parseJSON` explicitly.
278
313
 
279
314
  ```ts
280
- import { parseJSON } from 'vue-form-schema'
281
-
282
315
  const raw = [
283
316
  {
284
317
  type: 'text',
285
318
  name: 'username',
286
- label: 'Username',
287
319
  default: '',
288
320
  required: true,
289
321
  validators: [
@@ -291,44 +323,21 @@ const raw = [
291
323
  { rule: 'maxLength', value: 20 },
292
324
  ],
293
325
  },
294
- {
295
- type: 'email',
296
- name: 'email',
297
- validators: [{ rule: 'email' }],
298
- },
299
326
  ]
300
327
 
301
- // Option A pass directly to useForm (auto-detected)
302
- useForm({ schema: raw })
303
-
304
- // Option B — parse explicitly
328
+ useForm({ schema: raw }) // auto-detected
329
+ // or
330
+ import { parseJSON } from '@macrulez/vue-form-schema'
305
331
  const fields = parseJSON(raw)
306
- useForm({ schema: fields })
307
332
  ```
308
333
 
309
- **Supported JSON validator rules:**
310
-
311
- | Rule | Parameter | Example |
312
- |---|---|---|
313
- | `required` | — | `{ rule: 'required' }` |
314
- | `minLength` | `value: number` | `{ rule: 'minLength', value: 2 }` |
315
- | `maxLength` | `value: number` | `{ rule: 'maxLength', value: 100 }` |
316
- | `min` | `value: number` | `{ rule: 'min', value: 0 }` |
317
- | `max` | `value: number` | `{ rule: 'max', value: 9999 }` |
318
- | `pattern` | `value: string` (regex) | `{ rule: 'pattern', value: '^[a-z]+$' }` |
319
- | `email` | — | `{ rule: 'email' }` |
320
- | `url` | — | `{ rule: 'url' }` |
321
-
322
- All rules accept an optional `message` string to override the default error text.
334
+ **Supported JSON validator rules:** `required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`. All accept an optional `message` override.
323
335
 
324
336
  ### Zod
325
337
 
326
- Install Zod and import the adapter from `vue-form-schema/zod`:
327
-
328
338
  ```ts
329
339
  import { z } from 'zod'
330
- import { parseZod } from 'vue-form-schema/zod'
331
- import { useForm } from 'vue-form-schema'
340
+ import { parseZod } from '@macrulez/vue-form-schema/zod'
332
341
 
333
342
  const schema = z.object({
334
343
  name: z.string().min(2).describe('Full name'),
@@ -338,40 +347,16 @@ const schema = z.object({
338
347
  })
339
348
 
340
349
  const fields = parseZod(schema)
341
-
342
- const { values, submit } = useForm({ schema: fields })
350
+ const { values } = useForm({ schema: fields })
343
351
  ```
344
352
 
345
- Type inference is preserved `values` will be typed as the Zod schema's output type when you supply it explicitly:
346
-
347
- ```ts
348
- type FormData = z.infer<typeof schema>
349
-
350
- const { values } = useForm<FormData>({ schema: fields })
351
- // values.value.name is string
352
- ```
353
-
354
- **Zod type mapping:**
355
-
356
- | Zod type | Field type |
357
- |---|---|
358
- | `z.string()` | `text` |
359
- | `z.number()` | `number` |
360
- | `z.boolean()` | `checkbox` |
361
- | `z.enum(...)` | `select` |
362
- | `z.array(...)` | `array` |
363
- | `z.object(...)` | `group` |
364
-
365
- Use `.describe('label text')` on any field to set the `label`.
353
+ **Zod field type mapping:** `z.string()` `text`, `z.number()` `number`, `z.boolean()` `checkbox`, `z.enum()` `select`, `z.array()` `array`, `z.object()` → `group`. Use `.describe('label')` to set the field label.
366
354
 
367
355
  ### Yup
368
356
 
369
- Install Yup and import the adapter from `vue-form-schema/yup`:
370
-
371
357
  ```ts
372
358
  import { object, string, number } from 'yup'
373
- import { parseYup } from 'vue-form-schema/yup'
374
- import { useForm } from 'vue-form-schema'
359
+ import { parseYup } from '@macrulez/vue-form-schema/yup'
375
360
 
376
361
  const schema = object({
377
362
  name: string().required().label('Full name'),
@@ -380,275 +365,634 @@ const schema = object({
380
365
  })
381
366
 
382
367
  const fields = parseYup(schema)
368
+ const { values } = useForm({ schema: fields })
369
+ ```
370
+
371
+ ### Valibot
383
372
 
384
- const { values, submit } = useForm({ schema: fields })
373
+ ```ts
374
+ import * as v from 'valibot'
375
+ import { parseValibot } from '@macrulez/vue-form-schema/valibot'
376
+
377
+ const schema = v.object({
378
+ name: v.pipe(v.string(), v.minLength(2)),
379
+ email: v.pipe(v.string(), v.email()),
380
+ age: v.optional(v.number()),
381
+ role: v.picklist(['admin', 'user']),
382
+ })
383
+
384
+ const fields = parseValibot(schema)
385
+ const { values } = useForm({ schema: fields })
385
386
  ```
386
387
 
387
- **Yup type mapping:**
388
+ **Valibot → field type mapping:** `v.string()` → `text`, `v.number()` → `number`, `v.boolean()` → `checkbox`, `v.picklist()` / `v.enum()` → `select`, `v.array()` → `array`, `v.object()` → `group`. `v.pipe(v.string(), v.email())` → `type: 'email'`. `v.optional()` / `v.nullable()` → `required: false`.
388
389
 
389
- | Yup type | Field type |
390
+ ---
391
+
392
+ ## Built-in validators
393
+
394
+ ```ts
395
+ import {
396
+ required, minLength, maxLength, min, max, pattern, email, url,
397
+ sameAs,
398
+ fileType, fileSize, fileCount,
399
+ } from '@macrulez/vue-form-schema'
400
+ ```
401
+
402
+ | Function | Description |
390
403
  |---|---|
391
- | `string()` | `text` |
392
- | `number()` | `number` |
393
- | `boolean()` | `checkbox` |
394
- | `array()` | `array` |
395
- | `object()` | `group` |
404
+ | `required` | Fails for `null`, `undefined`, `''`, or empty array |
405
+ | `minLength(n, msg?)` | Min length for string or array |
406
+ | `maxLength(n, msg?)` | Max length for string or array |
407
+ | `min(n, msg?)` | Numeric minimum |
408
+ | `max(n, msg?)` | Numeric maximum |
409
+ | `pattern(re, msg?)` | Regex match |
410
+ | `email` | Basic email format |
411
+ | `url` | Valid URL (`new URL()`) |
412
+ | `sameAs(field, msg?)` | Value must equal another field |
413
+ | `fileType(types[], msg?)` | File MIME type or extension whitelist |
414
+ | `fileSize(bytes, msg?)` | Max file size |
415
+ | `fileCount(n, msg?)` | Max number of files |
396
416
 
397
417
  ---
398
418
 
399
- ## Built-in validators
419
+ ## Custom validators
400
420
 
401
- Import standalone validator functions when building a `FieldDefinition` array manually:
421
+ ### Sync
402
422
 
403
423
  ```ts
404
- import {
405
- required,
406
- minLength,
407
- maxLength,
408
- min,
409
- max,
410
- pattern,
411
- email,
412
- url,
413
- } from 'vue-form-schema'
414
- ```
415
-
416
- | Function | Signature | Description |
417
- |---|---|---|
418
- | `required` | `ValidatorFn` | Fails for `null`, `undefined`, `''`, or empty array |
419
- | `minLength(n, msg?)` | `(n: number) => ValidatorFn` | Min length for string or array |
420
- | `maxLength(n, msg?)` | `(n: number) => ValidatorFn` | Max length for string or array |
421
- | `min(n, msg?)` | `(n: number) => ValidatorFn` | Numeric minimum |
422
- | `max(n, msg?)` | `(n: number) => ValidatorFn` | Numeric maximum |
423
- | `pattern(re, msg?)` | `(re: RegExp) => ValidatorFn` | Regex match |
424
- | `email` | `ValidatorFn` | Basic email format check |
425
- | `url` | `ValidatorFn` | Valid URL (uses `new URL()`) |
424
+ import type { ValidatorFn } from '@macrulez/vue-form-schema'
425
+
426
+ const noSpaces: ValidatorFn = (value) =>
427
+ typeof value === 'string' && value.includes(' ') ? 'No spaces allowed' : null
428
+ ```
429
+
430
+ ### Async
431
+
432
+ Async validators are debounced (300 ms). Errors are merged into `errors` after resolution.
426
433
 
427
434
  ```ts
428
- import { minLength, maxLength, email } from 'vue-form-schema'
435
+ import type { AsyncValidatorFn } from '@macrulez/vue-form-schema'
436
+
437
+ const uniqueUsername: AsyncValidatorFn = async (value) => {
438
+ const { taken } = await fetch(`/api/check?q=${value}`).then((r) => r.json())
439
+ return taken ? 'Username is taken' : null
440
+ }
441
+ ```
442
+
443
+ ### Multiple errors per field (`validateMode`)
444
+
445
+ ```ts
446
+ useForm({
447
+ schema,
448
+ validateMode: 'all', // collect all errors per field (default: 'first')
449
+ })
450
+ ```
451
+
452
+ ---
453
+
454
+ ## Cross-field validation
455
+
456
+ Use `sameAs` for password confirmation or write a custom validator — all validators receive `allValues` as the second argument.
457
+
458
+ ```ts
459
+ import { sameAs } from '@macrulez/vue-form-schema'
429
460
 
430
461
  const schema: FieldDefinition[] = [
462
+ { type: 'text', name: 'password', label: 'Password', required: true },
431
463
  {
432
464
  type: 'text',
433
- name: 'username',
465
+ name: 'confirm',
466
+ label: 'Confirm password',
467
+ validators: [sameAs('password', 'Passwords must match')],
468
+ },
469
+ ]
470
+ ```
471
+
472
+ ---
473
+
474
+ ## Conditional fields
475
+
476
+ `visible` and `disabled` can be a boolean, a reactive function, or a safe string expression.
477
+
478
+ ```ts
479
+ const schema: FieldDefinition[] = [
480
+ { type: 'checkbox', name: 'hasCompany', label: 'I represent a company' },
481
+ {
482
+ type: 'text',
483
+ name: 'companyName',
484
+ label: 'Company name',
485
+ visible: (values) => values['hasCompany'] === true,
434
486
  required: true,
435
- validators: [
436
- minLength(3, 'At least 3 characters'),
437
- maxLength(20),
438
- ],
439
487
  },
488
+ // string expression — has access to the `values` variable
440
489
  {
441
- type: 'email',
442
- name: 'email',
443
- validators: [email],
490
+ type: 'select',
491
+ name: 'drink',
492
+ label: 'Drink',
493
+ visible: 'values.age >= 18',
494
+ options: [{ label: 'Beer', value: 'beer' }, { label: 'Water', value: 'water' }],
444
495
  },
445
496
  ]
446
497
  ```
447
498
 
448
- ---
499
+ Set `clearOnHide: true` in `useForm` to automatically reset a hidden field's value.
449
500
 
450
- ## Custom validators
501
+ ---
451
502
 
452
- ### Sync validator
503
+ ## Dynamic options
453
504
 
454
- A `ValidatorFn` receives the field's current value and all form values. Return a string on failure, or `null` on success.
505
+ `options` can be a static array, a **sync function**, or an **async function**.
455
506
 
456
507
  ```ts
457
- import type { ValidatorFn } from 'vue-form-schema'
508
+ // Sync re-evaluated on every values change
509
+ {
510
+ type: 'select',
511
+ name: 'city',
512
+ options: (values) => citiesByCountry[values['country'] as string] ?? [],
513
+ }
458
514
 
459
- const noSpaces: ValidatorFn = (value) => {
460
- if (typeof value === 'string' && value.includes(' ')) return 'No spaces allowed'
461
- return null
515
+ // Async fetched on mount and re-fetched when optionsDeps change
516
+ {
517
+ type: 'select',
518
+ name: 'framework',
519
+ optionsDeps: ['language'],
520
+ options: async (values) => {
521
+ const res = await fetch(`/api/frameworks?lang=${values['language']}`)
522
+ return res.json()
523
+ },
462
524
  }
525
+ ```
526
+
527
+ While loading, `optionsLoading.value['framework']` is `true` and the select is disabled in `FormRenderer`. Access the loading state directly via `form.optionsLoading`.
528
+
529
+ ### Computed `defaultValue`
463
530
 
464
- // Cross-field: confirm password
465
- const matchesPassword: ValidatorFn = (value, allValues) => {
466
- if (value !== allValues['password']) return 'Passwords do not match'
467
- return null
531
+ `defaultValue` can also be a function evaluated at form initialisation with already-resolved partial values as context:
532
+
533
+ ```ts
534
+ {
535
+ type: 'text',
536
+ name: 'displayName',
537
+ defaultValue: (values) => `${values.firstName} ${values.lastName}`,
468
538
  }
539
+ ```
469
540
 
541
+ ---
542
+
543
+ ## Dynamic array fields
544
+
545
+ ### Schema definition
546
+
547
+ ```ts
470
548
  const schema: FieldDefinition[] = [
471
- { type: 'text', name: 'password', label: 'Password' },
472
549
  {
473
- type: 'text',
474
- name: 'confirmPassword',
475
- label: 'Confirm password',
476
- validators: [matchesPassword],
550
+ type: 'array',
551
+ name: 'members',
552
+ label: 'Team members',
553
+ fields: [
554
+ { type: 'text', name: 'members.name', label: 'Name', required: true },
555
+ { type: 'email', name: 'members.email', label: 'Email', required: true },
556
+ ],
477
557
  },
478
558
  ]
479
559
  ```
480
560
 
481
- ### Async validator
561
+ `FormRenderer` renders an `ArrayField` automatically with Add / Remove buttons.
482
562
 
483
- An `AsyncValidatorFn` returns a `Promise<string | null>`. Async validators are debounced (300 ms by default) so they don't fire on every keystroke.
563
+ ### `useFieldArray` composable
484
564
 
485
565
  ```ts
486
- import type { AsyncValidatorFn } from 'vue-form-schema'
566
+ import { useFieldArray } from '@macrulez/vue-form-schema'
487
567
 
488
- const uniqueUsername: AsyncValidatorFn = async (value) => {
489
- const res = await fetch(`/api/check-username?q=${value}`)
490
- const { taken } = await res.json()
491
- return taken ? 'This username is already taken' : null
492
- }
568
+ const { rows, count, append, prepend, remove, move, swap, replace } =
569
+ useFieldArray(form, 'members')
570
+ ```
571
+
572
+ | Method | Description |
573
+ |---|---|
574
+ | `append(defaults?)` | Add a row at the end |
575
+ | `prepend(defaults?)` | Add a row at the beginning |
576
+ | `remove(index)` | Remove a row |
577
+ | `move(from, to)` | Move a row |
578
+ | `swap(a, b)` | Swap two rows |
579
+ | `replace(index, defaults?)` | Replace a row with fresh defaults |
580
+
581
+ `rows` is a `ComputedRef<FieldArrayRow[]>`. Each row exposes `index`, `key`, and `fields` — the nested `FieldDefinition[]` with prefixed paths for that row.
582
+
583
+ ---
584
+
585
+ ## Multi-step forms
586
+
587
+ ```ts
588
+ import { useMultiStepForm } from '@macrulez/vue-form-schema'
589
+
590
+ const wizard = useMultiStepForm(
591
+ [
592
+ { title: 'Account', schema: accountFields },
593
+ { title: 'Profile', schema: profileFields },
594
+ { title: 'Confirm', schema: confirmFields },
595
+ ],
596
+ async (allValues) => {
597
+ await api.register(allValues)
598
+ },
599
+ )
600
+ ```
493
601
 
602
+ | Property / Method | Description |
603
+ |---|---|
604
+ | `currentStep` | `Ref<number>` — 0-based index |
605
+ | `totalSteps` | Number of steps |
606
+ | `isFirstStep` / `isLastStep` | `ComputedRef<boolean>` |
607
+ | `form` | `UseFormReturn` for the current step |
608
+ | `values` | All values across all steps merged |
609
+ | `next()` | Validate current step then advance (returns `false` if invalid) |
610
+ | `back()` | Go to previous step |
611
+ | `goTo(n)` | Jump to step `n` |
612
+ | `submit()` | Validate all steps then call `onSubmit` |
613
+
614
+ ### `MultiStepFormRenderer`
615
+
616
+ ```vue
617
+ import { MultiStepFormRenderer } from '@macrulez/vue-form-schema/ui'
618
+
619
+ <MultiStepFormRenderer :wizard="wizard" />
620
+ ```
621
+
622
+ Renders the current step's fields and Back / Next / Submit navigation buttons.
623
+
624
+ ---
625
+
626
+ ## Field transform & parse
627
+
628
+ ```ts
494
629
  const schema: FieldDefinition[] = [
495
630
  {
496
631
  type: 'text',
497
632
  name: 'username',
498
- required: true,
499
- asyncValidators: [uniqueUsername],
633
+ // trim on every keystroke
634
+ transform: (value) => (typeof value === 'string' ? value.trim() : value),
635
+ },
636
+ {
637
+ type: 'text',
638
+ name: 'tags',
639
+ defaultValue: 'vue,react',
640
+ // split into array at submit time — values.tags is still a string
641
+ parse: (raw) => String(raw).split(',').map((s) => s.trim()),
500
642
  },
501
643
  ]
502
644
  ```
503
645
 
504
- ---
505
-
506
- ## Conditional fields
646
+ `transform` mutates `values` immediately. `parse` runs only at submit time and does not mutate `values`.
507
647
 
508
- `visible` and `disabled` can be a static boolean, a reactive function, or a safe string expression.
648
+ ---
509
649
 
510
- ### Function condition
650
+ ## File upload field
511
651
 
512
652
  ```ts
653
+ import { fileType, fileSize, fileCount } from '@macrulez/vue-form-schema'
654
+
513
655
  const schema: FieldDefinition[] = [
514
656
  {
515
- type: 'checkbox',
516
- name: 'hasCompany',
517
- label: 'I represent a company',
518
- },
519
- {
520
- type: 'text',
521
- name: 'companyName',
522
- label: 'Company name',
523
- required: true,
524
- // shown only when hasCompany is checked
525
- visible: (values) => values['hasCompany'] === true,
657
+ type: 'file',
658
+ name: 'avatar',
659
+ label: 'Profile photo',
660
+ accept: 'image/*',
661
+ validators: [
662
+ fileType(['image/'], 'Only images are accepted'),
663
+ fileSize(2 * 1024 * 1024, 'Max 2 MB'),
664
+ ],
526
665
  },
527
666
  {
528
- type: 'text',
529
- name: 'vat',
530
- label: 'VAT number',
531
- // disabled unless company name is entered
532
- disabled: (values) => !values['companyName'],
667
+ type: 'file',
668
+ name: 'attachments',
669
+ label: 'Attachments',
670
+ multiple: true,
671
+ validators: [fileCount(5, 'Up to 5 files')],
533
672
  },
534
673
  ]
535
674
  ```
536
675
 
537
- ### String expression
676
+ `values['avatar']` is `File | null`; `values['attachments']` is `File[] | null`.
677
+
678
+ `FormRenderer` automatically renders `FileField` with a drag-and-drop zone and a file list with remove buttons.
679
+
680
+ ---
681
+
682
+ ## Custom field components
683
+
684
+ A custom component is a pure **presentation layer** — it receives pre-computed validation state as props and signals changes back to the form. No validation logic lives inside the component itself.
685
+
686
+ ### How validation flows
687
+
688
+ ```
689
+ useForm
690
+ ├─ validators / asyncValidators / required ← defined in the schema
691
+ ├─ errors.value['fieldName'] = ['Too short'] ← computed internally
692
+ └─ passes to your component via props:
693
+ error: string[] — list of error messages
694
+ touched: boolean — whether the field has been blurred
695
+ ```
696
+
697
+ Your component's only job:
698
+
699
+ | What | How |
700
+ |---|---|
701
+ | Report a value change | `emit('update:modelValue', newValue)` |
702
+ | Trigger validation | `emit('blur')` — fires validation when `validateOn` is `'blur'` or `'eager'` |
703
+ | Show errors | Read `props.error` / `props.touched` (or use `useFormField`) |
538
704
 
539
- String expressions have access to a `values` variable. Arithmetic, comparison, and logical operators are supported. Potentially unsafe calls (`fetch`, `eval`, etc.) are rejected by a whitelist check.
705
+ ### The `FormFieldProps` contract
706
+
707
+ Every component that plugs into the library must declare these props and two emits:
540
708
 
541
709
  ```ts
710
+ import type { FormFieldProps } from '@macrulez/vue-form-schema'
711
+
712
+ // props
713
+ const props = defineProps<FormFieldProps>()
714
+ // {
715
+ // field: FieldDefinition — the full field config (validators, label, …)
716
+ // modelValue: unknown — current value from form state
717
+ // error: string[] — validation errors (empty when valid)
718
+ // touched: boolean — true after first blur
719
+ // }
720
+
721
+ // emits
722
+ const emit = defineEmits<{
723
+ 'update:modelValue': [value: unknown]
724
+ blur: []
725
+ }>()
726
+ ```
727
+
728
+ ### Complete example — custom phone input
729
+
730
+ ```vue
731
+ <!-- MyPhoneInput.vue -->
732
+ <script setup lang="ts">
733
+ import { computed } from 'vue'
734
+ import { useFormField } from '@macrulez/vue-form-schema'
735
+ import type { FormFieldProps } from '@macrulez/vue-form-schema'
736
+
737
+ const props = defineProps<FormFieldProps>()
738
+ const emit = defineEmits<{
739
+ 'update:modelValue': [value: string]
740
+ blur: []
741
+ }>()
742
+
743
+ const { hasError, errorMessage, isRequired } = useFormField(props)
744
+
745
+ // strip non-digits for storage, display formatted
746
+ const display = computed(() =>
747
+ String(props.modelValue ?? '').replace(/\D/g, '').replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3'),
748
+ )
749
+ </script>
750
+
751
+ <template>
752
+ <div class="field">
753
+ <label :for="field.name">
754
+ {{ field.label }}
755
+ <span v-if="isRequired" aria-hidden="true">*</span>
756
+ </label>
757
+
758
+ <input
759
+ :id="field.name"
760
+ type="tel"
761
+ :value="display"
762
+ :aria-invalid="hasError ? 'true' : 'false'"
763
+ :aria-describedby="hasError ? `${field.name}-error` : undefined"
764
+ @input="emit('update:modelValue', ($event.target as HTMLInputElement).value.replace(/\D/g, ''))"
765
+ @blur="emit('blur')"
766
+ />
767
+
768
+ <p v-if="hasError" :id="`${field.name}-error`" role="alert">
769
+ {{ errorMessage }}
770
+ </p>
771
+ </div>
772
+ </template>
773
+ ```
774
+
775
+ ### Attach to a field via `field.component`
776
+
777
+ ```ts
778
+ import MyPhoneInput from './MyPhoneInput.vue'
779
+ import { minLength, pattern } from '@macrulez/vue-form-schema'
780
+
542
781
  const schema: FieldDefinition[] = [
543
- { type: 'number', name: 'age', label: 'Age' },
544
782
  {
545
- type: 'select',
546
- name: 'alcoholChoice',
547
- label: 'Drink preference',
548
- visible: 'values.age >= 18', // string expression
549
- options: [
550
- { label: 'Beer', value: 'beer' },
551
- { label: 'Wine', value: 'wine' },
552
- { label: 'Water', value: 'water' },
783
+ type: 'text',
784
+ name: 'phone',
785
+ label: 'Phone number',
786
+ component: MyPhoneInput, // your component renders instead of TextField
787
+ required: true,
788
+ validators: [
789
+ minLength(10, 'Enter a full phone number'),
790
+ pattern(/^\d{10}$/, 'Digits only, 10 characters'),
553
791
  ],
554
792
  },
555
793
  ]
556
794
  ```
557
795
 
558
- ### clearOnHide
796
+ ### Using without `FormRenderer` (manual wiring)
559
797
 
560
- When `clearOnHide: true` is set in `useForm`, hiding a field resets its value back to `defaultValue` (or `null`).
798
+ If you render fields yourself without `FormRenderer` wire errors and the touch handler directly:
799
+
800
+ ```vue
801
+ <script setup lang="ts">
802
+ import { useForm } from '@macrulez/vue-form-schema'
803
+ import MyPhoneInput from './MyPhoneInput.vue'
804
+
805
+ const form = useForm({ schema, validateOn: 'blur' })
806
+ const touchField = (form as any).touchField // exposed internally
807
+ </script>
808
+
809
+ <template>
810
+ <form @submit.prevent="form.submit()">
811
+ <MyPhoneInput
812
+ :field="form.fields.value[0]"
813
+ :model-value="form.values.value.phone"
814
+ :error="form.errors.value.phone ?? []"
815
+ :touched="form.touched.value.phone ?? false"
816
+ @update:model-value="form.setField('phone', $event)"
817
+ @blur="touchField('phone')"
818
+ />
819
+ <button type="submit">Save</button>
820
+ </form>
821
+ </template>
822
+ ```
823
+
824
+ ### `useFormField` helper — computed shortcuts
561
825
 
562
826
  ```ts
563
- useForm({
564
- schema,
565
- clearOnHide: true,
566
- })
827
+ import { useFormField } from '@macrulez/vue-form-schema'
828
+
829
+ const props = defineProps<FormFieldProps>()
830
+ const {
831
+ hasError, // ComputedRef<boolean> — touched && error.length > 0
832
+ errorMessage, // ComputedRef<string | null> — first error, or null
833
+ allErrors, // ComputedRef<string[]> — all errors when touched, else []
834
+ isRequired, // ComputedRef<boolean>
835
+ isDisabled, // ComputedRef<boolean>
836
+ } = useFormField(props)
837
+ ```
838
+
839
+ ---
840
+
841
+ ## Component registry
842
+
843
+ Replace all instances of a field type across a subtree — useful for integrating UI libraries.
844
+
845
+ ### App-level (Vue plugin)
846
+
847
+ ```ts
848
+ import { createApp } from 'vue'
849
+ import { createFormRegistry } from '@macrulez/vue-form-schema'
850
+ import { ElInput, ElSelect } from 'element-plus'
851
+
852
+ createApp(App)
853
+ .use(createFormRegistry({ text: ElInput, select: ElSelect }))
854
+ .mount('#app')
567
855
  ```
568
856
 
857
+ ### Subtree-level
858
+
859
+ ```ts
860
+ import { provideRegistry } from '@macrulez/vue-form-schema'
861
+
862
+ // Inside a component's setup()
863
+ provideRegistry({ checkbox: MyToggle })
864
+ ```
865
+
866
+ Component priority: `field.component` > `FormRenderer :components` prop > registry > built-in defaults.
867
+
569
868
  ---
570
869
 
571
870
  ## Input masking
572
871
 
573
- Masks format user input in real time. They are applied automatically inside `FormRenderer` and can be used standalone via the `applyMask` / `removeMask` / `bindMask` functions.
872
+ Masks format user input in real time. Applied automatically in `FormRenderer`; also usable standalone.
574
873
 
575
- ### Mask presets
874
+ ### Presets
576
875
 
577
- | Preset | Pattern | Example output |
578
- |---|---|---|
579
- | `phone-ru` | `+7 (###) ###-##-##` | `+7 (916) 123-45-67` |
580
- | `phone-eu` | `+## (##) ###-##-##` | `+49 (30) 123-45-67` |
581
- | `date` | `##.##.####` | `01.01.2024` |
582
- | `inn` | `############` | `123456789012` |
583
- | `iban` | `AA## #### #### #### #### #### ####` | `GB29 NWBK 6016 ...` |
876
+ | Preset | Example output |
877
+ |---|---|
878
+ | `phone-ru` | `+7 (916) 123-45-67` |
879
+ | `phone-eu` | `+49 (30) 123-45-67` |
880
+ | `date` | `01.01.2024` |
881
+ | `inn` | `123456789012` |
882
+ | `iban` | `GB29 NWBK 6016 1331 9268 19` |
584
883
 
585
884
  ```ts
586
- // Using a preset
587
- const field: FieldDefinition = {
588
- type: 'text',
589
- name: 'phone',
590
- label: 'Phone',
591
- mask: { preset: 'phone-ru' },
592
- }
885
+ { type: 'text', name: 'phone', mask: { preset: 'phone-ru' } }
593
886
  ```
594
887
 
595
888
  ### Custom patterns
596
889
 
597
- - `#` matches a single digit (0–9)
598
- - `A` — matches a single letter (a–z, A–Z); output is uppercased
599
- - Any other character — treated as a fixed literal
890
+ `#` = digit, `A` = letter (uppercased), anything else = literal.
600
891
 
601
892
  ```ts
602
- // Postcode: two letters + four digits
603
- const field: FieldDefinition = {
604
- type: 'text',
605
- name: 'postcode',
606
- label: 'Postcode',
607
- mask: { pattern: 'AA####' }, // e.g. AB1234
608
- }
893
+ { type: 'text', name: 'postcode', mask: { pattern: 'AA####' } } // AB1234
894
+ ```
895
+
896
+ ### Standalone API
897
+
898
+ ```ts
899
+ import { applyMask, removeMask, bindMask } from '@macrulez/vue-form-schema'
900
+
901
+ applyMask('9161234567', { preset: 'phone-ru' }) // '+7 (916) 123-45-67'
902
+ removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' }) // '9161234567'
903
+
904
+ const cleanup = bindMask(inputEl, { preset: 'date' })
905
+ onUnmounted(cleanup)
906
+ ```
907
+
908
+ ---
909
+
910
+ ## Schema composition
911
+
912
+ ```ts
913
+ import { mergeSchemas, omitFields, pickFields, extendField } from '@macrulez/vue-form-schema'
914
+
915
+ const base = [
916
+ { type: 'text' as const, name: 'firstName' },
917
+ { type: 'text' as const, name: 'lastName' },
918
+ { type: 'email' as const, name: 'email' },
919
+ ]
920
+
921
+ // Combine — later schemas win on name collision
922
+ const extended = mergeSchemas(base, [{ type: 'text' as const, name: 'phone' }])
923
+
924
+ // Remove fields
925
+ const noEmail = omitFields(base, ['email'])
926
+
927
+ // Keep only specific fields
928
+ const nameOnly = pickFields(base, ['firstName', 'lastName'])
929
+
930
+ // Non-mutating patch
931
+ const required = extendField(base, 'email', { required: true, label: 'Email address' })
609
932
  ```
610
933
 
611
- ### Standalone mask API
934
+ ---
935
+
936
+ ## TypeScript inference
937
+
938
+ `InferValues<T>` maps a `readonly FieldDefinition[]` literal to a typed values object.
612
939
 
613
940
  ```ts
614
- import { applyMask, removeMask, bindMask } from 'vue-form-schema'
941
+ import { defineSchema } from '@macrulez/vue-form-schema'
942
+ import type { InferValues } from '@macrulez/vue-form-schema'
615
943
 
616
- // Format a raw value
617
- applyMask('9161234567', { preset: 'phone-ru' })
618
- // '+7 (916) 123-45-67'
944
+ const schema = defineSchema([
945
+ { type: 'text' as const, name: 'username' as const },
946
+ { type: 'number' as const, name: 'age' as const },
947
+ { type: 'checkbox' as const, name: 'agreed' as const },
948
+ ] as const)
619
949
 
620
- // Strip mask literals from a formatted value
621
- removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' })
622
- // → '9161234567'
950
+ type Values = InferValues<typeof schema>
951
+ // { username: string; age: number; agreed: boolean }
623
952
 
624
- // Attach to a native <input> element (returns cleanup fn)
625
- const cleanup = bindMask(inputElement, { preset: 'date' })
626
- // call cleanup() in onUnmounted
953
+ const { values } = useForm<Values>({ schema })
954
+ // values.value.username is string
627
955
  ```
628
956
 
957
+ **Type mapping:** `checkbox` → `boolean`, `number` → `number`, `array` → `unknown[]`, everything else → `string`.
958
+
629
959
  ---
630
960
 
631
- ## FormRenderer UI component
961
+ ## Persisted forms
962
+
963
+ ```ts
964
+ useForm({
965
+ schema,
966
+ persist: 'local', // or 'session'
967
+ persistKey: 'checkout', // optional — defaults to a hash of field names
968
+ })
969
+ ```
632
970
 
633
- `FormRenderer` is in the optional `vue-form-schema/ui` subpackage. It renders fields automatically from the schema and delegates display to built-in or custom components.
971
+ Values are restored from storage on `onMounted`. `reset()` clears the stored value. SSR-safe: the storage read is guarded by `typeof window !== 'undefined'`.
972
+
973
+ ---
974
+
975
+ ## Debug mode
634
976
 
635
977
  ```ts
636
- import { FormRenderer } from 'vue-form-schema/ui'
978
+ // Log every values change to console.group
979
+ useForm({ schema, debug: true })
637
980
  ```
638
981
 
639
- ### Basic usage
982
+ ```ts
983
+ // Reactive snapshot of all form state
984
+ import { useFormDebug } from '@macrulez/vue-form-schema'
640
985
 
641
- ```vue
642
- <script setup lang="ts">
643
- import { useForm } from 'vue-form-schema'
644
- import { FormRenderer } from 'vue-form-schema/ui'
986
+ const { snapshot } = useFormDebug(form)
987
+ // snapshot.value = { values, errors, touched, isDirty, isValid, isSubmitting }
988
+ ```
645
989
 
646
- const form = useForm({ schema, onSubmit })
647
- </script>
990
+ ---
648
991
 
649
- <template>
650
- <FormRenderer :form="form" submit-label="Save" />
651
- </template>
992
+ ## FormRenderer UI component
993
+
994
+ ```ts
995
+ import { FormRenderer } from '@macrulez/vue-form-schema/ui'
652
996
  ```
653
997
 
654
998
  ### Props
@@ -663,50 +1007,16 @@ const form = useForm({ schema, onSubmit })
663
1007
 
664
1008
  | Slot | Scope | Description |
665
1009
  |---|---|---|
666
- | `#field-{name}` | `{ field, value, error, touched, setValue, touch }` | Replace the entire field |
667
- | `#label-{name}` | `{ field }` | Replace the field label |
668
- | `#error-{name}` | `{ field, error }` | Replace the error display |
1010
+ | `#field-{name}` | `{ field, value, error, touched, setValue, touch }` | Replace an entire field |
1011
+ | `#label-{name}` | `{ field }` | Replace a label |
1012
+ | `#error-{name}` | `{ field, error }` | Replace error display |
669
1013
  | `#submit` | `{ isSubmitting, isValid }` | Replace the submit button |
670
1014
 
671
- ### Custom field renderers
672
-
673
- Pass a `components` map to swap built-in inputs with your own (e.g. Element Plus, Naive UI):
674
-
675
- ```vue
676
- <script setup lang="ts">
677
- import { ElInput, ElSelect } from 'element-plus'
678
-
679
- const myComponents = { text: ElInput, select: ElSelect }
680
- </script>
681
-
682
- <template>
683
- <FormRenderer :form="form" :components="myComponents" />
684
- </template>
685
- ```
686
-
687
- ### Per-field slot override
688
-
689
- ```vue
690
- <template>
691
- <FormRenderer :form="form">
692
- <!-- replace the "avatar" field entirely -->
693
- <template #field-avatar="{ value, setValue }">
694
- <AvatarUploader :model-value="value" @update:model-value="setValue" />
695
- </template>
696
-
697
- <!-- custom submit -->
698
- <template #submit="{ isSubmitting }">
699
- <MyButton type="submit" :loading="isSubmitting">Save profile</MyButton>
700
- </template>
701
- </FormRenderer>
702
- </template>
703
- ```
704
-
705
1015
  ### Built-in field components
706
1016
 
707
- The following components are exported from `vue-form-schema/ui` and can be used independently:
1017
+ All exported individually from `vue-form-schema/ui`:
708
1018
 
709
- | Component | Types |
1019
+ | Component | Field types |
710
1020
  |---|---|
711
1021
  | `TextField` | `text`, `email` |
712
1022
  | `NumberField` | `number` |
@@ -715,56 +1025,55 @@ The following components are exported from `vue-form-schema/ui` and can be used
715
1025
  | `CheckboxField` | `checkbox` |
716
1026
  | `RadioField` | `radio` |
717
1027
  | `DateField` | `date` |
1028
+ | `ArrayField` | `array` |
1029
+ | `FileField` | `file` |
718
1030
 
719
1031
  ---
720
1032
 
721
- ## SSR compatibility
1033
+ ## Tailwind UI theme
722
1034
 
723
- The core package (`useForm`, validators, parsers, `ConditionEvaluator`) does not use any browser-specific APIs. State management and validation work on the server.
1035
+ A drop-in replacement for `FormRenderer` using Tailwind utility classes no custom CSS needed in your app. Requires Tailwind CSS installed and configured to scan library source files.
724
1036
 
725
- `bindMask` and `FormRenderer` use DOM APIs (`HTMLInputElement`, event listeners) and should only be called or mounted on the client side. Gate them with `onMounted` or Vue's `<ClientOnly>` wrapper as needed.
726
-
727
- ---
1037
+ ```ts
1038
+ import { TailwindFormRenderer } from '@macrulez/vue-form-schema/ui/tailwind'
1039
+ ```
728
1040
 
729
- ## TypeScript generics
1041
+ ```vue
1042
+ <!-- Same form, same schema — just swap the renderer -->
1043
+ <TailwindFormRenderer :form="form" submit-label="Save" />
1044
+ ```
730
1045
 
731
- `useForm` is generic over the shape of the values object. Supply the type explicitly to get full inference on `values.value`:
1046
+ All field components are also exported individually:
732
1047
 
733
1048
  ```ts
734
- interface UserForm {
735
- name: string
736
- email: string
737
- age: number | null
738
- role: 'admin' | 'user'
739
- }
1049
+ import {
1050
+ TwTextField, TwSelectField, TwCheckboxField,
1051
+ TwRadioField, TwFileField, TwArrayField,
1052
+ //
1053
+ } from '@macrulez/vue-form-schema/ui/tailwind'
1054
+ ```
740
1055
 
741
- const { values, submit } = useForm<UserForm>({
742
- schema,
743
- onSubmit: (data) => {
744
- // data is typed as UserForm
745
- console.log(data.name)
746
- },
747
- })
1056
+ ---
748
1057
 
749
- // values.value is Ref<UserForm>
750
- ```
1058
+ ## Accessibility
751
1059
 
752
- ### Validator function types
1060
+ All built-in field components include full a11y attributes:
753
1061
 
754
- ```ts
755
- import type { ValidatorFn, AsyncValidatorFn } from 'vue-form-schema'
1062
+ | Feature | How |
1063
+ |---|---|
1064
+ | `aria-required` | Set to `"true"` on required inputs, selects, textareas, fieldsets |
1065
+ | `aria-invalid` | Set to `"true"` when the field is touched and has errors |
1066
+ | `aria-describedby` | Points to `"{name}-error"` when errors are present |
1067
+ | `role="alert"` + `aria-live="polite"` | Error lists are announced by screen readers on appearance |
1068
+ | `label[for]` + `input[id]` | All inputs have matching label and id |
1069
+ | `fieldset` + `legend` | Radio groups use semantic grouping |
1070
+ | `aria-checked` | Checkboxes reflect boolean state explicitly |
756
1071
 
757
- // Sync: (value, allValues) => string | null
758
- const myValidator: ValidatorFn = (value, allValues) => {
759
- return String(value).length > 0 ? null : 'Required'
760
- }
1072
+ ---
761
1073
 
762
- // Async: (value, allValues) => Promise<string | null>
763
- const myAsyncValidator: AsyncValidatorFn = async (value) => {
764
- const ok = await checkServer(value)
765
- return ok ? null : 'Not available'
766
- }
767
- ```
1074
+ ## SSR compatibility
1075
+
1076
+ The core (`useForm`, validators, parsers, `ConditionEvaluator`) does not use browser APIs. `bindMask` and `FormRenderer` use DOM APIs — wrap them in `onMounted` or `<ClientOnly>` when needed.
768
1077
 
769
1078
  ---
770
1079
 
@@ -773,40 +1082,25 @@ const myAsyncValidator: AsyncValidatorFn = async (value) => {
773
1082
  ```
774
1083
  useForm
775
1084
 
776
- ├── SchemaParser (json / zod / yup)
1085
+ ├── Schema normalisation (json / zod / yup / valibot)
777
1086
  │ └── FieldDefinition[]
778
1087
 
779
1088
  ├── ConditionEvaluator
780
- reactive watchEffect → resolves visible / disabled per field
1089
+ │ watchEffect → resolves visible / disabled / options per field
781
1090
 
782
1091
  ├── ValidationEngine
783
1092
  │ sync validators → errors Record<path, string[]>
784
- │ async validators → debounced, merged into errors
1093
+ │ async validators → debounced 300ms
785
1094
 
786
1095
  ├── MaskEngine (standalone)
787
1096
  │ applyMask / removeMask / bindMask
788
1097
 
789
- └── (optional) FormRenderer [/ui]
790
- renders FieldDefinition[] → input components
791
- slots: #field-{name}, #label-{name}, #error-{name}, #submit
792
- ```
793
-
794
- **Module dependency graph:**
795
-
796
- ```
797
- index.ts
798
- ├── core/types.ts
799
- ├── core/useForm.ts
800
- │ ├── core/ValidationEngine.ts
801
- │ ├── core/ConditionEvaluator.ts
802
- │ └── parsers/json.ts
803
- ├── core/MaskEngine.ts
804
- ├── parsers/zod.ts ← vue-form-schema/zod
805
- └── parsers/yup.ts ← vue-form-schema/yup
806
-
807
- ui/index.ts ← vue-form-schema/ui
808
- ├── ui/FormRenderer.vue
809
- └── ui/fields/*.vue
1098
+ └── (optional) UI subpackages
1099
+ FormRenderer [/ui]
1100
+ TailwindFormRenderer [/ui/tailwind]
1101
+ useFieldArray [core]
1102
+ useMultiStepForm [core]
1103
+ useFormDebug [core]
810
1104
  ```
811
1105
 
812
1106
  ---
@@ -815,10 +1109,12 @@ ui/index.ts ← vue-form-schema/ui
815
1109
 
816
1110
  | Entry point | Peer deps | Notes |
817
1111
  |---|---|---|
818
- | `vue-form-schema` | `vue ^3.3` | Core 8 KB gzip |
819
- | `vue-form-schema/zod` | `zod ^3` | Lazy zero cost if unused |
820
- | `vue-form-schema/yup` | `yup ^1` | Lazy zero cost if unused |
821
- | `vue-form-schema/ui` | `vue ^3.3` | Optional UI layer |
1112
+ | `vue-form-schema` | `vue ^3.3` | Core headless, no UI |
1113
+ | `vue-form-schema/zod` | `zod ^3` | Optional adapter |
1114
+ | `vue-form-schema/yup` | `yup ^1` | Optional adapter |
1115
+ | `vue-form-schema/valibot` | `valibot ^1` | Optional adapter |
1116
+ | `vue-form-schema/ui` | `vue ^3.3` | BEM-styled built-in components |
1117
+ | `vue-form-schema/ui/tailwind` | `vue ^3.3`, Tailwind CSS | Tailwind utility-class components |
822
1118
 
823
1119
  All entry points are tree-shakeable ESM + CJS dual builds.
824
1120