@macrulez/vue-form-schema 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +829 -0
- package/dist/MaskEngine-D22m29OM.js +157 -0
- package/dist/MaskEngine-hd5xHed7.cjs +1 -0
- package/dist/__tests__/ConditionEvaluator.test.d.ts +2 -0
- package/dist/__tests__/ConditionEvaluator.test.d.ts.map +1 -0
- package/dist/__tests__/MaskEngine.test.d.ts +2 -0
- package/dist/__tests__/MaskEngine.test.d.ts.map +1 -0
- package/dist/__tests__/ValidationEngine.test.d.ts +2 -0
- package/dist/__tests__/ValidationEngine.test.d.ts.map +1 -0
- package/dist/__tests__/parsers.test.d.ts +2 -0
- package/dist/__tests__/parsers.test.d.ts.map +1 -0
- package/dist/__tests__/useForm.test.d.ts +2 -0
- package/dist/__tests__/useForm.test.d.ts.map +1 -0
- package/dist/core/ConditionEvaluator.d.ts +18 -0
- package/dist/core/ConditionEvaluator.d.ts.map +1 -0
- package/dist/core/MaskEngine.d.ts +21 -0
- package/dist/core/MaskEngine.d.ts.map +1 -0
- package/dist/core/ValidationEngine.d.ts +25 -0
- package/dist/core/ValidationEngine.d.ts.map +1 -0
- package/dist/core/types.d.ts +76 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/useForm.d.ts +3 -0
- package/dist/core/useForm.d.ts.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +278 -0
- package/dist/parsers/json.d.ts +3 -0
- package/dist/parsers/json.d.ts.map +1 -0
- package/dist/parsers/yup.d.ts +10 -0
- package/dist/parsers/yup.d.ts.map +1 -0
- package/dist/parsers/zod.d.ts +10 -0
- package/dist/parsers/zod.d.ts.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui.cjs +1 -0
- package/dist/ui.d.ts +324 -0
- package/dist/ui.js +438 -0
- package/dist/yup.cjs +1 -0
- package/dist/yup.d.ts +45 -0
- package/dist/yup.js +55 -0
- package/dist/zod.cjs +1 -0
- package/dist/zod.d.ts +45 -0
- package/dist/zod.js +104 -0
- package/package.json +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
# vue-form-schema
|
|
2
|
+
|
|
3
|
+
Reactive forms from a declarative schema (JSON, Zod, or Yup) for Vue 3. A headless, SSR-compatible alternative to VeeValidate / FormKit for forms that are generated dynamically or driven from the server.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [Features](#features)
|
|
10
|
+
- [Demo](#demo)
|
|
11
|
+
- [Installation](#installation)
|
|
12
|
+
- [Quick start](#quick-start)
|
|
13
|
+
- [FieldDefinition reference](#fielddefinition-reference)
|
|
14
|
+
- [useForm composable](#useform-composable)
|
|
15
|
+
- [Schema formats](#schema-formats)
|
|
16
|
+
- [FieldDefinition array](#fielddefinition-array)
|
|
17
|
+
- [JSON schema](#json-schema)
|
|
18
|
+
- [Zod](#zod)
|
|
19
|
+
- [Yup](#yup)
|
|
20
|
+
- [Built-in validators](#built-in-validators)
|
|
21
|
+
- [Custom validators](#custom-validators)
|
|
22
|
+
- [Conditional fields](#conditional-fields)
|
|
23
|
+
- [Input masking](#input-masking)
|
|
24
|
+
- [FormRenderer UI component](#formrenderer-ui-component)
|
|
25
|
+
- [SSR compatibility](#ssr-compatibility)
|
|
26
|
+
- [TypeScript generics](#typescript-generics)
|
|
27
|
+
- [Architecture](#architecture)
|
|
28
|
+
- [Bundle size & peer dependencies](#bundle-size--peer-dependencies)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Any schema source** — define fields as a plain JSON array, a Zod object, or a Yup object
|
|
35
|
+
- **Headless by default** — zero UI dependencies in the core; bring your own components
|
|
36
|
+
- **Reactive conditions** — `visible`, `disabled`, and `required` accept a boolean, a function, or a string expression evaluated against live form values
|
|
37
|
+
- **Validation** — sync and async validators, built-in rules (`required`, `email`, `minLength`, …), cross-field access
|
|
38
|
+
- **Input masking** — phone (RU/EU), date, IBAN, INN, and custom `#`/`A` patterns; no external dependencies
|
|
39
|
+
- **SSR-safe** — no direct browser APIs in the core; validation and state work on the server
|
|
40
|
+
- **Tree-shakeable** — Zod/Yup adapters are separate entry points; the UI subpackage is optional
|
|
41
|
+
- **TypeScript 5+** — generic type inference flows from schema to `values`
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Demo
|
|
46
|
+
|
|
47
|
+
A local interactive demo covers all major features of the library. Clone the repo and run:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install
|
|
51
|
+
npm run demo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Opens at **http://localhost:5174** with the following examples:
|
|
55
|
+
|
|
56
|
+
| Page | What it shows |
|
|
57
|
+
|---|---|
|
|
58
|
+
| **Basic form** | `FieldDefinition[]` with built-in validators — `required`, `minLength`, `email`, `min/max` |
|
|
59
|
+
| **JSON schema** | Server-driven schema with rule-based validators (`{ rule: 'minLength', value: 3 }`) |
|
|
60
|
+
| **Zod schema** | `z.object()` → `parseZod()`, type inference, `.describe()` for labels |
|
|
61
|
+
| **Yup schema** | `yup.object()` → `parseYup()`, Yup constraints delegated to `validateSync` |
|
|
62
|
+
| **Conditional fields** | `visible` as a function and as a string expression; `clearOnHide` behaviour |
|
|
63
|
+
| **Input masking** | All presets + custom `#`/`A` patterns; standalone `applyMask` / `removeMask` playground |
|
|
64
|
+
| **FormRenderer** | Out-of-the-box rendering; slot overrides (`#field-{name}`, `#submit`); custom component map |
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm install vue-form-schema
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Peer dependencies:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npm install vue # required
|
|
78
|
+
npm install zod # optional — only if you use the Zod adapter
|
|
79
|
+
npm install yup # optional — only if you use the Yup adapter
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Quick start
|
|
85
|
+
|
|
86
|
+
```vue
|
|
87
|
+
<script setup lang="ts">
|
|
88
|
+
import { useForm } from 'vue-form-schema'
|
|
89
|
+
import type { FieldDefinition } from 'vue-form-schema'
|
|
90
|
+
|
|
91
|
+
const schema: FieldDefinition[] = [
|
|
92
|
+
{ type: 'text', name: 'name', label: 'Full name', required: true },
|
|
93
|
+
{ type: 'email', name: 'email', label: 'Email', required: true },
|
|
94
|
+
{
|
|
95
|
+
type: 'select',
|
|
96
|
+
name: 'role',
|
|
97
|
+
label: 'Role',
|
|
98
|
+
options: [
|
|
99
|
+
{ label: 'Admin', value: 'admin' },
|
|
100
|
+
{ label: 'User', value: 'user' },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
const { values, errors, touched, isValid, isSubmitting, submit, setField } = useForm({
|
|
106
|
+
schema,
|
|
107
|
+
validateOn: 'blur',
|
|
108
|
+
onSubmit: async (data) => {
|
|
109
|
+
await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) })
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<form @submit.prevent="submit">
|
|
116
|
+
<div v-for="field in schema" :key="field.name">
|
|
117
|
+
<label>{{ field.label }}</label>
|
|
118
|
+
<input
|
|
119
|
+
:type="field.type"
|
|
120
|
+
:value="values[field.name]"
|
|
121
|
+
@input="setField(field.name, ($event.target as HTMLInputElement).value)"
|
|
122
|
+
@blur="touched[field.name] = true"
|
|
123
|
+
/>
|
|
124
|
+
<span v-if="touched[field.name] && errors[field.name]">
|
|
125
|
+
{{ errors[field.name][0] }}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
<button type="submit" :disabled="!isValid || isSubmitting">Submit</button>
|
|
129
|
+
</form>
|
|
130
|
+
</template>
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## FieldDefinition reference
|
|
136
|
+
|
|
137
|
+
Every field in your schema is described by a `FieldDefinition` object.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
interface FieldDefinition {
|
|
141
|
+
// ─── Required ─────────────────────────────────────────────────────────────
|
|
142
|
+
type: 'text' | 'number' | 'email' | 'select' | 'checkbox'
|
|
143
|
+
| 'radio' | 'textarea' | 'date' | 'array' | 'group'
|
|
144
|
+
|
|
145
|
+
/** Flat dot-path key used in the values object, e.g. "address.city" */
|
|
146
|
+
name: string
|
|
147
|
+
|
|
148
|
+
// ─── Display ──────────────────────────────────────────────────────────────
|
|
149
|
+
label?: string
|
|
150
|
+
placeholder?: string
|
|
151
|
+
|
|
152
|
+
// ─── Initial value ────────────────────────────────────────────────────────
|
|
153
|
+
defaultValue?: unknown // set on mount and after reset()
|
|
154
|
+
|
|
155
|
+
// ─── Constraints ──────────────────────────────────────────────────────────
|
|
156
|
+
required?: boolean
|
|
157
|
+
|
|
158
|
+
/** Static boolean, or a function receiving all current values */
|
|
159
|
+
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)
|
|
167
|
+
|
|
168
|
+
// ─── Validation ───────────────────────────────────────────────────────────
|
|
169
|
+
validators?: ValidatorFn[]
|
|
170
|
+
asyncValidators?: AsyncValidatorFn[]
|
|
171
|
+
|
|
172
|
+
// ─── Masking ──────────────────────────────────────────────────────────────
|
|
173
|
+
mask?: string | MaskConfig
|
|
174
|
+
|
|
175
|
+
// ─── select / radio ───────────────────────────────────────────────────────
|
|
176
|
+
options?: { label: string; value: unknown }[]
|
|
177
|
+
|
|
178
|
+
// ─── group / array ────────────────────────────────────────────────────────
|
|
179
|
+
fields?: FieldDefinition[]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Nested fields
|
|
184
|
+
|
|
185
|
+
Use `type: 'group'` to create a named group of fields. Child field names must use dot-path notation so values are properly scoped.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const schema: FieldDefinition[] = [
|
|
189
|
+
{
|
|
190
|
+
type: 'group',
|
|
191
|
+
name: 'address',
|
|
192
|
+
label: 'Address',
|
|
193
|
+
fields: [
|
|
194
|
+
{ type: 'text', name: 'address.city', label: 'City', required: true },
|
|
195
|
+
{ type: 'text', name: 'address.country', label: 'Country', required: true },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The resulting `values` object will be `{ 'address.city': '...', 'address.country': '...' }`.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## useForm composable
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { useForm } from 'vue-form-schema'
|
|
209
|
+
|
|
210
|
+
const form = useForm(config)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Config
|
|
214
|
+
|
|
215
|
+
| Property | Type | Default | Description |
|
|
216
|
+
|---|---|---|---|
|
|
217
|
+
| `schema` | `FieldDefinition[] \| JSONSchema` | — | Field definitions |
|
|
218
|
+
| `initialValues` | `Partial<T>` | `{}` | Seed values (override field defaults) |
|
|
219
|
+
| `validateOn` | `'input' \| 'blur' \| 'submit'` | `'blur'` | When sync validation is triggered |
|
|
220
|
+
| `clearOnHide` | `boolean` | `false` | Reset a field's value when it becomes hidden |
|
|
221
|
+
| `onSubmit` | `(values: T) => void \| Promise<void>` | — | Called after successful validation |
|
|
222
|
+
|
|
223
|
+
### Return value
|
|
224
|
+
|
|
225
|
+
| Property | Type | Description |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `fields` | `ComputedRef<FieldDefinition[]>` | Field list after conditions are evaluated |
|
|
228
|
+
| `values` | `Ref<T>` | Current form values (reactive) |
|
|
229
|
+
| `errors` | `Ref<Record<string, string[]>>` | Validation errors keyed by field name |
|
|
230
|
+
| `touched` | `Ref<Record<string, boolean>>` | Whether a field has been interacted with |
|
|
231
|
+
| `isDirty` | `ComputedRef<boolean>` | `true` when values differ from the initial state |
|
|
232
|
+
| `isValid` | `ComputedRef<boolean>` | `true` when all visible fields pass validation |
|
|
233
|
+
| `isSubmitting` | `Ref<boolean>` | `true` while `onSubmit` is running |
|
|
234
|
+
| `submit()` | `() => Promise<void>` | Touch all fields, validate, call `onSubmit` |
|
|
235
|
+
| `reset(values?)` | `(values?: Partial<T>) => void` | Restore initial state (or supply new values) |
|
|
236
|
+
| `setField(path, value)` | `(path: string, value: unknown) => void` | Programmatically set a value |
|
|
237
|
+
| `getField(path)` | `(path: string) => unknown` | Read a value by dot-path |
|
|
238
|
+
|
|
239
|
+
### Example — programmatic control
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const { setField, getField, reset } = useForm({ schema })
|
|
243
|
+
|
|
244
|
+
// Set a nested field
|
|
245
|
+
setField('address.city', 'Berlin')
|
|
246
|
+
|
|
247
|
+
// Read it back
|
|
248
|
+
console.log(getField('address.city')) // 'Berlin'
|
|
249
|
+
|
|
250
|
+
// Reset to schema defaults
|
|
251
|
+
reset()
|
|
252
|
+
|
|
253
|
+
// Reset to specific values
|
|
254
|
+
reset({ name: 'Alice', email: 'alice@example.com' })
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Schema formats
|
|
260
|
+
|
|
261
|
+
### FieldDefinition array
|
|
262
|
+
|
|
263
|
+
The native format — an array of `FieldDefinition` objects, usually created from the parser functions or manually:
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import type { FieldDefinition } from 'vue-form-schema'
|
|
267
|
+
|
|
268
|
+
const schema: FieldDefinition[] = [
|
|
269
|
+
{ type: 'text', name: 'username', required: true },
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
useForm({ schema })
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### JSON schema
|
|
276
|
+
|
|
277
|
+
A serialisable array format useful when the schema arrives from an API. Validators are expressed as named rules instead of functions. Pass directly to `useForm` or call `parseJSON` explicitly.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { parseJSON } from 'vue-form-schema'
|
|
281
|
+
|
|
282
|
+
const raw = [
|
|
283
|
+
{
|
|
284
|
+
type: 'text',
|
|
285
|
+
name: 'username',
|
|
286
|
+
label: 'Username',
|
|
287
|
+
default: '',
|
|
288
|
+
required: true,
|
|
289
|
+
validators: [
|
|
290
|
+
{ rule: 'minLength', value: 3, message: 'At least 3 characters' },
|
|
291
|
+
{ rule: 'maxLength', value: 20 },
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
type: 'email',
|
|
296
|
+
name: 'email',
|
|
297
|
+
validators: [{ rule: 'email' }],
|
|
298
|
+
},
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
// Option A — pass directly to useForm (auto-detected)
|
|
302
|
+
useForm({ schema: raw })
|
|
303
|
+
|
|
304
|
+
// Option B — parse explicitly
|
|
305
|
+
const fields = parseJSON(raw)
|
|
306
|
+
useForm({ schema: fields })
|
|
307
|
+
```
|
|
308
|
+
|
|
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.
|
|
323
|
+
|
|
324
|
+
### Zod
|
|
325
|
+
|
|
326
|
+
Install Zod and import the adapter from `vue-form-schema/zod`:
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { z } from 'zod'
|
|
330
|
+
import { parseZod } from 'vue-form-schema/zod'
|
|
331
|
+
import { useForm } from 'vue-form-schema'
|
|
332
|
+
|
|
333
|
+
const schema = z.object({
|
|
334
|
+
name: z.string().min(2).describe('Full name'),
|
|
335
|
+
age: z.number().min(0).optional(),
|
|
336
|
+
email: z.string().email(),
|
|
337
|
+
role: z.enum(['admin', 'user']),
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
const fields = parseZod(schema)
|
|
341
|
+
|
|
342
|
+
const { values, submit } = useForm({ schema: fields })
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Type inference is preserved — `values` will be typed as the Zod schema's output type when you supply it explicitly:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
type FormData = z.infer<typeof schema>
|
|
349
|
+
|
|
350
|
+
const { values } = useForm<FormData>({ schema: fields })
|
|
351
|
+
// values.value.name is string
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
**Zod type mapping:**
|
|
355
|
+
|
|
356
|
+
| Zod type | Field type |
|
|
357
|
+
|---|---|
|
|
358
|
+
| `z.string()` | `text` |
|
|
359
|
+
| `z.number()` | `number` |
|
|
360
|
+
| `z.boolean()` | `checkbox` |
|
|
361
|
+
| `z.enum(...)` | `select` |
|
|
362
|
+
| `z.array(...)` | `array` |
|
|
363
|
+
| `z.object(...)` | `group` |
|
|
364
|
+
|
|
365
|
+
Use `.describe('label text')` on any field to set the `label`.
|
|
366
|
+
|
|
367
|
+
### Yup
|
|
368
|
+
|
|
369
|
+
Install Yup and import the adapter from `vue-form-schema/yup`:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { object, string, number } from 'yup'
|
|
373
|
+
import { parseYup } from 'vue-form-schema/yup'
|
|
374
|
+
import { useForm } from 'vue-form-schema'
|
|
375
|
+
|
|
376
|
+
const schema = object({
|
|
377
|
+
name: string().required().label('Full name'),
|
|
378
|
+
email: string().email().required(),
|
|
379
|
+
age: number().min(0).optional(),
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
const fields = parseYup(schema)
|
|
383
|
+
|
|
384
|
+
const { values, submit } = useForm({ schema: fields })
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**Yup type mapping:**
|
|
388
|
+
|
|
389
|
+
| Yup type | Field type |
|
|
390
|
+
|---|---|
|
|
391
|
+
| `string()` | `text` |
|
|
392
|
+
| `number()` | `number` |
|
|
393
|
+
| `boolean()` | `checkbox` |
|
|
394
|
+
| `array()` | `array` |
|
|
395
|
+
| `object()` | `group` |
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Built-in validators
|
|
400
|
+
|
|
401
|
+
Import standalone validator functions when building a `FieldDefinition` array manually:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import {
|
|
405
|
+
required,
|
|
406
|
+
minLength,
|
|
407
|
+
maxLength,
|
|
408
|
+
min,
|
|
409
|
+
max,
|
|
410
|
+
pattern,
|
|
411
|
+
email,
|
|
412
|
+
url,
|
|
413
|
+
} from 'vue-form-schema'
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
| Function | Signature | Description |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| `required` | `ValidatorFn` | Fails for `null`, `undefined`, `''`, or empty array |
|
|
419
|
+
| `minLength(n, msg?)` | `(n: number) => ValidatorFn` | Min length for string or array |
|
|
420
|
+
| `maxLength(n, msg?)` | `(n: number) => ValidatorFn` | Max length for string or array |
|
|
421
|
+
| `min(n, msg?)` | `(n: number) => ValidatorFn` | Numeric minimum |
|
|
422
|
+
| `max(n, msg?)` | `(n: number) => ValidatorFn` | Numeric maximum |
|
|
423
|
+
| `pattern(re, msg?)` | `(re: RegExp) => ValidatorFn` | Regex match |
|
|
424
|
+
| `email` | `ValidatorFn` | Basic email format check |
|
|
425
|
+
| `url` | `ValidatorFn` | Valid URL (uses `new URL()`) |
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
import { minLength, maxLength, email } from 'vue-form-schema'
|
|
429
|
+
|
|
430
|
+
const schema: FieldDefinition[] = [
|
|
431
|
+
{
|
|
432
|
+
type: 'text',
|
|
433
|
+
name: 'username',
|
|
434
|
+
required: true,
|
|
435
|
+
validators: [
|
|
436
|
+
minLength(3, 'At least 3 characters'),
|
|
437
|
+
maxLength(20),
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
type: 'email',
|
|
442
|
+
name: 'email',
|
|
443
|
+
validators: [email],
|
|
444
|
+
},
|
|
445
|
+
]
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## Custom validators
|
|
451
|
+
|
|
452
|
+
### Sync validator
|
|
453
|
+
|
|
454
|
+
A `ValidatorFn` receives the field's current value and all form values. Return a string on failure, or `null` on success.
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import type { ValidatorFn } from 'vue-form-schema'
|
|
458
|
+
|
|
459
|
+
const noSpaces: ValidatorFn = (value) => {
|
|
460
|
+
if (typeof value === 'string' && value.includes(' ')) return 'No spaces allowed'
|
|
461
|
+
return null
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Cross-field: confirm password
|
|
465
|
+
const matchesPassword: ValidatorFn = (value, allValues) => {
|
|
466
|
+
if (value !== allValues['password']) return 'Passwords do not match'
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const schema: FieldDefinition[] = [
|
|
471
|
+
{ type: 'text', name: 'password', label: 'Password' },
|
|
472
|
+
{
|
|
473
|
+
type: 'text',
|
|
474
|
+
name: 'confirmPassword',
|
|
475
|
+
label: 'Confirm password',
|
|
476
|
+
validators: [matchesPassword],
|
|
477
|
+
},
|
|
478
|
+
]
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Async validator
|
|
482
|
+
|
|
483
|
+
An `AsyncValidatorFn` returns a `Promise<string | null>`. Async validators are debounced (300 ms by default) so they don't fire on every keystroke.
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
import type { AsyncValidatorFn } from 'vue-form-schema'
|
|
487
|
+
|
|
488
|
+
const uniqueUsername: AsyncValidatorFn = async (value) => {
|
|
489
|
+
const res = await fetch(`/api/check-username?q=${value}`)
|
|
490
|
+
const { taken } = await res.json()
|
|
491
|
+
return taken ? 'This username is already taken' : null
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const schema: FieldDefinition[] = [
|
|
495
|
+
{
|
|
496
|
+
type: 'text',
|
|
497
|
+
name: 'username',
|
|
498
|
+
required: true,
|
|
499
|
+
asyncValidators: [uniqueUsername],
|
|
500
|
+
},
|
|
501
|
+
]
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## Conditional fields
|
|
507
|
+
|
|
508
|
+
`visible` and `disabled` can be a static boolean, a reactive function, or a safe string expression.
|
|
509
|
+
|
|
510
|
+
### Function condition
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
const schema: FieldDefinition[] = [
|
|
514
|
+
{
|
|
515
|
+
type: 'checkbox',
|
|
516
|
+
name: 'hasCompany',
|
|
517
|
+
label: 'I represent a company',
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
type: 'text',
|
|
521
|
+
name: 'companyName',
|
|
522
|
+
label: 'Company name',
|
|
523
|
+
required: true,
|
|
524
|
+
// shown only when hasCompany is checked
|
|
525
|
+
visible: (values) => values['hasCompany'] === true,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
type: 'text',
|
|
529
|
+
name: 'vat',
|
|
530
|
+
label: 'VAT number',
|
|
531
|
+
// disabled unless company name is entered
|
|
532
|
+
disabled: (values) => !values['companyName'],
|
|
533
|
+
},
|
|
534
|
+
]
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
### String expression
|
|
538
|
+
|
|
539
|
+
String expressions have access to a `values` variable. Arithmetic, comparison, and logical operators are supported. Potentially unsafe calls (`fetch`, `eval`, etc.) are rejected by a whitelist check.
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
const schema: FieldDefinition[] = [
|
|
543
|
+
{ type: 'number', name: 'age', label: 'Age' },
|
|
544
|
+
{
|
|
545
|
+
type: 'select',
|
|
546
|
+
name: 'alcoholChoice',
|
|
547
|
+
label: 'Drink preference',
|
|
548
|
+
visible: 'values.age >= 18', // string expression
|
|
549
|
+
options: [
|
|
550
|
+
{ label: 'Beer', value: 'beer' },
|
|
551
|
+
{ label: 'Wine', value: 'wine' },
|
|
552
|
+
{ label: 'Water', value: 'water' },
|
|
553
|
+
],
|
|
554
|
+
},
|
|
555
|
+
]
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### clearOnHide
|
|
559
|
+
|
|
560
|
+
When `clearOnHide: true` is set in `useForm`, hiding a field resets its value back to `defaultValue` (or `null`).
|
|
561
|
+
|
|
562
|
+
```ts
|
|
563
|
+
useForm({
|
|
564
|
+
schema,
|
|
565
|
+
clearOnHide: true,
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## Input masking
|
|
572
|
+
|
|
573
|
+
Masks format user input in real time. They are applied automatically inside `FormRenderer` and can be used standalone via the `applyMask` / `removeMask` / `bindMask` functions.
|
|
574
|
+
|
|
575
|
+
### Mask presets
|
|
576
|
+
|
|
577
|
+
| Preset | Pattern | Example output |
|
|
578
|
+
|---|---|---|
|
|
579
|
+
| `phone-ru` | `+7 (###) ###-##-##` | `+7 (916) 123-45-67` |
|
|
580
|
+
| `phone-eu` | `+## (##) ###-##-##` | `+49 (30) 123-45-67` |
|
|
581
|
+
| `date` | `##.##.####` | `01.01.2024` |
|
|
582
|
+
| `inn` | `############` | `123456789012` |
|
|
583
|
+
| `iban` | `AA## #### #### #### #### #### ####` | `GB29 NWBK 6016 ...` |
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
// Using a preset
|
|
587
|
+
const field: FieldDefinition = {
|
|
588
|
+
type: 'text',
|
|
589
|
+
name: 'phone',
|
|
590
|
+
label: 'Phone',
|
|
591
|
+
mask: { preset: 'phone-ru' },
|
|
592
|
+
}
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Custom patterns
|
|
596
|
+
|
|
597
|
+
- `#` — matches a single digit (0–9)
|
|
598
|
+
- `A` — matches a single letter (a–z, A–Z); output is uppercased
|
|
599
|
+
- Any other character — treated as a fixed literal
|
|
600
|
+
|
|
601
|
+
```ts
|
|
602
|
+
// Postcode: two letters + four digits
|
|
603
|
+
const field: FieldDefinition = {
|
|
604
|
+
type: 'text',
|
|
605
|
+
name: 'postcode',
|
|
606
|
+
label: 'Postcode',
|
|
607
|
+
mask: { pattern: 'AA####' }, // e.g. AB1234
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Standalone mask API
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
import { applyMask, removeMask, bindMask } from 'vue-form-schema'
|
|
615
|
+
|
|
616
|
+
// Format a raw value
|
|
617
|
+
applyMask('9161234567', { preset: 'phone-ru' })
|
|
618
|
+
// → '+7 (916) 123-45-67'
|
|
619
|
+
|
|
620
|
+
// Strip mask literals from a formatted value
|
|
621
|
+
removeMask('+7 (916) 123-45-67', { preset: 'phone-ru' })
|
|
622
|
+
// → '9161234567'
|
|
623
|
+
|
|
624
|
+
// Attach to a native <input> element (returns cleanup fn)
|
|
625
|
+
const cleanup = bindMask(inputElement, { preset: 'date' })
|
|
626
|
+
// call cleanup() in onUnmounted
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## FormRenderer UI component
|
|
632
|
+
|
|
633
|
+
`FormRenderer` is in the optional `vue-form-schema/ui` subpackage. It renders fields automatically from the schema and delegates display to built-in or custom components.
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
import { FormRenderer } from 'vue-form-schema/ui'
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Basic usage
|
|
640
|
+
|
|
641
|
+
```vue
|
|
642
|
+
<script setup lang="ts">
|
|
643
|
+
import { useForm } from 'vue-form-schema'
|
|
644
|
+
import { FormRenderer } from 'vue-form-schema/ui'
|
|
645
|
+
|
|
646
|
+
const form = useForm({ schema, onSubmit })
|
|
647
|
+
</script>
|
|
648
|
+
|
|
649
|
+
<template>
|
|
650
|
+
<FormRenderer :form="form" submit-label="Save" />
|
|
651
|
+
</template>
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
### Props
|
|
655
|
+
|
|
656
|
+
| Prop | Type | Default | Description |
|
|
657
|
+
|---|---|---|---|
|
|
658
|
+
| `form` | `UseFormReturn` | — | Return value of `useForm` |
|
|
659
|
+
| `components` | `Partial<Record<FieldType, Component>>` | built-ins | Override per-type renderers |
|
|
660
|
+
| `submitLabel` | `string` | `'Submit'` | Submit button text |
|
|
661
|
+
|
|
662
|
+
### Slots
|
|
663
|
+
|
|
664
|
+
| Slot | Scope | Description |
|
|
665
|
+
|---|---|---|
|
|
666
|
+
| `#field-{name}` | `{ field, value, error, touched, setValue, touch }` | Replace the entire field |
|
|
667
|
+
| `#label-{name}` | `{ field }` | Replace the field label |
|
|
668
|
+
| `#error-{name}` | `{ field, error }` | Replace the error display |
|
|
669
|
+
| `#submit` | `{ isSubmitting, isValid }` | Replace the submit button |
|
|
670
|
+
|
|
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
|
+
### Built-in field components
|
|
706
|
+
|
|
707
|
+
The following components are exported from `vue-form-schema/ui` and can be used independently:
|
|
708
|
+
|
|
709
|
+
| Component | Types |
|
|
710
|
+
|---|---|
|
|
711
|
+
| `TextField` | `text`, `email` |
|
|
712
|
+
| `NumberField` | `number` |
|
|
713
|
+
| `TextareaField` | `textarea` |
|
|
714
|
+
| `SelectField` | `select` |
|
|
715
|
+
| `CheckboxField` | `checkbox` |
|
|
716
|
+
| `RadioField` | `radio` |
|
|
717
|
+
| `DateField` | `date` |
|
|
718
|
+
|
|
719
|
+
---
|
|
720
|
+
|
|
721
|
+
## SSR compatibility
|
|
722
|
+
|
|
723
|
+
The core package (`useForm`, validators, parsers, `ConditionEvaluator`) does not use any browser-specific APIs. State management and validation work on the server.
|
|
724
|
+
|
|
725
|
+
`bindMask` and `FormRenderer` use DOM APIs (`HTMLInputElement`, event listeners) and should only be called or mounted on the client side. Gate them with `onMounted` or Vue's `<ClientOnly>` wrapper as needed.
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
729
|
+
## TypeScript generics
|
|
730
|
+
|
|
731
|
+
`useForm` is generic over the shape of the values object. Supply the type explicitly to get full inference on `values.value`:
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
interface UserForm {
|
|
735
|
+
name: string
|
|
736
|
+
email: string
|
|
737
|
+
age: number | null
|
|
738
|
+
role: 'admin' | 'user'
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const { values, submit } = useForm<UserForm>({
|
|
742
|
+
schema,
|
|
743
|
+
onSubmit: (data) => {
|
|
744
|
+
// data is typed as UserForm
|
|
745
|
+
console.log(data.name)
|
|
746
|
+
},
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
// values.value is Ref<UserForm>
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
### Validator function types
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
import type { ValidatorFn, AsyncValidatorFn } from 'vue-form-schema'
|
|
756
|
+
|
|
757
|
+
// Sync: (value, allValues) => string | null
|
|
758
|
+
const myValidator: ValidatorFn = (value, allValues) => {
|
|
759
|
+
return String(value).length > 0 ? null : 'Required'
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Async: (value, allValues) => Promise<string | null>
|
|
763
|
+
const myAsyncValidator: AsyncValidatorFn = async (value) => {
|
|
764
|
+
const ok = await checkServer(value)
|
|
765
|
+
return ok ? null : 'Not available'
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
## Architecture
|
|
772
|
+
|
|
773
|
+
```
|
|
774
|
+
useForm
|
|
775
|
+
│
|
|
776
|
+
├── SchemaParser (json / zod / yup)
|
|
777
|
+
│ └── FieldDefinition[]
|
|
778
|
+
│
|
|
779
|
+
├── ConditionEvaluator
|
|
780
|
+
│ reactive watchEffect → resolves visible / disabled per field
|
|
781
|
+
│
|
|
782
|
+
├── ValidationEngine
|
|
783
|
+
│ sync validators → errors Record<path, string[]>
|
|
784
|
+
│ async validators → debounced, merged into errors
|
|
785
|
+
│
|
|
786
|
+
├── MaskEngine (standalone)
|
|
787
|
+
│ applyMask / removeMask / bindMask
|
|
788
|
+
│
|
|
789
|
+
└── (optional) FormRenderer [/ui]
|
|
790
|
+
renders FieldDefinition[] → input components
|
|
791
|
+
slots: #field-{name}, #label-{name}, #error-{name}, #submit
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
**Module dependency graph:**
|
|
795
|
+
|
|
796
|
+
```
|
|
797
|
+
index.ts
|
|
798
|
+
├── core/types.ts
|
|
799
|
+
├── core/useForm.ts
|
|
800
|
+
│ ├── core/ValidationEngine.ts
|
|
801
|
+
│ ├── core/ConditionEvaluator.ts
|
|
802
|
+
│ └── parsers/json.ts
|
|
803
|
+
├── core/MaskEngine.ts
|
|
804
|
+
├── parsers/zod.ts ← vue-form-schema/zod
|
|
805
|
+
└── parsers/yup.ts ← vue-form-schema/yup
|
|
806
|
+
|
|
807
|
+
ui/index.ts ← vue-form-schema/ui
|
|
808
|
+
├── ui/FormRenderer.vue
|
|
809
|
+
└── ui/fields/*.vue
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## Bundle size & peer dependencies
|
|
815
|
+
|
|
816
|
+
| Entry point | Peer deps | Notes |
|
|
817
|
+
|---|---|---|
|
|
818
|
+
| `vue-form-schema` | `vue ^3.3` | Core ≤ 8 KB gzip |
|
|
819
|
+
| `vue-form-schema/zod` | `zod ^3` | Lazy — zero cost if unused |
|
|
820
|
+
| `vue-form-schema/yup` | `yup ^1` | Lazy — zero cost if unused |
|
|
821
|
+
| `vue-form-schema/ui` | `vue ^3.3` | Optional UI layer |
|
|
822
|
+
|
|
823
|
+
All entry points are tree-shakeable ESM + CJS dual builds.
|
|
824
|
+
|
|
825
|
+
---
|
|
826
|
+
|
|
827
|
+
## License
|
|
828
|
+
|
|
829
|
+
MIT
|