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