@macrulez/vue-form-schema 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +461 -180
- package/dist/MaskEngine-BEXwX4BK.js +144 -0
- package/dist/MaskEngine-CPr-fSPy.cjs +1 -0
- package/dist/ValidationEngine-BC4U1ef1.js +135 -0
- package/dist/ValidationEngine-Diz-qTFs.cjs +1 -0
- package/dist/_plugin-vue_export-helper-BHFhmbuH.cjs +1 -0
- package/dist/_plugin-vue_export-helper-CHgC5LLL.js +9 -0
- package/dist/core/ConditionEvaluator.d.ts.map +1 -1
- package/dist/core/MaskEngine.d.ts.map +1 -1
- package/dist/core/ValidationEngine.d.ts +7 -0
- package/dist/core/ValidationEngine.d.ts.map +1 -1
- package/dist/core/formRegistry.d.ts +33 -0
- package/dist/core/formRegistry.d.ts.map +1 -0
- package/dist/core/inferTypes.d.ts.map +1 -1
- package/dist/core/schemaUtils.d.ts +38 -0
- package/dist/core/schemaUtils.d.ts.map +1 -1
- package/dist/core/serverErrors.d.ts +69 -0
- package/dist/core/serverErrors.d.ts.map +1 -0
- package/dist/core/types.d.ts +12 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/useForm.d.ts +4 -1
- package/dist/core/useForm.d.ts.map +1 -1
- package/dist/core/useMultiStepForm.d.ts.map +1 -1
- package/dist/devtools/index.d.ts +26 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools.cjs +1 -0
- package/dist/devtools.d.ts +6 -0
- package/dist/devtools.js +93 -0
- package/dist/formRegistry-Bv1eaWQ-.js +31 -0
- package/dist/formRegistry-DaDmXyYY.cjs +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +393 -368
- package/dist/openapi.cjs +1 -0
- package/dist/openapi.d.ts +202 -0
- package/dist/openapi.js +118 -0
- package/dist/parsers/json.d.ts.map +1 -1
- package/dist/parsers/openapi.d.ts +139 -0
- package/dist/parsers/openapi.d.ts.map +1 -0
- package/dist/parsers/valibot.d.ts +13 -4
- package/dist/parsers/valibot.d.ts.map +1 -1
- package/dist/parsers/yup.d.ts +10 -4
- package/dist/parsers/yup.d.ts.map +1 -1
- package/dist/parsers/zod.d.ts +18 -4
- package/dist/parsers/zod.d.ts.map +1 -1
- package/dist/schemaUtils-Cc7CwGXB.cjs +1 -0
- package/dist/schemaUtils-Cvlsk24U.js +337 -0
- package/dist/style.css +1 -0
- package/dist/ui/naive/index.d.ts +11 -0
- package/dist/ui/naive/index.d.ts.map +1 -0
- package/dist/ui/primevue/index.d.ts +11 -0
- package/dist/ui/primevue/index.d.ts.map +1 -0
- package/dist/ui/shadcn/index.d.ts +11 -0
- package/dist/ui/shadcn/index.d.ts.map +1 -0
- package/dist/ui-naive.cjs +1 -0
- package/dist/ui-naive.d.ts +276 -0
- package/dist/ui-naive.js +667 -0
- package/dist/ui-primevue.cjs +1 -0
- package/dist/ui-primevue.d.ts +276 -0
- package/dist/ui-primevue.js +651 -0
- package/dist/ui-shadcn.cjs +1 -0
- package/dist/ui-shadcn.d.ts +276 -0
- package/dist/ui-shadcn.js +650 -0
- package/dist/ui-tailwind.cjs +1 -1
- package/dist/ui-tailwind.d.ts +153 -274
- package/dist/ui-tailwind.js +180 -161
- package/dist/ui.cjs +1 -1
- package/dist/ui.d.ts +170 -308
- package/dist/ui.js +130 -131
- package/dist/valibot.cjs +1 -1
- package/dist/valibot.d.ts +25 -2
- package/dist/valibot.js +47 -20
- package/dist/yup.cjs +1 -1
- package/dist/yup.d.ts +23 -3
- package/dist/yup.js +16 -15
- package/dist/zod.cjs +1 -1
- package/dist/zod.d.ts +31 -3
- package/dist/zod.js +58 -28
- package/package.json +69 -7
- package/dist/MaskEngine-DAKSNIHQ.cjs +0 -1
- 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
|
|
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
|
|
90
|
-
|
|
91
|
-
| **Basic form**
|
|
92
|
-
| **JSON schema**
|
|
93
|
-
| **
|
|
94
|
-
| **
|
|
95
|
-
| **
|
|
96
|
-
| **
|
|
97
|
-
| **
|
|
98
|
-
| **
|
|
99
|
-
| **
|
|
100
|
-
| **
|
|
101
|
-
| **
|
|
102
|
-
| **
|
|
103
|
-
| **
|
|
104
|
-
| **
|
|
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',
|
|
133
|
-
{ type: 'email', name: 'email', label: 'Email',
|
|
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',
|
|
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:
|
|
196
|
-
|
|
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?:
|
|
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?:
|
|
254
|
+
visible?: boolean | string | ((values: Record<string, unknown>) => boolean)
|
|
214
255
|
|
|
215
256
|
// ─── Validation ───────────────────────────────────────────────────────────
|
|
216
|
-
validators?:
|
|
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?:
|
|
225
|
-
|
|
226
|
-
|
|
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?:
|
|
286
|
+
accept?: string // passed to <input accept>
|
|
245
287
|
multiple?: boolean
|
|
246
|
-
maxSize?:
|
|
247
|
-
maxFiles?: number
|
|
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
|
|
263
|
-
|
|
264
|
-
| `schema`
|
|
265
|
-
| `initialValues` | `Partial<T>`
|
|
266
|
-
| `validateOn`
|
|
267
|
-
| `validateMode`
|
|
268
|
-
| `clearOnHide`
|
|
269
|
-
| `onSubmit`
|
|
270
|
-
| `persist`
|
|
271
|
-
| `persistKey`
|
|
272
|
-
| `debug`
|
|
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
|
|
277
|
-
|
|
278
|
-
| `fields`
|
|
279
|
-
| `values`
|
|
280
|
-
| `errors`
|
|
281
|
-
| `touched`
|
|
282
|
-
| `optionsLoading`
|
|
283
|
-
| `isDirty`
|
|
284
|
-
| `isValid`
|
|
285
|
-
| `isSubmitting`
|
|
286
|
-
| `submit()`
|
|
287
|
-
| `reset(values?)`
|
|
288
|
-
| `setField(path, value)` | —
|
|
289
|
-
| `getField(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 })
|
|
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:
|
|
344
|
-
age:
|
|
383
|
+
name: z.string().min(2).describe('Full name'),
|
|
384
|
+
age: z.number().min(0).optional(),
|
|
345
385
|
email: z.string().email(),
|
|
346
|
-
role:
|
|
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:
|
|
407
|
+
name: string().required().label('Full name'),
|
|
363
408
|
email: string().email().required(),
|
|
364
|
-
age:
|
|
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:
|
|
424
|
+
name: v.pipe(v.string(), v.minLength(2)),
|
|
379
425
|
email: v.pipe(v.string(), v.email()),
|
|
380
|
-
age:
|
|
381
|
-
role:
|
|
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,
|
|
485
|
+
required,
|
|
486
|
+
minLength,
|
|
487
|
+
maxLength,
|
|
488
|
+
min,
|
|
489
|
+
max,
|
|
490
|
+
pattern,
|
|
491
|
+
email,
|
|
492
|
+
url,
|
|
397
493
|
sameAs,
|
|
398
|
-
fileType,
|
|
494
|
+
fileType,
|
|
495
|
+
fileSize,
|
|
496
|
+
fileCount,
|
|
399
497
|
} from '@macrulez/vue-form-schema'
|
|
400
498
|
```
|
|
401
499
|
|
|
402
|
-
| Function
|
|
403
|
-
|
|
404
|
-
| `required`
|
|
405
|
-
| `minLength(n, msg?)`
|
|
406
|
-
| `maxLength(n, msg?)`
|
|
407
|
-
| `min(n, msg?)`
|
|
408
|
-
| `max(n, msg?)`
|
|
409
|
-
| `pattern(re, msg?)`
|
|
410
|
-
| `email`
|
|
411
|
-
| `url`
|
|
412
|
-
| `sameAs(field, msg?)`
|
|
413
|
-
| `fileType(types[], msg?)` | File MIME type or extension whitelist
|
|
414
|
-
| `fileSize(bytes, msg?)`
|
|
415
|
-
| `fileCount(n, msg?)`
|
|
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',
|
|
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: [
|
|
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',
|
|
555
|
-
{ type: 'email', name: '
|
|
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
|
|
573
|
-
|
|
574
|
-
| `append(defaults?)`
|
|
575
|
-
| `prepend(defaults?)`
|
|
576
|
-
| `remove(index)`
|
|
577
|
-
| `move(from, to)`
|
|
578
|
-
| `swap(a, b)`
|
|
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
|
|
603
|
-
|
|
604
|
-
| `currentStep`
|
|
605
|
-
| `totalSteps`
|
|
606
|
-
| `isFirstStep` / `isLastStep` | `ComputedRef<boolean>`
|
|
607
|
-
| `form`
|
|
608
|
-
| `values`
|
|
609
|
-
| `next()`
|
|
610
|
-
| `back()`
|
|
611
|
-
| `goTo(n)`
|
|
612
|
-
| `submit()`
|
|
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) =>
|
|
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
|
|
700
|
-
|
|
701
|
-
| Report a value change | `emit('update:modelValue', newValue)`
|
|
702
|
-
| Trigger validation
|
|
703
|
-
| Show errors
|
|
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 ?? '')
|
|
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="
|
|
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,
|
|
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
|
|
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,
|
|
832
|
-
errorMessage,
|
|
833
|
-
allErrors,
|
|
834
|
-
isRequired,
|
|
835
|
-
isDisabled,
|
|
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
|
|
877
|
-
|
|
878
|
-
| `phone-ru` | `+7 (916) 123-45-67`
|
|
879
|
-
| `phone-eu` | `+49 (30) 123-45-67`
|
|
880
|
-
| `date`
|
|
881
|
-
| `inn`
|
|
882
|
-
| `iban`
|
|
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' })
|
|
902
|
-
removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' })
|
|
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
|
-
|
|
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'
|
|
946
|
-
{ type: 'number'
|
|
947
|
-
{ type: 'checkbox' as const, name: 'agreed'
|
|
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',
|
|
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
|
|
1001
|
-
|
|
1002
|
-
| `form`
|
|
1003
|
-
| `components`
|
|
1004
|
-
| `submitLabel` | `string`
|
|
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
|
|
1009
|
-
|
|
1010
|
-
| `#field-{name}` | `{ field, value, error, touched, setValue, touch }` | Replace an entire field
|
|
1011
|
-
| `#label-{name}` | `{ field }`
|
|
1012
|
-
| `#error-{name}` | `{ field, error }`
|
|
1013
|
-
| `#submit`
|
|
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
|
|
1020
|
-
|
|
1021
|
-
| `TextField`
|
|
1022
|
-
| `NumberField`
|
|
1023
|
-
| `TextareaField` | `textarea`
|
|
1024
|
-
| `SelectField`
|
|
1025
|
-
| `CheckboxField` | `checkbox`
|
|
1026
|
-
| `RadioField`
|
|
1027
|
-
| `DateField`
|
|
1028
|
-
| `ArrayField`
|
|
1029
|
-
| `FileField`
|
|
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,
|
|
1051
|
-
|
|
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
|
|
1063
|
-
|
|
1064
|
-
| `aria-required`
|
|
1065
|
-
| `aria-invalid`
|
|
1066
|
-
| `aria-describedby`
|
|
1067
|
-
| `role="alert"` + `aria-live="polite"` | Error lists are announced by screen readers on appearance
|
|
1068
|
-
| `label[for]` + `input[id]`
|
|
1069
|
-
| `fieldset` + `legend`
|
|
1070
|
-
| `aria-checked`
|
|
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
|
|
1111
|
-
|
|
1112
|
-
| `vue-form-schema`
|
|
1113
|
-
| `vue-form-schema/zod`
|
|
1114
|
-
| `vue-form-schema/yup`
|
|
1115
|
-
| `vue-form-schema/valibot`
|
|
1116
|
-
| `vue-form-schema/
|
|
1117
|
-
| `vue-form-schema/ui
|
|
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
|
|