@macrulez/vue-form-schema: реактивные формы из схемы для Vue 3

Формы во Vue 3 — один из немногих сценариев, где даже простая задача обрастает бойлерплейтом быстрее, чем успеваешь это заметить. Сначала пишешь ref('') для значения, потом добавляешь ref(false) для touched, потом computed для ошибки, потом ещё один computed для того, показывать ли ошибку. Умножь это на каждое поле, добавь логику условного отображения зависимых полей, маскирование телефона, валидацию при blur, асинхронную проверку уникальности — и получаешь компонент на 300–400 строк, в котором половина — это обслуживание состояния формы, а не бизнес-логика.
Стандартные решения — VeeValidate, FormKit — большие и тянут свою идеологию. Иногда нужно что-то легче: описал поля как данные, подключил composable, получил готовую систему без лишних зависимостей и без изучения отдельного DSL.
Написал @macrulez/vue-form-schema именно с такой идеей. Форма — это массив объектов-схем. Всё остальное — валидация, маски, условия, рендеринг, многошаговые wizard'ы — идёт из коробки. Пакет не требует ничего, кроме Vue 3. Zod, Yup, Valibot — опциональные peer-зависимости, нужные только если используешь соответствующий парсер.
Основа: useForm
Центральный composable — useForm. Он принимает массив FieldDefinition и конфигурационный объект, а возвращает всё состояние формы как набор реактивных объектов. Никаких компонентов-обёрток, никакой глобальной регистрации — просто вызов функции в setup().
typescript
import { useForm, required, minLength, email } from '@macrulez/vue-form-schema'
const { values, errors, touched, isValid, isSubmitting, submit, reset, setField } = useForm({
schema: [
{ type: 'text', name: 'name', label: 'Имя', required: true, validators: [minLength(2)] },
{ type: 'email', name: 'email', label: 'Email', required: true, validators: [email()] },
{ type: 'text', name: 'phone', label: 'Телефон', mask: '+7 (###) ###-##-##' },
],
validateOn: 'blur', // 'input' | 'blur' | 'submit' | 'eager'
validateMode: 'first', // 'first' | 'all' — одна или все ошибки поля
onSubmit: async (data) => {
await api.save(data)
},
})
values — реактивный объект с текущими значениями всех полей, ключи совпадают с name из схемы. errors — объект с массивами строк ошибок для каждого поля, пустой массив если ошибок нет. touched — объект-флаги, показывает взаимодействовал ли пользователь с конкретным полем. isValid — true только когда нет ни одной ошибки ни в одном поле. isSubmitting — true пока выполняется асинхронный onSubmit, удобно блокировать кнопку отправки.
submit() — не просто вызов onSubmit. Сначала он помечает все поля как touched, прогоняет полную валидацию по всем полям, и только если ошибок нет — вызывает onSubmit с итоговым объектом данных. reset() возвращает форму в начальное состояние: значения из defaultValue (или null), сброс touched и errors.
Про режимы validateOn стоит сказать отдельно. 'submit' — самый мягкий: ошибки появляются только после попытки отправки. 'blur' — стандартный UX: проверяем поле когда пользователь ушёл с него. 'input' — живая валидация на каждый символ, подходит для паролей с требованиями к сложности. 'eager' — гибридный режим: до первого blur ведёт себя как 'blur', но после того как поле было помечено touched, переключается в режим 'input'. Это решает классическую проблему: не раздражать пользователя ошибками пока он ещё не закончил ввод, но сразу показывать исправление как только он начал редактировать после ошибки.
Встроенные валидаторы
Валидаторы в пакете — это фабричные функции, каждая возвращает функцию-валидатор (value) => string | null. Возвращает null — значит ок, возвращает строку — это текст ошибки. Такой контракт позволяет легко писать свои валидаторы без какого-либо специального API.
typescript
import {
required, minLength, maxLength,
min, max, pattern, email, url,
sameAs, fileType, fileSize, fileCount
} from '@macrulez/vue-form-schema'
const schema = [
{
type: 'text',
name: 'username',
validators: [
required('Обязательное поле'),
minLength(3, 'Минимум 3 символа'),
maxLength(20),
pattern(/^[a-z0-9_]+$/, 'Только строчные буквы, цифры и _'),
],
},
{
type: 'text',
name: 'confirm',
validators: [sameAs('password', 'Пароли не совпадают')],
},
]
Валидаторы в массиве выполняются по порядку. Если включён validateMode: 'first' — цепочка прерывается на первой ошибке, пользователь видит одно сообщение. При validateMode: 'all' собираются все ошибки сразу — удобно для полей пароля, где нужно показать список несоблюдённых требований.
sameAs — особый валидатор: он обращается к значению другого поля по имени. Это позволяет делать cross-field валидацию прямо через схему, без кастомного кода.
Свой валидатор — это просто функция:
typescript
const noSpaces = (v: string) => /\s/.test(v) ? 'Без пробелов' : null
// с параметрами
const divisibleBy = (n: number) => (v: number) =>
v % n !== 0 ? `Должно быть кратно ${n}` : null
Асинхронные валидаторы работают так же — просто возвращай Promise<string | null>. Форма ждёт выполнения всех async-валидаторов перед тем как показать результат или разрешить сабмит. Это удобно для проверки уникальности логина, существования email или доступности промокода — всё что требует запроса к серверу.
Типы полей
Каждое поле в схеме описывается объектом с обязательными type и name. Тип определяет какой инпут рендерить и какого вида значение хранить.
| type | Тип значения | Описание |
|---|---|---|
text |
string | null |
Текстовый input |
email |
string | null |
Email input |
number |
number | null |
Числовой input |
textarea |
string | null |
Многострочный текст |
select |
unknown |
Выпадающий список |
radio |
unknown |
Группа радиокнопок |
checkbox |
boolean | null |
Чекбокс |
date |
string | null |
Датапикер (формат YYYY-MM-DD) |
file |
File | File[] | null |
Загрузка файлов |
array |
unknown[] |
Динамический список строк |
group |
Record<string, unknown> |
Группа вложенных полей |
Общие свойства, которые работают на всех типах: label — подпись поля, required — добавляет встроенный required-валидатор и aria-атрибут, disabled — отключает взаимодействие, placeholder — текст-подсказка, defaultValue — начальное значение (может быть функцией (values) => unknown для вычисления из значений других полей), validators — массив функций-валидаторов, conditions — правила видимости, component — кастомный Vue-компонент для рендеринга этого поля.
Значение null как начальное — намеренное решение. Это позволяет отличить «пустое поле» от «поле с дефолтным значением» и корректно обрабатывать optional-поля в TypeScript без undefined.
Условные поля
Условное отображение полей — одна из самых частых потребностей в реальных формах. Обычно это выглядит как v-if в шаблоне с проверкой form.values.type === 'company', разбросанный по компоненту. В схемном подходе условие живёт прямо рядом с описанием поля.
typescript
const schema = [
{
type: 'select',
name: 'paymentMethod',
label: 'Способ оплаты',
options: [
{ label: 'Карта', value: 'card' },
{ label: 'Счёт', value: 'invoice' },
],
},
{
type: 'text',
name: 'cardNumber',
label: 'Номер карты',
conditions: [{ field: 'paymentMethod', operator: 'eq', value: 'card' }],
},
{
type: 'text',
name: 'inn',
label: 'ИНН организации',
conditions: [{ field: 'paymentMethod', operator: 'eq', value: 'invoice' }],
},
]
Свойство conditions принимает массив правил. Каждое правило — это тройка { field, operator, value }. Доступные операторы: eq, neq, gt, gte, lt, lte, in, notIn, contains, startsWith, empty, notEmpty. Несколько условий в массиве объединяются через AND — поле отображается только когда все условия выполнены.
Важный нюанс: когда поле скрыто, оно автоматически исключается из итогового объекта данных, который приходит в onSubmit. Не нужно дополнительно чистить данные перед отправкой — в сабмите никогда не окажется cardNumber если пользователь выбрал оплату по счёту. Валидация скрытых полей тоже не запускается, что предотвращает ложные ошибки.
Под капотом условия пересчитываются через computed при каждом изменении values — всё реактивно, UI обновляется мгновенно.
Маскирование ввода
Маска задаётся прямо в схеме через свойство mask. Это избавляет от необходимости подключать отдельную библиотеку для маскирования и следить за синхронизацией маски с валидацией.
typescript
const schema = [
{ type: 'text', name: 'phone', label: 'Телефон', mask: '+7 (###) ###-##-##' },
{ type: 'text', name: 'card', label: 'Карта', mask: '#### #### #### ####' },
{ type: 'text', name: 'date', label: 'Дата', mask: '##.##.####' },
]
В строке маски # — это placeholder для цифры. Все остальные символы — литералы, которые вставляются автоматически при вводе. Пользователь вводит только цифры, скобки, пробелы и дефисы вставляются сами в нужных позициях. При стирании символов маска тоже корректно откатывается.
Реализовано через bindMask — функцию, которая навешивает обработчики на DOM-элемент input. Вызывается при монтировании компонента поля, снимается при анмаунте. Значение в form.values содержит отформатированную строку с масками — +7 (999) 123-45-67, а не сырые цифры.
Для нестандартных случаев — расширенный объектный формат, где можно указать произвольный символ-плейсхолдер и регулярное выражение для допустимых символов:
typescript
{ mask: '*** - ***', maskChar: '*', allowed: /[A-Z0-9]/i }
Это удобно для артикулов, кодов, инвентарных номеров — всего что имеет фиксированный формат но не состоит только из цифр.
FormRenderer — автоматический рендеринг
Если проект не требует нестандартной вёрстки — FormRenderer рендерит всю форму по схеме автоматически. Это избавляет от необходимости писать шаблонный HTML для каждого поля: лейбл, инпут, вывод ошибок, состояние disabled.
vue
<script setup>
import { useForm } from '@macrulez/vue-form-schema'
import { FormRenderer } from '@macrulez/vue-form-schema/ui'
const form = useForm({ schema, onSubmit })
</script>
<template>
<FormRenderer :form="form" submit-label="Сохранить" />
</template>
Под капотом FormRenderer итерирует видимые поля схемы и для каждого выбирает компонент по типу — TextField для text и email, SelectField для select, RadioField для radio и так далее. Каждый компонент поля оборачивается в BaseField, который отвечает за лейбл, список ошибок и aria-атрибуты доступности: aria-required, aria-invalid, aria-describedby. Логика видимости поля (conditions) уже учтена — скрытые поля в DOM не попадают.
Такая архитектура означает, что тема оформления — это просто другой набор компонентов с теми же пропсами. Хочешь BEM-стили — импортируй из vue-form-schema/ui. Хочешь Tailwind — из vue-form-schema/ui/tailwind. Хочешь собственные компоненты — регистрируй через createFormRegistry.
Отдельные поля переопределяются точечно через именованные слоты — не нужно переписывать весь рендерер ради одного нестандартного поля:
vue
<FormRenderer :form="form">
<!-- полностью заменяем рендер поля avatar -->
<template #field-avatar="{ field, value, setValue, touch }">
<AvatarUpload :value="value" @change="setValue" @blur="touch" />
</template>
<!-- кастомная кнопка вместо стандартной -->
<template #submit="{ isSubmitting }">
<MyButton type="submit" :loading="isSubmitting">Сохранить профиль</MyButton>
</template>
</FormRenderer>
Слот получает всё необходимое для подключения любого кастомного компонента к состоянию формы: field — объект определения поля, value — текущее значение, setValue — функция обновления, touch — пометить поле как touched, error и touched для управления отображением ошибок.
Zod, Yup, Valibot
Одна из частых ситуаций: схема валидации уже написана для бэкенда или API и нужно переиспользовать её на фронте без дублирования. Парсеры конвертируют схемы этих библиотек в массив FieldDefinition[].
typescript
// Zod
import { z } from 'zod'
import { parseZod } from '@macrulez/vue-form-schema/zod'
const schema = z.object({
username: z.string().min(3).max(20).describe('Username'),
email: z.string().email().describe('Email'),
role: z.enum(['admin', 'editor', 'viewer']).describe('Role'),
})
const fields = parseZod(schema)
const form = useForm({ schema: fields, onSubmit })
.describe() в Zod — единственный способ передать лейбл поля: эта строка становится label в FieldDefinition. z.string().email() превращается в type: 'email', z.enum([...]) — в type: 'select' с автоматически заполненными options, z.boolean() — в type: 'checkbox', z.optional() снимает флаг required.
typescript
// Yup
import * as yup from 'yup'
import { parseYup } from '@macrulez/vue-form-schema/yup'
const schema = yup.object({
fullName: yup.string().min(2).required().label('Full name'),
age: yup.number().min(18).optional().label('Age'),
bio: yup.string().max(200).optional().label('Bio'),
})
const fields = parseYup(schema)
В Yup лейбл задаётся через .label() — парсер читает его из метаданных схемы. Yup-ограничения (min, max, email, url, matches) делегируются напрямую validateSync() — все встроенные проверки Yup работают без повторной реализации.
typescript
// Valibot
import * as v from 'valibot'
import { parseValibot } from '@macrulez/vue-form-schema/valibot'
const schema = v.object({
email: v.pipe(v.string(), v.email()),
role: v.picklist(['admin', 'editor']),
})
// Valibot не имеет аналога .describe() — лейблы добавляем вручную
const fields = parseValibot(schema).map((f) => ({
...f,
label: { email: 'Email', role: 'Роль' }[f.name],
}))
Valibot отличается от Zod и Yup в одном месте: у него нет встроенного способа прикрепить произвольные метаданные к полю схемы, поэтому лейблы приходится добавлять после парсинга. Зато v.pipe(v.string(), v.email()) корректно определяется как type: 'email', v.picklist([...]) — как type: 'select', v.optional() снимает required.
Парсеры — это отдельные точки входа (/zod, /yup, /valibot), которые не попадают в бандл если их не импортировать. Сами Zod/Yup/Valibot являются peer-зависимостями — устанавливаются только если уже используются в проекте.
Динамические массивы: useFieldArray
Поля с type: 'array' решают задачу, которая в ручной реализации всегда превращается в отдельный компонент: форма где пользователь может добавлять и удалять строки — список участников, позиции заказа, теги, адреса.
В схеме array-поле содержит вложенный массив fields — это схема одной строки. При рендеринге каждая строка получает собственный набор полей с уникальными именами (members.0.name, members.1.name и т.д.), которые регистрируются в форме динамически.
typescript
import { useFieldArray } from '@macrulez/vue-form-schema'
const form = useForm({
schema: [
{ type: 'text', name: 'project', label: 'Проект', required: true },
{
type: 'array',
name: 'members',
label: 'Участники',
fields: [
{ type: 'text', name: 'members.name', label: 'Имя' },
{ type: 'email', name: 'members.email', label: 'Email' },
],
},
],
onSubmit,
})
const members = useFieldArray(form, 'members')
useFieldArray возвращает реактивный список строк и набор методов для управления ими:
typescript
// добавить пустую строку
members.append()
// добавить строку с предзаполненными данными
members.append({ name: 'Alice', email: 'alice@example.com' })
// добавить строку в начало
members.prepend()
// переставить строки
members.move(0, 2)
// поменять строки местами
members.swap(1, 3)
// заменить данные строки
members.replace(0, { name: 'Bob', email: 'bob@example.com' })
// удалить
members.remove(1)
// реактивное количество строк
console.log(members.count.value) // Ref<number>
Значение массива в form.values — это обычный массив объектов. После сабмита data.members будет [{ name: 'Alice', email: 'alice@example.com' }, ...] — без лишней обёртки, без индексов в ключах.
FormRenderer обрабатывает type: 'array' автоматически — рисует строки с кнопкой удаления и кнопкой «+ Add row» внизу. Кнопки стилизуются через CSS-классы vfs-array__row, vfs-array__remove, vfs-array__add.
Многошаговые формы: useMultiStepForm
Wizard-формы — регистрация в несколько шагов, онбординг, оформление заказа с выбором адреса и способа оплаты — требуют отдельной механики: валидации по шагам, хранения промежуточных данных, навигации вперёд-назад.
useMultiStepForm принимает массив конфигов шагов и финальный onSubmit. Каждый шаг — это обычный конфиг useForm, только без onSubmit (он задаётся централизованно). Под капотом каждый шаг — независимый useForm-инстанс.
typescript
import { useMultiStepForm } from '@macrulez/vue-form-schema'
import { MultiStepFormRenderer } from '@macrulez/vue-form-schema/ui'
const wizard = useMultiStepForm(
[
{
title: 'Аккаунт',
schema: [
{ type: 'text', name: 'username', label: 'Логин', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true },
],
},
{
title: 'Профиль',
schema: [
{ type: 'text', name: 'fullName', label: 'Имя', required: true },
{ type: 'select', name: 'country', label: 'Страна', options: countryOptions },
],
},
],
async (allValues) => {
// вызывается только после успешной валидации последнего шага
// allValues — объединённые данные всех шагов
await api.register(allValues)
}
)
vue
<template>
<!-- текущий шаг, кнопки Назад/Далее/Отправить -->
<MultiStepFormRenderer :form="wizard" />
</template>
Как работает навигация: wizard.next() сначала вызывает submit() на форме текущего шага — это валидирует все его поля и помечает как touched. Если ошибки есть — шаг не меняется, пользователь видит ошибки. Если всё ок — currentStep инкрементируется. Кнопка «Назад» просто декрементирует шаг без валидации. wizard.goTo(n) — перепрыгнуть на конкретный шаг напрямую.
wizard.values — ComputedRef с объединёнными данными всех шагов одновременно. Удобно для показа сводки перед финальной отправкой. wizard.isValid — true только если все шаги прошли валидацию. wizard.isSubmitting — true пока выполняется onSubmit.
MultiStepFormRenderer рисует индикатор шагов, поля текущего шага через FormRenderer и кнопки навигации. Полностью кастомизируется: через :components передаёшь свои компоненты полей, через слоты переопределяешь кнопки навигации или добавляешь свой прогресс-бар.
Динамические опции
Опции для select и radio не всегда известны на момент инициализации формы — они могут зависеть от API, от значений других полей или от роли пользователя. Для этого есть setFieldOptions и setFieldProp.
typescript
const form = useForm({
schema: [
{
type: 'select',
name: 'city',
label: 'Город',
options: [], // пустой массив на старте
optionsLoading: true, // показывает лоадер вместо select
},
],
onSubmit,
})
// подгружаем опции после монтирования
onMounted(async () => {
const cities = await api.getCities()
form.setFieldOptions('city', cities)
form.setFieldProp('city', 'optionsLoading', false)
})
Пока optionsLoading: true — FormRenderer вместо <select> показывает индикатор загрузки. Как только опции загружены и флаг сброшен — рендерится полноценный список. Всё реактивно.
Часто опции одного поля зависят от значения другого — классический пример «страна → город»:
typescript
watch(() => form.values.value.country, async (country) => {
if (!country) return
// блокируем поле на время загрузки
form.setFieldProp('city', 'optionsLoading', true)
// сбрасываем предыдущий выбор чтобы не осталось невалидного значения
form.setField('city', null)
const cities = await api.getCities(country)
form.setFieldOptions('city', cities)
form.setFieldProp('city', 'optionsLoading', false)
})
setFieldProp позволяет менять любой проп поля в рантайме — не только optionsLoading. Можно динамически менять disabled, required, label, placeholder или добавлять/убирать валидаторы. Это открывает возможности для сложной зависимой логики без хардкода в схеме.
Загрузка файлов
Поле type: 'file' — полноценный компонент загрузки с drag & drop, предпросмотром списка файлов и встроенными валидаторами для работы с File-объектами.
typescript
const schema = [
{
type: 'file',
name: 'avatar',
label: 'Аватар',
accept: 'image/*', // передаётся в атрибут accept нативного input
validators: [
fileType(['image/jpeg', 'image/png', 'image/webp'], 'Только JPG/PNG/WebP'),
fileSize(2 * 1024 * 1024, 'Максимум 2 МБ'),
],
},
{
type: 'file',
name: 'documents',
label: 'Документы',
multiple: true, // разрешить несколько файлов
accept: '.pdf,.doc,.docx',
validators: [
fileCount(5, 'Максимум 5 файлов'),
fileType(['application/pdf'], 'Только PDF'),
],
},
]
Значение в form.values — это File | null для одиночного поля и File[] | null для multiple: true. Файловые объекты передаются в onSubmit как есть — можно сразу передавать в FormData для отправки на сервер.
Специальные валидаторы для файлов — fileType, fileSize, fileCount — работают с реальными File-объектами: fileType проверяет file.type (MIME), fileSize проверяет file.size в байтах, fileCount — длину массива. Все три принимают опциональное второе сообщение об ошибке.
FormRenderer рисует drag & drop зону: пользователь может перетащить файлы или кликнуть для выбора через системный диалог. После выбора отображается список файлов с названием, размером и кнопкой удаления. Загрузка на сервер — за пределами компонента, поле только хранит File-объекты.
Кастомные компоненты полей
Стандартных типов бывает недостаточно — нужен телефонный инпут с флагом страны, rich text editor, кастомный date picker или автокомплит с поиском. Любой Vue-компонент можно подключить к форме через свойство component в схеме.
typescript
import PhoneInput from './PhoneInput.vue'
import RichTextEditor from './RichTextEditor.vue'
const schema = [
{
type: 'text',
name: 'phone',
label: 'Телефон',
component: PhoneInput, // FormRenderer использует его вместо TextField
},
{
type: 'textarea',
name: 'description',
label: 'Описание',
component: RichTextEditor,
},
]
Компонент должен реализовывать простой контракт — четыре пропса и два emit:
typescript
// PhoneInput.vue
const props = defineProps<{
field: FieldDefinition // объект поля из схемы — доступны все кастомные свойства
modelValue: string | null // текущее значение
error?: string[] // массив текстов ошибок
touched?: boolean // был ли контакт с полем
}>()
const emit = defineEmits<{
'update:modelValue': [value: string] // обновить значение в форме
blur: [] // пометить поле как touched
}>()
Это всё. Никакого inject, никакого глобального стора, никакого специального базового класса. Компонент получает значение и ошибки через пропсы, отдаёт изменения через emit. Валидация, маски, условия — всё работает автоматически.
Если нужно подключить поле к форме без FormRenderer — например, вставить кастомный элемент прямо в шаблон страницы — есть useFormField:
typescript
// внутри своего компонента или прямо в setup() страницы
import { useFormField } from '@macrulez/vue-form-schema'
const { value, errors, isTouched, handleBlur, handleUpdate } = useFormField(form, 'phone')
value — реактивный Ref с текущим значением. errors — реактивный массив строк ошибок. isTouched — флаг touched. handleBlur — вызвать при blur-событии. handleUpdate — передать новое значение. Это нижний уровень API для полной свободы в интеграции любого компонента.
Реестр компонентов
Когда в проекте есть дизайн-система или набор переопределённых компонентов, прописывать component в каждом поле каждой схемы — не вариант. Глобальный реестр решает это через Vue-плагин.
typescript
// main.ts
import { createFormRegistry } from '@macrulez/vue-form-schema'
import MyTextInput from './components/MyTextInput.vue'
import MySelectInput from './components/MySelectInput.vue'
import MyCheckbox from './components/MyCheckbox.vue'
app.use(createFormRegistry({
text: MyTextInput,
email: MyTextInput,
number: MyTextInput,
select: MySelectInput,
checkbox: MyCheckbox,
}))
После этого любой FormRenderer в приложении автоматически использует эти компоненты для соответствующих типов полей. Схемы писать так же — никаких изменений не нужно. Переход на новую версию дизайн-системы или глобальная замена инпутов — это одно место, один коммит.
Приоритет разрешения компонента следующий: field.component в схеме → пропс :components на FormRenderer → глобальный реестр → встроенные компоненты. Это означает, что переопределение на уровне конкретной формы всегда выигрывает у глобального, а на уровне конкретного поля — у обоих.
vue
<!-- приложение использует MyTextInput глобально -->
<FormRenderer :form="form" />
<!-- но для этой формы используем другой компонент для select -->
<FormRenderer :form="form" :components="{ select: PremiumSelect }" />
Реестр также поддерживается в TailwindFormRenderer и в ArrayField — кастомные компоненты работают одинаково на любом уровне вложенности.
Tailwind-тема
Стандартные компоненты из vue-form-schema/ui используют BEM-классы: vfs-input, vfs-field__label, vfs-field--error и т.д. Это хорошо работает с кастомным CSS, но не вписывается в проект на Tailwind, где принято описывать стили utility-классами прямо в разметке.
Для таких проектов есть отдельный пакет компонентов:
vue
<script setup>
import { TailwindFormRenderer } from '@macrulez/vue-form-schema/ui/tailwind'
</script>
<template>
<!-- та же форма, тот же composable — только другой рендерер -->
<TailwindFormRenderer :form="form" submit-label="Сохранить" />
</template>
Разница только в рендерере — useForm, схема, валидаторы, условия остаются теми же. TailwindFormRenderer использует компоненты вроде bg-gray-900 rounded-lg border border-gray-700 вместо vfs-input. Все компоненты экспортируются отдельно для точечного использования:
typescript
import {
TwTextField, TwTextareaField, TwSelectField,
TwCheckboxField, TwRadioField, TwDateField,
TwFileField, TwArrayField
} from '@macrulez/vue-form-schema/ui/tailwind'
Важный момент при подключении Tailwind: в конфиге нужно добавить путь к компонентам пакета в content, иначе JIT-сканер не найдёт классы и они не попадут в сборку:
javascript
// tailwind.config.js
export default {
content: [
'./src/**/*.{vue,ts}',
'./node_modules/@macrulez/vue-form-schema/dist/**/*.js', // классы Tailwind в компонентах
],
}
Дебаг и инспекция
Во время разработки удобно видеть всё состояние формы сразу: значения, ошибки, touched-флаги. Вместо расстановки console.log по обработчикам — useFormDebug даёт реактивный снимок.
vue
<script setup>
import { useFormDebug } from '@macrulez/vue-form-schema'
const snap = useFormDebug(form)
</script>
<template>
<MyForm :form="form" />
<!-- панель отладки прямо на странице во время разработки -->
<pre v-if="isDev" style="position:fixed;bottom:0;right:0;font-size:11px;max-height:300px;overflow:auto">
{{ snap }}
</pre>
</template>
Снимок содержит полное состояние: values — текущие значения всех полей, errors — все накопленные ошибки, touched — флаги touched по полям, isValid, isSubmitting — глобальные флаги, fields — массив FieldDefinition с учётом вычисленных условий видимости. Всё обновляется реактивно при каждом изменении.
Отдельно полезно при работе с useMultiStepForm — можно повесить useFormDebug на форму конкретного шага и видеть его состояние изолированно.
Сценарии в реальных проектах
CRM: карточка создания заявки
Типичная CRM-форма: зависит от типа клиента (физлицо или юрлицо), у каждого типа свой набор реквизитов. Города подгружаются после выбора страны. Телефон с маской.
Раньше это выглядело как компонент на 200+ строк с кучей v-if, несколькими watch и ручным управлением состоянием загрузки. Здесь — всё в схеме, минимум imperative-кода.
typescript
const form = useForm({
schema: [
{ type: 'select', name: 'clientType', label: 'Тип клиента',
options: [{ label: 'Физлицо', value: 'person' }, { label: 'Юрлицо', value: 'company' }] },
// реквизиты физлица
{ type: 'text', name: 'passport', label: 'Серия и номер паспорта',
conditions: [{ field: 'clientType', operator: 'eq', value: 'person' }],
mask: '#### ######' },
{ type: 'date', name: 'birthDate', label: 'Дата рождения',
conditions: [{ field: 'clientType', operator: 'eq', value: 'person' }] },
// реквизиты юрлица
{ type: 'text', name: 'companyName', label: 'Название организации',
conditions: [{ field: 'clientType', operator: 'eq', value: 'company' }],
validators: [required()] },
{ type: 'text', name: 'inn', label: 'ИНН',
conditions: [{ field: 'clientType', operator: 'eq', value: 'company' }],
validators: [minLength(10), maxLength(12), pattern(/^\d+$/, 'Только цифры')] },
{ type: 'select', name: 'country', label: 'Страна', options: countryOptions },
{ type: 'select', name: 'city', label: 'Город', options: [], optionsLoading: false },
{ type: 'text', name: 'phone', label: 'Телефон', mask: '+7 (###) ###-##-##' },
{ type: 'textarea', name: 'notes', label: 'Комментарий' },
],
validateOn: 'blur',
onSubmit: async (data) => api.createRequest(data),
})
watch(() => form.values.value.country, async (country) => {
if (!country) return
form.setFieldProp('city', 'optionsLoading', true)
form.setField('city', null)
form.setFieldOptions('city', await api.getCities(country))
form.setFieldProp('city', 'optionsLoading', false)
})
Когда выбрано «Физлицо» — в onSubmit придут только поля физлица, поля юрлица (companyName, inn) будут исключены автоматически. Инвертируй тип — поля поменяются без единой строки в шаблоне. Весь визуал — через FormRenderer, ни строки HTML для полей.
Кабинет пользователя: wizard онбординга
Новый пользователь проходит три шага регистрации. Сложность классической реализации — хранить данные между шагами, запускать валидацию только для текущего шага, собрать всё вместе на последнем шаге. Здесь это всё решает useMultiStepForm.
Каждый шаг — независимая форма. Переход к следующему шагу возможен только после успешной валидации текущего. Пользователь может вернуться назад и изменить данные — они сохраняются в соответствующем шаге.
typescript
const wizard = useMultiStepForm(
[
{
title: 'Основные данные',
schema: [
{ type: 'text', name: 'firstName', label: 'Имя', required: true },
{ type: 'text', name: 'lastName', label: 'Фамилия', required: true },
{ type: 'email', name: 'email', label: 'Email', required: true,
validators: [email(), async (v) => {
const exists = await api.checkEmail(v)
return exists ? 'Email уже зарегистрирован' : null
}]
},
],
},
{
title: 'Рабочие данные',
schema: [
{ type: 'select', name: 'role', label: 'Роль', required: true, options: roleOptions },
{ type: 'select', name: 'department', label: 'Отдел', required: true, options: deptOptions },
{ type: 'text', name: 'position', label: 'Должность' },
],
},
{
title: 'Настройки',
schema: [
{ type: 'radio', name: 'theme', label: 'Тема интерфейса', defaultValue: 'system',
options: [
{ label: 'Светлая', value: 'light' },
{ label: 'Тёмная', value: 'dark' },
{ label: 'Системная', value: 'system' },
]
},
{ type: 'checkbox', name: 'newsletter', label: 'Получать еженедельный дайджест' },
{ type: 'checkbox', name: 'terms', label: 'Согласен с условиями использования',
required: true,
validators: [(v) => v !== true ? 'Необходимо принять условия' : null] },
],
},
],
async (allValues) => {
// вызывается один раз после успешной валидации последнего шага
// allValues содержит данные всех трёх шагов объединённые
await api.completeOnboarding(allValues)
router.push('/dashboard')
}
)
Обрати внимание на async-валидатор в email на первом шаге: он проверяет уникальность через API. wizard.next() дождётся выполнения этого запроса перед тем как перейти на второй шаг — пользователь не сможет двигаться дальше с уже занятым email.
MultiStepFormRenderer рисует прогресс-индикатор, поля текущего шага и кнопки Назад/Далее/Отправить. Весь внешний вид кастомизируется через :components и именованные слоты.
SaaS: форма настроек с реестром компонентов
В продуктах с дизайн-системой все формы должны использовать корпоративные компоненты — не браузерные инпуты. Без реестра это означало бы прописывать component: DSInput в каждом поле каждой из десятков форм. При обновлении DS-компонента — обновлять вручную везде.
Реестр решает это раз и навсегда: регистрируем в main.ts, получаем везде без каких-либо изменений в схемах.
typescript
// main.ts
import { createFormRegistry } from '@macrulez/vue-form-schema'
import { DSInput, DSSelect, DSTextarea, DSCheckbox, DSDatePicker } from '@company/design-system'
app.use(createFormRegistry({
text: DSInput,
email: DSInput,
number: DSInput,
select: DSSelect,
textarea: DSTextarea,
checkbox: DSCheckbox,
date: DSDatePicker,
}))
Теперь любой FormRenderer в любом компоненте приложения автоматически использует компоненты из дизайн-системы. Схемы пишем как обычно — никаких изменений не нужно. Выходит новая мажорная версия DS с изменённым API — обновляем адаптеры в одном месте.
vue
<!-- SettingsPage.vue, ProfilePage.vue, BillingPage.vue — все используют DSInput автоматически -->
<FormRenderer :form="settingsForm" />
<FormRenderer :form="profileForm" />
<FormRenderer :form="billingForm" />
Там, где нужно точечное исключение — переопределяем на уровне конкретной формы:
vue
<!-- форма оплаты использует специальный компонент для номера карты -->
<FormRenderer :form="paymentForm" :components="{ text: CreditCardInput }" />
Или на уровне конкретного поля прямо в схеме — для максимальной гибкости без влияния на всё остальное:
typescript
{ type: 'text', name: 'promoCode', label: 'Промокод', component: PromoCodeInput }
Приоритеты разрешаются в правильном порядке: схема поля → пропс рендерера → глобальный реестр → встроенный компонент. Ничего не ломается, всё предсказуемо.
NPM: https://www.npmjs.com/package/@macrulez/vue-form-schema
GitHub: https://github.com/macrulezru/vue-form-schema