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