@macrulez/vue-form-schema 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +461 -180
  2. package/dist/MaskEngine-BEXwX4BK.js +144 -0
  3. package/dist/MaskEngine-CPr-fSPy.cjs +1 -0
  4. package/dist/ValidationEngine-BC4U1ef1.js +135 -0
  5. package/dist/ValidationEngine-Diz-qTFs.cjs +1 -0
  6. package/dist/_plugin-vue_export-helper-BHFhmbuH.cjs +1 -0
  7. package/dist/_plugin-vue_export-helper-CHgC5LLL.js +9 -0
  8. package/dist/core/ConditionEvaluator.d.ts.map +1 -1
  9. package/dist/core/MaskEngine.d.ts.map +1 -1
  10. package/dist/core/ValidationEngine.d.ts +7 -0
  11. package/dist/core/ValidationEngine.d.ts.map +1 -1
  12. package/dist/core/formRegistry.d.ts +33 -0
  13. package/dist/core/formRegistry.d.ts.map +1 -0
  14. package/dist/core/inferTypes.d.ts.map +1 -1
  15. package/dist/core/schemaUtils.d.ts +38 -0
  16. package/dist/core/schemaUtils.d.ts.map +1 -1
  17. package/dist/core/serverErrors.d.ts +69 -0
  18. package/dist/core/serverErrors.d.ts.map +1 -0
  19. package/dist/core/types.d.ts +12 -0
  20. package/dist/core/types.d.ts.map +1 -1
  21. package/dist/core/useForm.d.ts +4 -1
  22. package/dist/core/useForm.d.ts.map +1 -1
  23. package/dist/core/useMultiStepForm.d.ts.map +1 -1
  24. package/dist/devtools/index.d.ts +26 -0
  25. package/dist/devtools/index.d.ts.map +1 -0
  26. package/dist/devtools.cjs +1 -0
  27. package/dist/devtools.d.ts +6 -0
  28. package/dist/devtools.js +93 -0
  29. package/dist/formRegistry-Bv1eaWQ-.js +31 -0
  30. package/dist/formRegistry-DaDmXyYY.cjs +1 -0
  31. package/dist/index.cjs +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +393 -368
  35. package/dist/openapi.cjs +1 -0
  36. package/dist/openapi.d.ts +202 -0
  37. package/dist/openapi.js +118 -0
  38. package/dist/parsers/json.d.ts.map +1 -1
  39. package/dist/parsers/openapi.d.ts +139 -0
  40. package/dist/parsers/openapi.d.ts.map +1 -0
  41. package/dist/parsers/valibot.d.ts +13 -4
  42. package/dist/parsers/valibot.d.ts.map +1 -1
  43. package/dist/parsers/yup.d.ts +10 -4
  44. package/dist/parsers/yup.d.ts.map +1 -1
  45. package/dist/parsers/zod.d.ts +18 -4
  46. package/dist/parsers/zod.d.ts.map +1 -1
  47. package/dist/schemaUtils-Cc7CwGXB.cjs +1 -0
  48. package/dist/schemaUtils-Cvlsk24U.js +337 -0
  49. package/dist/style.css +1 -0
  50. package/dist/ui/naive/index.d.ts +11 -0
  51. package/dist/ui/naive/index.d.ts.map +1 -0
  52. package/dist/ui/primevue/index.d.ts +11 -0
  53. package/dist/ui/primevue/index.d.ts.map +1 -0
  54. package/dist/ui/shadcn/index.d.ts +11 -0
  55. package/dist/ui/shadcn/index.d.ts.map +1 -0
  56. package/dist/ui-naive.cjs +1 -0
  57. package/dist/ui-naive.d.ts +411 -0
  58. package/dist/ui-naive.js +667 -0
  59. package/dist/ui-primevue.cjs +1 -0
  60. package/dist/ui-primevue.d.ts +411 -0
  61. package/dist/ui-primevue.js +651 -0
  62. package/dist/ui-shadcn.cjs +1 -0
  63. package/dist/ui-shadcn.d.ts +411 -0
  64. package/dist/ui-shadcn.js +650 -0
  65. package/dist/ui-tailwind.cjs +1 -1
  66. package/dist/ui-tailwind.d.ts +14 -0
  67. package/dist/ui-tailwind.js +180 -161
  68. package/dist/ui.cjs +1 -1
  69. package/dist/ui.d.ts +14 -0
  70. package/dist/ui.js +130 -131
  71. package/dist/valibot.cjs +1 -1
  72. package/dist/valibot.d.ts +25 -2
  73. package/dist/valibot.js +47 -20
  74. package/dist/yup.cjs +1 -1
  75. package/dist/yup.d.ts +23 -3
  76. package/dist/yup.js +16 -15
  77. package/dist/zod.cjs +1 -1
  78. package/dist/zod.d.ts +31 -3
  79. package/dist/zod.js +58 -28
  80. package/package.json +66 -6
  81. package/dist/MaskEngine-DAKSNIHQ.cjs +0 -1
  82. package/dist/MaskEngine-mLoXKd4d.js +0 -251
package/README.md CHANGED
@@ -18,6 +18,7 @@ Reactive forms from a declarative schema (JSON, Zod, Yup, or Valibot) for Vue 3.
18
18
  - [Features](#features)
19
19
  - [Demo](#demo)
20
20
  - [Installation](#installation)
21
+ - [Using Nuxt?](#using-nuxt)
21
22
  - [Quick start](#quick-start)
22
23
  - [FieldDefinition reference](#fielddefinition-reference)
23
24
  - [useForm composable](#useform-composable)
@@ -27,6 +28,7 @@ Reactive forms from a declarative schema (JSON, Zod, Yup, or Valibot) for Vue 3.
27
28
  - [Zod](#zod)
28
29
  - [Yup](#yup)
29
30
  - [Valibot](#valibot)
31
+ - [OpenAPI / standard JSON Schema](#openapi--standard-json-schema)
30
32
  - [Built-in validators](#built-in-validators)
31
33
  - [Custom validators](#custom-validators)
32
34
  - [Cross-field validation](#cross-field-validation)
@@ -40,11 +42,15 @@ Reactive forms from a declarative schema (JSON, Zod, Yup, or Valibot) for Vue 3.
40
42
  - [Component registry](#component-registry)
41
43
  - [Input masking](#input-masking)
42
44
  - [Schema composition](#schema-composition)
45
+ - [Discriminated schemas](#discriminated-schemas)
43
46
  - [TypeScript inference](#typescript-inference)
47
+ - [Server-side validation errors](#server-side-validation-errors)
44
48
  - [Persisted forms](#persisted-forms)
45
49
  - [Debug mode](#debug-mode)
50
+ - [Vue DevTools](#vue-devtools)
46
51
  - [FormRenderer UI component](#formrenderer-ui-component)
47
52
  - [Tailwind UI theme](#tailwind-ui-theme)
53
+ - [shadcn / PrimeVue / Naive UI themes](#shadcn--primevue--naive-ui-themes)
48
54
  - [Accessibility](#accessibility)
49
55
  - [SSR compatibility](#ssr-compatibility)
50
56
  - [Architecture](#architecture)
@@ -54,7 +60,7 @@ Reactive forms from a declarative schema (JSON, Zod, Yup, or Valibot) for Vue 3.
54
60
 
55
61
  ## Features
56
62
 
57
- - **Any schema source** — `FieldDefinition[]`, JSON array, Zod, Yup, or Valibot
63
+ - **Any schema source** — `FieldDefinition[]`, JSON array, Zod, Yup, Valibot, or standard JSON Schema / OpenAPI
58
64
  - **Headless by default** — zero UI dependencies in the core; bring your own components
59
65
  - **Reactive conditions** — `visible`, `disabled` accept a boolean, function, or string expression
60
66
  - **Dynamic options** — sync and async `options` functions with dependency tracking (`optionsDeps`)
@@ -67,13 +73,18 @@ Reactive forms from a declarative schema (JSON, Zod, Yup, or Valibot) for Vue 3.
67
73
  - **Custom components** — `field.component` + per-app and per-subtree component registry
68
74
  - **Input masking** — phone (RU/EU), date, IBAN, INN, custom `#`/`A` patterns; no external deps
69
75
  - **Schema composition** — `mergeSchemas`, `omitFields`, `pickFields`, `extendField`
76
+ - **Discriminated schemas** — `discriminatedFields` builds `visible` wiring for a field set that switches entirely by a discriminator value, with native `z.discriminatedUnion` / `v.variant` mapping
70
77
  - **TypeScript inference** — `InferValues<T>` maps schema literals to typed values
71
78
  - **Persisted forms** — `persist: 'local' | 'session'` with SSR-safe storage
79
+ - **Server-side validation errors** — `applyServerErrors` maps Laravel/DRF/flat/custom formats onto `errors`
72
80
  - **Debug mode** — `debug: true` logs state changes; `useFormDebug` returns a reactive snapshot
73
81
  - **Tailwind UI theme** — `vue-form-schema/ui/tailwind` subentry with utility-class components
82
+ - **shadcn / PrimeVue / Naive UI themes** — drop-in renderers for popular component libraries
74
83
  - **Accessibility** — `aria-required`, `aria-invalid`, `aria-describedby`, `fieldset`/`legend` for radio
75
84
  - **SSR-safe** — no direct browser APIs in the core
76
85
  - **Tree-shakeable** — Zod/Yup/Valibot adapters and UI are separate entry points
86
+ - **Nuxt module** — [`@macrulez/nuxt-vue-form-schema`](./packages/nuxt) auto-imports composables, validators and schema adapters
87
+ - **Vue DevTools** — `vue-form-schema/devtools` adds a live forms inspector + timeline, zero cost when not installed
77
88
 
78
89
  ---
79
90
 
@@ -86,22 +97,25 @@ npm run demo
86
97
 
87
98
  Opens at **http://localhost:5174**:
88
99
 
89
- | Page | What it shows |
90
- |---|---|
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 |
100
+ | Page | What it shows |
101
+ | ------------------------- | -------------------------------------------------------------------------------- |
102
+ | **Basic form** | `FieldDefinition[]` with built-in validators |
103
+ | **JSON schema** | Server-driven schema with rule-based validators |
104
+ | **OpenAPI / JSON Schema** | `parseOpenAPI()` from a request body + `$ref`, `parseJSONSchema()` standalone |
105
+ | **Zod schema** | `parseZod()` with type inference |
106
+ | **Yup schema** | `parseYup()` with Yup constraints |
107
+ | **Conditional fields** | `visible` / `disabled` as function and string expression |
108
+ | **Input masking** | All presets + custom patterns |
109
+ | **FormRenderer** | Slot overrides, custom component map |
110
+ | **Array fields** | `type: 'array'` + `useFieldArray` API |
111
+ | **Multi-step wizard** | `useMultiStepForm` + step progress |
112
+ | **Dependent fields** | Sync/async function options, `defaultValue` as function |
113
+ | **Custom registry** | `provideRegistry` replaces built-in checkbox with PillToggle |
114
+ | **File upload** | Drag-and-drop, `fileType`/`fileSize`/`fileCount` validators |
115
+ | **Server-side errors** | `applyServerErrors()` with a simulated Laravel 422 response |
116
+ | **Tailwind theme** | Default `FormRenderer` vs `TailwindFormRenderer` side by side |
117
+ | **UI themes** | `ShadcnFormRenderer` / `PrimeVueFormRenderer` / `NaiveFormRenderer`, same schema |
118
+ | **Accessibility** | `aria-*` attributes, `fieldset`/`legend` for radio groups |
105
119
 
106
120
  ---
107
121
 
@@ -119,6 +133,23 @@ npm install yup # Yup adapter
119
133
  npm install valibot # Valibot adapter
120
134
  ```
121
135
 
136
+ ### Using Nuxt?
137
+
138
+ [`@macrulez/nuxt-vue-form-schema`](./packages/nuxt) auto-imports `useForm`, `useFieldArray`, the built-in validators, schema adapters and more — no manual `import` needed:
139
+
140
+ ```bash
141
+ npm install @macrulez/nuxt-vue-form-schema
142
+ ```
143
+
144
+ ```ts
145
+ // nuxt.config.ts
146
+ export default defineNuxtConfig({
147
+ modules: ['@macrulez/nuxt-vue-form-schema'],
148
+ })
149
+ ```
150
+
151
+ See the [module's README](./packages/nuxt/README.md) for options.
152
+
122
153
  ---
123
154
 
124
155
  ## Quick start
@@ -129,15 +160,15 @@ import { useForm } from '@macrulez/vue-form-schema'
129
160
  import type { FieldDefinition } from '@macrulez/vue-form-schema'
130
161
 
131
162
  const schema: FieldDefinition[] = [
132
- { type: 'text', name: 'name', label: 'Full name', required: true },
133
- { type: 'email', name: 'email', label: 'Email', required: true },
163
+ { type: 'text', name: 'name', label: 'Full name', required: true },
164
+ { type: 'email', name: 'email', label: 'Email', required: true },
134
165
  {
135
166
  type: 'select',
136
167
  name: 'role',
137
168
  label: 'Role',
138
169
  options: [
139
170
  { label: 'Admin', value: 'admin' },
140
- { label: 'User', value: 'user' },
171
+ { label: 'User', value: 'user' },
141
172
  ],
142
173
  },
143
174
  ]
@@ -192,14 +223,24 @@ const form = useForm({ schema, onSubmit })
192
223
  ```ts
193
224
  interface FieldDefinition {
194
225
  // ─── Required ─────────────────────────────────────────────────────────────
195
- type: 'text' | 'number' | 'email' | 'select' | 'checkbox'
196
- | 'radio' | 'textarea' | 'date' | 'array' | 'group' | 'file'
226
+ type:
227
+ | 'text'
228
+ | 'number'
229
+ | 'email'
230
+ | 'select'
231
+ | 'checkbox'
232
+ | 'radio'
233
+ | 'textarea'
234
+ | 'date'
235
+ | 'array'
236
+ | 'group'
237
+ | 'file'
197
238
 
198
239
  /** Flat dot-path key in the values object, e.g. "address.city" */
199
240
  name: string
200
241
 
201
242
  // ─── Display ──────────────────────────────────────────────────────────────
202
- label?: string
243
+ label?: string
203
244
  placeholder?: string
204
245
 
205
246
  // ─── Initial value ────────────────────────────────────────────────────────
@@ -210,10 +251,10 @@ interface FieldDefinition {
210
251
  required?: boolean
211
252
  disabled?: boolean | ((values: Record<string, unknown>) => boolean)
212
253
  /** Boolean, function, or string expression evaluated against live values */
213
- visible?: boolean | string | ((values: Record<string, unknown>) => boolean)
254
+ visible?: boolean | string | ((values: Record<string, unknown>) => boolean)
214
255
 
215
256
  // ─── Validation ───────────────────────────────────────────────────────────
216
- validators?: ValidatorFn[]
257
+ validators?: ValidatorFn[]
217
258
  asyncValidators?: AsyncValidatorFn[]
218
259
 
219
260
  // ─── Masking ──────────────────────────────────────────────────────────────
@@ -221,9 +262,10 @@ interface FieldDefinition {
221
262
 
222
263
  // ─── select / radio options ───────────────────────────────────────────────
223
264
  /** Static array, sync function, or async function */
224
- options?: FieldOption[]
225
- | ((values: Record<string, unknown>) => FieldOption[])
226
- | ((values: Record<string, unknown>) => Promise<FieldOption[]>)
265
+ options?:
266
+ | FieldOption[]
267
+ | ((values: Record<string, unknown>) => FieldOption[])
268
+ | ((values: Record<string, unknown>) => Promise<FieldOption[]>)
227
269
  /** Field names that trigger async options re-fetch when their values change */
228
270
  optionsDeps?: string[]
229
271
 
@@ -241,10 +283,10 @@ interface FieldDefinition {
241
283
  component?: Component | string
242
284
 
243
285
  // ─── File field options ───────────────────────────────────────────────────
244
- accept?: string // passed to <input accept>
286
+ accept?: string // passed to <input accept>
245
287
  multiple?: boolean
246
- maxSize?: number // bytes (informational; use fileSize validator to enforce)
247
- maxFiles?: number // informational; use fileCount validator to enforce
288
+ maxSize?: number // bytes (informational; use fileSize validator to enforce)
289
+ maxFiles?: number // informational; use fileCount validator to enforce
248
290
  }
249
291
  ```
250
292
 
@@ -259,34 +301,34 @@ const form = useForm(config)
259
301
 
260
302
  ### Config
261
303
 
262
- | Property | Type | Default | Description |
263
- |---|---|---|---|
264
- | `schema` | `FieldDefinition[] \| JSONSchema` | — | Field definitions |
265
- | `initialValues` | `Partial<T>` | `{}` | Seed values (override field defaults) |
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 |
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` |
304
+ | Property | Type | Default | Description |
305
+ | --------------- | ------------------------------------------ | --------- | ----------------------------------------------- |
306
+ | `schema` | `FieldDefinition[] \| JSONSchema` | — | Field definitions |
307
+ | `initialValues` | `Partial<T>` | `{}` | Seed values (override field defaults) |
308
+ | `validateOn` | `'input' \| 'blur' \| 'submit' \| 'eager'` | `'blur'` | When validation fires |
309
+ | `validateMode` | `'first' \| 'all'` | `'first'` | Return first error only, or all errors |
310
+ | `clearOnHide` | `boolean` | `false` | Reset field value when it becomes hidden |
311
+ | `onSubmit` | `(values: T) => void \| Promise<void>` | — | Called after successful validation |
312
+ | `persist` | `false \| 'session' \| 'local'` | `false` | Persist values to sessionStorage / localStorage |
313
+ | `persistKey` | `string` | auto | Storage key prefix |
314
+ | `debug` | `boolean` | `false` | Log state changes to `console.group` |
273
315
 
274
316
  ### Return value
275
317
 
276
- | Property | Type | Description |
277
- |---|---|---|
278
- | `fields` | `ComputedRef<FieldDefinition[]>` | Fields after conditions are evaluated |
279
- | `values` | `Ref<T>` | Current form values |
280
- | `errors` | `Ref<Record<string, string[]>>` | Validation errors keyed by field name |
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 |
284
- | `isValid` | `ComputedRef<boolean>` | `true` when all visible fields pass validation |
285
- | `isSubmitting` | `Ref<boolean>` | `true` while `onSubmit` is running |
286
- | `submit()` | `() => Promise<void>` | Touch all fields, validate, call `onSubmit` |
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 |
318
+ | Property | Type | Description |
319
+ | ----------------------- | -------------------------------- | ---------------------------------------------- |
320
+ | `fields` | `ComputedRef<FieldDefinition[]>` | Fields after conditions are evaluated |
321
+ | `values` | `Ref<T>` | Current form values |
322
+ | `errors` | `Ref<Record<string, string[]>>` | Validation errors keyed by field name |
323
+ | `touched` | `Ref<Record<string, boolean>>` | Fields that have been blurred |
324
+ | `optionsLoading` | `Ref<Record<string, boolean>>` | Async options loading state per field |
325
+ | `isDirty` | `ComputedRef<boolean>` | `true` when values differ from initial state |
326
+ | `isValid` | `ComputedRef<boolean>` | `true` when all visible fields pass validation |
327
+ | `isSubmitting` | `Ref<boolean>` | `true` while `onSubmit` is running |
328
+ | `submit()` | `() => Promise<void>` | Touch all fields, validate, call `onSubmit` |
329
+ | `reset(values?)` | — | Restore initial state or supply new values |
330
+ | `setField(path, value)` | — | Set a value by dot-path |
331
+ | `getField(path)` | — | Read a value by dot-path |
290
332
 
291
333
  ### `validateOn: 'eager'`
292
334
 
@@ -301,9 +343,7 @@ With `'eager'`, validation runs on input — but only after the field has been b
301
343
  ```ts
302
344
  import type { FieldDefinition } from '@macrulez/vue-form-schema'
303
345
 
304
- const schema: FieldDefinition[] = [
305
- { type: 'text', name: 'username', required: true },
306
- ]
346
+ const schema: FieldDefinition[] = [{ type: 'text', name: 'username', required: true }]
307
347
  useForm({ schema })
308
348
  ```
309
349
 
@@ -325,7 +365,7 @@ const raw = [
325
365
  },
326
366
  ]
327
367
 
328
- useForm({ schema: raw }) // auto-detected
368
+ useForm({ schema: raw }) // auto-detected
329
369
  // or
330
370
  import { parseJSON } from '@macrulez/vue-form-schema'
331
371
  const fields = parseJSON(raw)
@@ -340,18 +380,23 @@ import { z } from 'zod'
340
380
  import { parseZod } from '@macrulez/vue-form-schema/zod'
341
381
 
342
382
  const schema = z.object({
343
- name: z.string().min(2).describe('Full name'),
344
- age: z.number().min(0).optional(),
383
+ name: z.string().min(2).describe('Full name'),
384
+ age: z.number().min(0).optional(),
345
385
  email: z.string().email(),
346
- role: z.enum(['admin', 'user']),
386
+ role: z.enum(['admin', 'user']),
347
387
  })
348
388
 
349
389
  const fields = parseZod(schema)
350
390
  const { values } = useForm({ schema: fields })
391
+ // values.value.name is string, values.value.age is number | undefined, ...
392
+ // — inferred automatically from `schema` via z.infer<typeof schema>, no
393
+ // useForm<Values>(...) needed.
351
394
  ```
352
395
 
353
396
  **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.
354
397
 
398
+ `parseZod` also accepts a root `z.discriminatedUnion(key, [...])` schema — see [Discriminated schemas](#discriminated-schemas).
399
+
355
400
  ### Yup
356
401
 
357
402
  ```ts
@@ -359,13 +404,14 @@ import { object, string, number } from 'yup'
359
404
  import { parseYup } from '@macrulez/vue-form-schema/yup'
360
405
 
361
406
  const schema = object({
362
- name: string().required().label('Full name'),
407
+ name: string().required().label('Full name'),
363
408
  email: string().email().required(),
364
- age: number().min(0).optional(),
409
+ age: number().min(0).optional(),
365
410
  })
366
411
 
367
412
  const fields = parseYup(schema)
368
413
  const { values } = useForm({ schema: fields })
414
+ // values.value is typed from InferType<typeof schema> automatically
369
415
  ```
370
416
 
371
417
  ### Valibot
@@ -375,44 +421,96 @@ import * as v from 'valibot'
375
421
  import { parseValibot } from '@macrulez/vue-form-schema/valibot'
376
422
 
377
423
  const schema = v.object({
378
- name: v.pipe(v.string(), v.minLength(2)),
424
+ name: v.pipe(v.string(), v.minLength(2)),
379
425
  email: v.pipe(v.string(), v.email()),
380
- age: v.optional(v.number()),
381
- role: v.picklist(['admin', 'user']),
426
+ age: v.optional(v.number()),
427
+ role: v.picklist(['admin', 'user']),
382
428
  })
383
429
 
384
430
  const fields = parseValibot(schema)
385
431
  const { values } = useForm({ schema: fields })
432
+ // values.value is typed from v.InferOutput<typeof schema> automatically
386
433
  ```
387
434
 
388
435
  **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`.
389
436
 
437
+ `parseValibot` also accepts a root `v.variant(key, [...])` schema — see [Discriminated schemas](#discriminated-schemas).
438
+
439
+ ### OpenAPI / standard JSON Schema
440
+
441
+ Unlike [`parseJSON`](#json-schema) (this library's own simplified rule-based format), `parseJSONSchema` / `parseOpenAPI` accept **real** JSON Schema — the kind your backend already emits via OpenAPI/Swagger — so you don't need a translation layer between your API spec and the form.
442
+
443
+ ```ts
444
+ import { parseOpenAPI } from '@macrulez/vue-form-schema/openapi'
445
+
446
+ // openapiDocument is your full OpenAPI document (e.g. fetched from /openapi.json)
447
+ const fields = parseOpenAPI(openapiDocument, { path: '/users', method: 'post' })
448
+ // or by JSON pointer into components.schemas:
449
+ const fields2 = parseOpenAPI(openapiDocument, '#/components/schemas/User')
450
+
451
+ const { values } = useForm({ schema: fields })
452
+ ```
453
+
454
+ Or on a standalone JSON Schema object, no OpenAPI wrapper:
455
+
456
+ ```ts
457
+ import { parseJSONSchema } from '@macrulez/vue-form-schema/openapi'
458
+
459
+ const fields = parseJSONSchema({
460
+ type: 'object',
461
+ properties: {
462
+ name: { type: 'string', minLength: 2 },
463
+ age: { type: 'integer', minimum: 0 },
464
+ role: { type: 'string', enum: ['admin', 'user'] },
465
+ },
466
+ required: ['name', 'role'],
467
+ } as const)
468
+
469
+ const { values } = useForm({ schema: fields })
470
+ // values.value.role is typed 'admin' | 'user' — inferred from the `as const` schema
471
+ ```
472
+
473
+ **Supported subset:** `type` (`object` / `string` / `number` / `integer` / `boolean` / `array`, including a `type` array like `['string', 'null']`), `properties` + `required`, `items` (array item schemas — object items get their `properties` mapped to bare-named row `fields`, per [array field conventions](#dynamic-array-fields)), `enum` / `const` → `select`, `format` (`email`, `date` / `date-time`, `uri`/`url`), `minLength`/`maxLength`/`minimum`/`maximum`/`pattern`, and local `$ref`s (`#/...`, resolved against the document passed as `rootDocument`, or against the schema itself for self-contained `$defs`).
474
+
475
+ **Not supported** (deliberately — full JSON Schema is a lot of spec): `oneOf` / `anyOf` / `allOf`, `additionalProperties`, `patternProperties`, remote/external `$ref`, tuple-form `items`. Properties using these parse as a plain `text` field without the unsupported constraint, rather than throwing.
476
+
477
+ `parseJSONSchema`'s return type carries a best-effort inferred value type from the schema literal (needs `as const`, same as `defineSchema`). `parseOpenAPI` can't infer statically (the extracted schema's shape depends on the `path`/`selector` argument at runtime) — pass an explicit type argument if you already generate one from your OpenAPI document, e.g. via `openapi-typescript`: `parseOpenAPI<CreateUserRequest>(document, '#/components/schemas/User')`.
478
+
390
479
  ---
391
480
 
392
481
  ## Built-in validators
393
482
 
394
483
  ```ts
395
484
  import {
396
- required, minLength, maxLength, min, max, pattern, email, url,
485
+ required,
486
+ minLength,
487
+ maxLength,
488
+ min,
489
+ max,
490
+ pattern,
491
+ email,
492
+ url,
397
493
  sameAs,
398
- fileType, fileSize, fileCount,
494
+ fileType,
495
+ fileSize,
496
+ fileCount,
399
497
  } from '@macrulez/vue-form-schema'
400
498
  ```
401
499
 
402
- | Function | Description |
403
- |---|---|
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 |
500
+ | Function | Description |
501
+ | ------------------------- | --------------------------------------------------- |
502
+ | `required` | Fails for `null`, `undefined`, `''`, or empty array |
503
+ | `minLength(n, msg?)` | Min length for string or array |
504
+ | `maxLength(n, msg?)` | Max length for string or array |
505
+ | `min(n, msg?)` | Numeric minimum |
506
+ | `max(n, msg?)` | Numeric maximum |
507
+ | `pattern(re, msg?)` | Regex match |
508
+ | `email` | Basic email format |
509
+ | `url` | Valid URL (`new URL()`) |
510
+ | `sameAs(field, msg?)` | Value must equal another field |
511
+ | `fileType(types[], msg?)` | File MIME type or extension whitelist |
512
+ | `fileSize(bytes, msg?)` | Max file size |
513
+ | `fileCount(n, msg?)` | Max number of files |
416
514
 
417
515
  ---
418
516
 
@@ -429,7 +527,7 @@ const noSpaces: ValidatorFn = (value) =>
429
527
 
430
528
  ### Async
431
529
 
432
- Async validators are debounced (300 ms). Errors are merged into `errors` after resolution.
530
+ Async validators are debounced (300 ms) while the user is typing/blurring a field. Errors are merged into `errors` after resolution. On `submit()`, async validators are run immediately (bypassing the debounce) and awaited, so a pending check (e.g. "username taken") blocks submission rather than resolving after `onSubmit` has already fired.
433
531
 
434
532
  ```ts
435
533
  import type { AsyncValidatorFn } from '@macrulez/vue-form-schema'
@@ -445,7 +543,7 @@ const uniqueUsername: AsyncValidatorFn = async (value) => {
445
543
  ```ts
446
544
  useForm({
447
545
  schema,
448
- validateMode: 'all', // collect all errors per field (default: 'first')
546
+ validateMode: 'all', // collect all errors per field (default: 'first')
449
547
  })
450
548
  ```
451
549
 
@@ -491,7 +589,10 @@ const schema: FieldDefinition[] = [
491
589
  name: 'drink',
492
590
  label: 'Drink',
493
591
  visible: 'values.age >= 18',
494
- options: [{ label: 'Beer', value: 'beer' }, { label: 'Water', value: 'water' }],
592
+ options: [
593
+ { label: 'Beer', value: 'beer' },
594
+ { label: 'Water', value: 'water' },
595
+ ],
495
596
  },
496
597
  ]
497
598
  ```
@@ -551,13 +652,15 @@ const schema: FieldDefinition[] = [
551
652
  name: 'members',
552
653
  label: 'Team members',
553
654
  fields: [
554
- { type: 'text', name: 'members.name', label: 'Name', required: true },
555
- { type: 'email', name: 'members.email', label: 'Email', required: true },
655
+ { type: 'text', name: 'name', label: 'Name', required: true },
656
+ { type: 'email', name: 'email', label: 'Email', required: true },
556
657
  ],
557
658
  },
558
659
  ]
559
660
  ```
560
661
 
662
+ Sub-field `name`s are bare (`'name'`, not `'members.name'`) — `useFieldArray` prefixes them to `'members.0.name'`, `'members.1.name'`, ... per row itself; a pre-prefixed name would end up doubled (`'members.0.members.name'`).
663
+
561
664
  `FormRenderer` renders an `ArrayField` automatically with Add / Remove buttons.
562
665
 
563
666
  ### `useFieldArray` composable
@@ -565,17 +668,16 @@ const schema: FieldDefinition[] = [
565
668
  ```ts
566
669
  import { useFieldArray } from '@macrulez/vue-form-schema'
567
670
 
568
- const { rows, count, append, prepend, remove, move, swap, replace } =
569
- useFieldArray(form, 'members')
671
+ const { rows, count, append, prepend, remove, move, swap, replace } = useFieldArray(form, 'members')
570
672
  ```
571
673
 
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 |
674
+ | Method | Description |
675
+ | --------------------------- | --------------------------------- |
676
+ | `append(defaults?)` | Add a row at the end |
677
+ | `prepend(defaults?)` | Add a row at the beginning |
678
+ | `remove(index)` | Remove a row |
679
+ | `move(from, to)` | Move a row |
680
+ | `swap(a, b)` | Swap two rows |
579
681
  | `replace(index, defaults?)` | Replace a row with fresh defaults |
580
682
 
581
683
  `rows` is a `ComputedRef<FieldArrayRow[]>`. Each row exposes `index`, `key`, and `fields` — the nested `FieldDefinition[]` with prefixed paths for that row.
@@ -599,17 +701,17 @@ const wizard = useMultiStepForm(
599
701
  )
600
702
  ```
601
703
 
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` |
704
+ | Property / Method | Description |
705
+ | ---------------------------- | --------------------------------------------------------------- |
706
+ | `currentStep` | `Ref<number>` — 0-based index |
707
+ | `totalSteps` | Number of steps |
708
+ | `isFirstStep` / `isLastStep` | `ComputedRef<boolean>` |
709
+ | `form` | `UseFormReturn` for the current step |
710
+ | `values` | All values across all steps merged |
711
+ | `next()` | Validate current step then advance (returns `false` if invalid) |
712
+ | `back()` | Go to previous step |
713
+ | `goTo(n)` | Jump to step `n` |
714
+ | `submit()` | Validate all steps then call `onSubmit` |
613
715
 
614
716
  ### `MultiStepFormRenderer`
615
717
 
@@ -638,7 +740,10 @@ const schema: FieldDefinition[] = [
638
740
  name: 'tags',
639
741
  defaultValue: 'vue,react',
640
742
  // split into array at submit time — values.tags is still a string
641
- parse: (raw) => String(raw).split(',').map((s) => s.trim()),
743
+ parse: (raw) =>
744
+ String(raw)
745
+ .split(',')
746
+ .map((s) => s.trim()),
642
747
  },
643
748
  ]
644
749
  ```
@@ -696,11 +801,11 @@ useForm
696
801
 
697
802
  Your component's only job:
698
803
 
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`) |
804
+ | What | How |
805
+ | --------------------- | ---------------------------------------------------------------------------- |
806
+ | Report a value change | `emit('update:modelValue', newValue)` |
807
+ | Trigger validation | `emit('blur')` — fires validation when `validateOn` is `'blur'` or `'eager'` |
808
+ | Show errors | Read `props.error` / `props.touched` (or use `useFormField`) |
704
809
 
705
810
  ### The `FormFieldProps` contract
706
811
 
@@ -744,7 +849,9 @@ const { hasError, errorMessage, isRequired } = useFormField(props)
744
849
 
745
850
  // strip non-digits for storage, display formatted
746
851
  const display = computed(() =>
747
- String(props.modelValue ?? '').replace(/\D/g, '').replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3'),
852
+ String(props.modelValue ?? '')
853
+ .replace(/\D/g, '')
854
+ .replace(/(\d{3})(\d{3})(\d{4})/, '($1) $2-$3'),
748
855
  )
749
856
  </script>
750
857
 
@@ -761,7 +868,9 @@ const display = computed(() =>
761
868
  :value="display"
762
869
  :aria-invalid="hasError ? 'true' : 'false'"
763
870
  :aria-describedby="hasError ? `${field.name}-error` : undefined"
764
- @input="emit('update:modelValue', ($event.target as HTMLInputElement).value.replace(/\D/g, ''))"
871
+ @input="
872
+ emit('update:modelValue', ($event.target as HTMLInputElement).value.replace(/\D/g, ''))
873
+ "
765
874
  @blur="emit('blur')"
766
875
  />
767
876
 
@@ -783,7 +892,7 @@ const schema: FieldDefinition[] = [
783
892
  type: 'text',
784
893
  name: 'phone',
785
894
  label: 'Phone number',
786
- component: MyPhoneInput, // ← your component renders instead of TextField
895
+ component: MyPhoneInput, // ← your component renders instead of TextField
787
896
  required: true,
788
897
  validators: [
789
898
  minLength(10, 'Enter a full phone number'),
@@ -803,7 +912,7 @@ import { useForm } from '@macrulez/vue-form-schema'
803
912
  import MyPhoneInput from './MyPhoneInput.vue'
804
913
 
805
914
  const form = useForm({ schema, validateOn: 'blur' })
806
- const touchField = (form as any).touchField // exposed internally
915
+ const touchField = (form as any).touchField // exposed internally
807
916
  </script>
808
917
 
809
918
  <template>
@@ -828,11 +937,11 @@ import { useFormField } from '@macrulez/vue-form-schema'
828
937
 
829
938
  const props = defineProps<FormFieldProps>()
830
939
  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>
940
+ hasError, // ComputedRef<boolean> — touched && error.length > 0
941
+ errorMessage, // ComputedRef<string | null> — first error, or null
942
+ allErrors, // ComputedRef<string[]> — all errors when touched, else []
943
+ isRequired, // ComputedRef<boolean>
944
+ isDisabled, // ComputedRef<boolean>
836
945
  } = useFormField(props)
837
946
  ```
838
947
 
@@ -873,13 +982,13 @@ Masks format user input in real time. Applied automatically in `FormRenderer`; a
873
982
 
874
983
  ### Presets
875
984
 
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` |
985
+ | Preset | Example output |
986
+ | ---------- | ----------------------------- |
987
+ | `phone-ru` | `+7 (916) 123-45-67` |
988
+ | `phone-eu` | `+49 (30) 123-45-67` |
989
+ | `date` | `01.01.2024` |
990
+ | `inn` | `123456789012` |
991
+ | `iban` | `GB29 NWBK 6016 1331 9268 19` |
883
992
 
884
993
  ```ts
885
994
  { type: 'text', name: 'phone', mask: { preset: 'phone-ru' } }
@@ -898,8 +1007,8 @@ Masks format user input in real time. Applied automatically in `FormRenderer`; a
898
1007
  ```ts
899
1008
  import { applyMask, removeMask, bindMask } from '@macrulez/vue-form-schema'
900
1009
 
901
- applyMask('9161234567', { preset: 'phone-ru' }) // '+7 (916) 123-45-67'
902
- removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' }) // '9161234567'
1010
+ applyMask('9161234567', { preset: 'phone-ru' }) // '+7 (916) 123-45-67'
1011
+ removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' }) // '9161234567'
903
1012
 
904
1013
  const cleanup = bindMask(inputEl, { preset: 'date' })
905
1014
  onUnmounted(cleanup)
@@ -933,18 +1042,94 @@ const required = extendField(base, 'email', { required: true, label: 'Email addr
933
1042
 
934
1043
  ---
935
1044
 
1045
+ ## Discriminated schemas
1046
+
1047
+ A common pattern: the entire set of fields changes based on one "discriminator" field's value — payment method, address type, document type. Wiring `visible` by hand on every field is verbose and easy to get wrong. `discriminatedFields(discriminatorName, variants)` builds that `visible` wiring for you:
1048
+
1049
+ ```ts
1050
+ import { discriminatedFields } from '@macrulez/vue-form-schema'
1051
+ import { useForm } from '@macrulez/vue-form-schema'
1052
+
1053
+ const schema = [
1054
+ {
1055
+ type: 'radio' as const,
1056
+ name: 'paymentMethod',
1057
+ label: 'Payment method',
1058
+ options: [
1059
+ { label: 'Card', value: 'card' },
1060
+ { label: 'PayPal', value: 'paypal' },
1061
+ ],
1062
+ },
1063
+ ...discriminatedFields('paymentMethod', {
1064
+ card: [
1065
+ { type: 'text' as const, name: 'cardNumber', label: 'Card number', required: true },
1066
+ { type: 'text' as const, name: 'cvc', label: 'CVC', required: true },
1067
+ ],
1068
+ paypal: [
1069
+ { type: 'email' as const, name: 'paypalEmail', label: 'PayPal email', required: true },
1070
+ ],
1071
+ }),
1072
+ ]
1073
+
1074
+ const { fields } = useForm({ schema, clearOnHide: true })
1075
+ ```
1076
+
1077
+ `discriminatedFields` doesn't create the discriminator field itself — define that separately (typically `select`/`radio`) and spread the helper's result alongside it. Each returned field's `visible` is set to "the discriminator's value matches this variant", combined via AND with the field's own `visible` if it already had one. Pair it with `clearOnHide: true` on `useForm` so switching variants resets the now-hidden variant's values.
1078
+
1079
+ **Native mapping from Zod / Valibot:** `parseZod` accepts a root `z.discriminatedUnion(key, [...])` schema, and `parseValibot` accepts a root `v.variant(key, [...])` schema — both convert straight into a discriminator `select` plus `discriminatedFields`-wired variant fields:
1080
+
1081
+ ```ts
1082
+ import { z } from 'zod'
1083
+ import { parseZod } from '@macrulez/vue-form-schema/zod'
1084
+
1085
+ const schema = z.discriminatedUnion('paymentMethod', [
1086
+ z.object({ paymentMethod: z.literal('card'), cardNumber: z.string(), cvc: z.string() }),
1087
+ z.object({ paymentMethod: z.literal('paypal'), paypalEmail: z.string().email() }),
1088
+ ])
1089
+
1090
+ const fields = parseZod(schema)
1091
+ ```
1092
+
1093
+ This only applies when the discriminated union/variant is the _root_ schema passed to `parseZod`/`parseValibot` — a discriminated union nested as a property inside a larger `z.object({...})` is not expanded automatically; use `discriminatedFields` directly for that case.
1094
+
1095
+ ---
1096
+
936
1097
  ## TypeScript inference
937
1098
 
938
- `InferValues<T>` maps a `readonly FieldDefinition[]` literal to a typed values object.
1099
+ ### From a Zod / Yup / Valibot schema (automatic)
1100
+
1101
+ `parseZod` / `parseYup` / `parseValibot` return a `FieldDefinition[]` that also carries the source schema's inferred value type. `useForm({ schema: fields })` picks it up automatically — no explicit `useForm<Values>(...)` needed:
1102
+
1103
+ ```ts
1104
+ import { z } from 'zod'
1105
+ import { parseZod } from '@macrulez/vue-form-schema/zod'
1106
+ import { useForm } from '@macrulez/vue-form-schema'
1107
+
1108
+ const schema = z.object({ username: z.string(), age: z.number() })
1109
+ const fields = parseZod(schema)
1110
+
1111
+ const { values, onSubmit } = useForm({
1112
+ schema: fields,
1113
+ onSubmit: (data) => {
1114
+ data.username // string ✓ — inferred from `schema`, not Record<string, unknown>
1115
+ },
1116
+ })
1117
+ ```
1118
+
1119
+ This works the same way for `parseYup` (via Yup's `InferType`) and `parseValibot` (via Valibot's `InferOutput`). Passing an explicit `useForm<Values>({ schema: fields })` still works and overrides the inferred type if you need to.
1120
+
1121
+ ### From a hand-written `FieldDefinition[]`
1122
+
1123
+ `InferValues<T>` maps a `readonly FieldDefinition[]` literal to a typed values object — use this when the schema isn't coming from Zod/Yup/Valibot.
939
1124
 
940
1125
  ```ts
941
1126
  import { defineSchema } from '@macrulez/vue-form-schema'
942
1127
  import type { InferValues } from '@macrulez/vue-form-schema'
943
1128
 
944
1129
  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 },
1130
+ { type: 'text' as const, name: 'username' as const },
1131
+ { type: 'number' as const, name: 'age' as const },
1132
+ { type: 'checkbox' as const, name: 'agreed' as const },
948
1133
  ] as const)
949
1134
 
950
1135
  type Values = InferValues<typeof schema>
@@ -958,12 +1143,50 @@ const { values } = useForm<Values>({ schema })
958
1143
 
959
1144
  ---
960
1145
 
1146
+ ## Server-side validation errors
1147
+
1148
+ Map a backend's validation error response onto `form.errors` — no hand-rolled unwrapping in every project.
1149
+
1150
+ ```ts
1151
+ import { applyServerErrors } from '@macrulez/vue-form-schema'
1152
+
1153
+ const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(form.values.value) })
1154
+ if (!res.ok) {
1155
+ const { formErrors } = applyServerErrors(form, await res.json(), { format: 'laravel' })
1156
+ if (formErrors.length) toast.error(formErrors[0]) // errors not tied to a specific field
1157
+ }
1158
+ ```
1159
+
1160
+ Built-in `format`s:
1161
+
1162
+ | Format | Shape |
1163
+ | ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
1164
+ | `'laravel'` | `{ message, errors: { field: ["msg", ...], "nested.field": [...] } }` |
1165
+ | `'drf'` | `{ field: ["msg"], nested: { field: ["msg"] } }` (flattened to dot-paths); `non_field_errors` / `detail` become `formErrors` |
1166
+ | `'flat'` | `{ field: "msg" \| ["msg", ...] }` — the default; matches most hand-rolled APIs |
1167
+
1168
+ Or pass your own mapper for anything else: `(raw) => ({ fieldErrors: {...}, formErrors: [...] })`.
1169
+
1170
+ Field errors set this way behave like any other entry in `errors` — the next time that field validates client-side (on blur by default, or on every keystroke with `validateOn: 'input'`), it's recomputed from the schema's own validators and the server error is naturally replaced. `submit()` also fully recomputes `errors`, so a stale server error never survives into the next submit attempt.
1171
+
1172
+ `applyServerErrors(form, raw, options?)`:
1173
+
1174
+ | Option | Type | Default | Description |
1175
+ | -------- | ------------------------------------------------ | -------- | ---------------------------------------------------------- |
1176
+ | `format` | `'laravel' \| 'drf' \| 'flat' \| ((raw) => ...)` | `'flat'` | Built-in format name or a custom mapper |
1177
+ | `touch` | `boolean` | `true` | Mark affected fields as touched so errors show immediately |
1178
+ | `merge` | `boolean` | `true` | Merge into existing `errors` instead of replacing them |
1179
+
1180
+ `normalizeServerErrors(raw, format?)` runs the same mapping without touching a form, if you just want `{ fieldErrors, formErrors }` yourself.
1181
+
1182
+ ---
1183
+
961
1184
  ## Persisted forms
962
1185
 
963
1186
  ```ts
964
1187
  useForm({
965
1188
  schema,
966
- persist: 'local', // or 'session'
1189
+ persist: 'local', // or 'session'
967
1190
  persistKey: 'checkout', // optional — defaults to a hash of field names
968
1191
  })
969
1192
  ```
@@ -987,6 +1210,23 @@ const { snapshot } = useFormDebug(form)
987
1210
  // snapshot.value = { values, errors, touched, isDirty, isValid, isSubmitting }
988
1211
  ```
989
1212
 
1213
+ ### Vue DevTools
1214
+
1215
+ `vue-form-schema/devtools` adds a custom **Forms** inspector (every active `useForm()` instance — values/errors/touched/isValid/isDirty, live) and a **Forms** timeline layer (`setField`/`touch`/`submit`/`submitSuccess`/`submitError`/`reset`/`asyncValidate` events) to the Vue DevTools browser extension / standalone app.
1216
+
1217
+ ```ts
1218
+ // main.ts — dev-only, dynamically imported so @vue/devtools-api never
1219
+ // reaches a production bundle
1220
+ const app = createApp(App)
1221
+ if (import.meta.env.DEV) {
1222
+ const { installFormDevtools } = await import('@macrulez/vue-form-schema/devtools')
1223
+ installFormDevtools(app)
1224
+ }
1225
+ app.mount('#app')
1226
+ ```
1227
+
1228
+ This is a separate entry point on purpose — `useForm()` itself only ever writes to a small dependency-free internal registry (near-zero cost, no `@vue/devtools-api` import) regardless of whether `installFormDevtools` is ever called, so devtools support costs nothing in the core bundle unless you opt in. Requires `@vue/devtools-api` (peer dependency, `^6 || ^7 || ^8` — install it alongside).
1229
+
990
1230
  ---
991
1231
 
992
1232
  ## FormRenderer UI component
@@ -997,36 +1237,36 @@ import { FormRenderer } from '@macrulez/vue-form-schema/ui'
997
1237
 
998
1238
  ### Props
999
1239
 
1000
- | Prop | Type | Default | Description |
1001
- |---|---|---|---|
1002
- | `form` | `UseFormReturn` | — | Return value of `useForm` |
1003
- | `components` | `Partial<Record<FieldType, Component>>` | built-ins | Override per-type renderers |
1004
- | `submitLabel` | `string` | `'Submit'` | Submit button text |
1240
+ | Prop | Type | Default | Description |
1241
+ | ------------- | --------------------------------------- | ---------- | --------------------------- |
1242
+ | `form` | `UseFormReturn` | — | Return value of `useForm` |
1243
+ | `components` | `Partial<Record<FieldType, Component>>` | built-ins | Override per-type renderers |
1244
+ | `submitLabel` | `string` | `'Submit'` | Submit button text |
1005
1245
 
1006
1246
  ### Slots
1007
1247
 
1008
- | Slot | Scope | Description |
1009
- |---|---|---|
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 |
1013
- | `#submit` | `{ isSubmitting, isValid }` | Replace the submit button |
1248
+ | Slot | Scope | Description |
1249
+ | --------------- | --------------------------------------------------- | ------------------------- |
1250
+ | `#field-{name}` | `{ field, value, error, touched, setValue, touch }` | Replace an entire field |
1251
+ | `#label-{name}` | `{ field }` | Replace a label |
1252
+ | `#error-{name}` | `{ field, error }` | Replace error display |
1253
+ | `#submit` | `{ isSubmitting, isValid }` | Replace the submit button |
1014
1254
 
1015
1255
  ### Built-in field components
1016
1256
 
1017
1257
  All exported individually from `vue-form-schema/ui`:
1018
1258
 
1019
- | Component | Field types |
1020
- |---|---|
1021
- | `TextField` | `text`, `email` |
1022
- | `NumberField` | `number` |
1023
- | `TextareaField` | `textarea` |
1024
- | `SelectField` | `select` |
1025
- | `CheckboxField` | `checkbox` |
1026
- | `RadioField` | `radio` |
1027
- | `DateField` | `date` |
1028
- | `ArrayField` | `array` |
1029
- | `FileField` | `file` |
1259
+ | Component | Field types |
1260
+ | --------------- | --------------- |
1261
+ | `TextField` | `text`, `email` |
1262
+ | `NumberField` | `number` |
1263
+ | `TextareaField` | `textarea` |
1264
+ | `SelectField` | `select` |
1265
+ | `CheckboxField` | `checkbox` |
1266
+ | `RadioField` | `radio` |
1267
+ | `DateField` | `date` |
1268
+ | `ArrayField` | `array` |
1269
+ | `FileField` | `file` |
1030
1270
 
1031
1271
  ---
1032
1272
 
@@ -1047,27 +1287,59 @@ All field components are also exported individually:
1047
1287
 
1048
1288
  ```ts
1049
1289
  import {
1050
- TwTextField, TwSelectField, TwCheckboxField,
1051
- TwRadioField, TwFileField, TwArrayField,
1290
+ TwTextField,
1291
+ TwSelectField,
1292
+ TwCheckboxField,
1293
+ TwRadioField,
1294
+ TwFileField,
1295
+ TwArrayField,
1052
1296
  // …
1053
1297
  } from '@macrulez/vue-form-schema/ui/tailwind'
1054
1298
  ```
1055
1299
 
1056
1300
  ---
1057
1301
 
1302
+ ## shadcn / PrimeVue / Naive UI themes
1303
+
1304
+ Three more drop-in `FormRenderer` replacements for popular component libraries — same schema, same `useForm`, just swap the renderer.
1305
+
1306
+ ```ts
1307
+ import { ShadcnFormRenderer } from '@macrulez/vue-form-schema/ui/shadcn'
1308
+ import { PrimeVueFormRenderer } from '@macrulez/vue-form-schema/ui/primevue'
1309
+ import { NaiveFormRenderer } from '@macrulez/vue-form-schema/ui/naive'
1310
+ ```
1311
+
1312
+ ```vue
1313
+ <ShadcnFormRenderer :form="form" submit-label="Save" />
1314
+ <PrimeVueFormRenderer :form="form" submit-label="Save" />
1315
+ <NaiveFormRenderer :form="form" submit-label="Save" />
1316
+ ```
1317
+
1318
+ | Theme | What it actually is |
1319
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1320
+ | `ui/shadcn` | **Not** a wrapper around importable shadcn-vue components — shadcn-vue ships as copy-paste source via its CLI, not as an npm component library, so there's nothing to import. This theme is Tailwind markup styled with shadcn/ui's own utility-class vocabulary (`border-input`, `bg-primary`, `text-destructive`, `ring-ring`, …). Drop it into a project that already has shadcn-vue's Tailwind theme tokens configured (`npx shadcn-vue init`) and it matches natively — same requirement as `ui/tailwind` for Tailwind CSS itself. |
1321
+ | `ui/primevue` | Real wrapper components around PrimeVue's own (`InputText`, `Select`, `RadioButton`, `Checkbox`, `DatePicker`, `Message`, `Button`, …). Requires `primevue ^4` or `^5` installed and its Vue plugin registered (`app.use(PrimeVue, { theme: { preset: Aura } })` or your own preset). |
1322
+ | `ui/naive` | Real wrapper components around Naive UI's own (`NInput`, `NSelect`, `NRadioGroup`, `NCheckbox`, `NDatePicker`, `NFormItem`, `NButton`, …). Requires `naive-ui ^2.38` installed; no global plugin needed. |
1323
+
1324
+ File fields in the PrimeVue and Naive UI themes use a small self-contained native dropzone rather than `<FileUpload>` / `<NUpload>` — those components are built around actively uploading to a server, not just collecting `File` objects for later form submission.
1325
+
1326
+ All field components are also exported individually per theme, prefixed `Sh*` / `Pv*` / `Nu*` respectively (e.g. `ShTextField`, `PvSelectField`, `NuCheckboxField`).
1327
+
1328
+ ---
1329
+
1058
1330
  ## Accessibility
1059
1331
 
1060
1332
  All built-in field components include full a11y attributes:
1061
1333
 
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 |
1334
+ | Feature | How |
1335
+ | ------------------------------------- | ----------------------------------------------------------------- |
1336
+ | `aria-required` | Set to `"true"` on required inputs, selects, textareas, fieldsets |
1337
+ | `aria-invalid` | Set to `"true"` when the field is touched and has errors |
1338
+ | `aria-describedby` | Points to `"{name}-error"` when errors are present |
1339
+ | `role="alert"` + `aria-live="polite"` | Error lists are announced by screen readers on appearance |
1340
+ | `label[for]` + `input[id]` | All inputs have matching label and id |
1341
+ | `fieldset` + `legend` | Radio groups use semantic grouping |
1342
+ | `aria-checked` | Checkboxes reflect boolean state explicitly |
1071
1343
 
1072
1344
  ---
1073
1345
 
@@ -1082,7 +1354,7 @@ The core (`useForm`, validators, parsers, `ConditionEvaluator`) does not use bro
1082
1354
  ```
1083
1355
  useForm
1084
1356
 
1085
- ├── Schema normalisation (json / zod / yup / valibot)
1357
+ ├── Schema normalisation (json / zod / yup / valibot / openapi)
1086
1358
  │ └── FieldDefinition[]
1087
1359
 
1088
1360
  ├── ConditionEvaluator
@@ -1098,23 +1370,32 @@ useForm
1098
1370
  └── (optional) UI subpackages
1099
1371
  FormRenderer [/ui]
1100
1372
  TailwindFormRenderer [/ui/tailwind]
1373
+ ShadcnFormRenderer [/ui/shadcn]
1374
+ PrimeVueFormRenderer [/ui/primevue]
1375
+ NaiveFormRenderer [/ui/naive]
1101
1376
  useFieldArray [core]
1102
1377
  useMultiStepForm [core]
1103
1378
  useFormDebug [core]
1379
+ installFormDevtools [/devtools]
1104
1380
  ```
1105
1381
 
1106
1382
  ---
1107
1383
 
1108
1384
  ## Bundle size & peer dependencies
1109
1385
 
1110
- | Entry point | Peer deps | Notes |
1111
- |---|---|---|
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 |
1386
+ | Entry point | Peer deps | Notes |
1387
+ | ----------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------- |
1388
+ | `vue-form-schema` | `vue ^3.3` | Core — headless, no UI |
1389
+ | `vue-form-schema/zod` | `zod ^3` | Optional adapter |
1390
+ | `vue-form-schema/yup` | `yup ^1` | Optional adapter |
1391
+ | `vue-form-schema/valibot` | `valibot ^1` | Optional adapter |
1392
+ | `vue-form-schema/openapi` | none | Standard JSON Schema / OpenAPI adapter — no peer deps |
1393
+ | `vue-form-schema/ui` | `vue ^3.3` | BEM-styled built-in components |
1394
+ | `vue-form-schema/ui/tailwind` | `vue ^3.3`, Tailwind CSS | Tailwind utility-class components |
1395
+ | `vue-form-schema/ui/shadcn` | `vue ^3.3`, Tailwind CSS | shadcn/ui-styled Tailwind markup (see note above — not a shadcn-vue component wrapper) |
1396
+ | `vue-form-schema/ui/primevue` | `vue ^3.3`, `primevue ^4 \| ^5` | Real PrimeVue component wrappers |
1397
+ | `vue-form-schema/ui/naive` | `vue ^3.3`, `naive-ui ^2.38` | Real Naive UI component wrappers |
1398
+ | `vue-form-schema/devtools` | `@vue/devtools-api ^6 \| ^7 \| ^8` | Vue DevTools inspector + timeline, dev-only |
1118
1399
 
1119
1400
  All entry points are tree-shakeable ESM + CJS dual builds.
1120
1401