GrForm
Берут, когда полей больше одного и они проверяются.
Когда брать
- полей больше одного и они проверяются — правила объявляются по имени поля, а не развешиваются руками;
- поля зависят друг от друга — валидатор видит всю модель: совпадение паролей, «дата конца позже начала»;
- отправка блокируется до валидности — вместе со скроллом и фокусом к первой ошибке;
- форма блокируется целиком —
disabledна форме гасит все контролы внутри.
Когда взять другое
| Нужно | Берите |
|---|---|
| Поле одно и правил нет | GrFormField + GrInput |
| Запросить одно значение окном | GrPromptDialog |
| Только раскладка и подписи, проверка на сервере | GrFormField |
Контролы про форму не знают: оркестрация подключается через GrFormField по
name. Поэтому любой существующий контрол попадает в валидацию без единой
правки — и свой, написанный поверх контракта форм-контрола, тоже.
Обязательность приходит из двух мест
Поле обязательно, если у него есть правило required или у самого
GrFormField стоит проп required. Оба источника участвуют и в маркере *, и
в валидации: раньше второй рисовал звёздочку, но submit проходил с пустым
полем.
Полю без правил в этом случае применяется неявное { required: true } — с тем
же сообщением из локали, что и у явного правила.
Снимок и сброс
resetFields() возвращает модель к снимку. Снимок берётся при монтировании,
поэтому форме редактирования нужно переснять его после загрузки данных:
const form = ref<GrFormInstance>()
const data = await api.load()
Object.assign(model, data)
form.value?.setSnapshot()
Без этого «сброс» возвращал бы пустой объект, каким модель была до ответа
сервера. resetFields(names?) умеет и точечный сброс; ключ, которого в снимке
нет, удаляется, а не превращается в undefined. Исключение — методы: они
в снимок не попадают по построению, и удалять их значило бы ломать модель
(см. «Модель может быть чужой»).
isDirty сравнивает модель со снимком, isValid означает «известных ошибок
нет» — до первого validate() это ещё не «форма валидна», а «никто не
проверял». Оба доступны и через ref формы, и в слот-пропах.
Выключение формы
disabled у формы уезжает в контекст поля и дальше в контролы: «выключить всё
на время отправки» больше не требует обходить контролы по одному. Проп
контрола и проп поля складываются с ним по «или» — как это уже сделано с
readonly.
Файлы правилом формы
Ограничения файла описываются там же, где остальные правила, — правилом file:
const rules: GrFormRules = {
contract: [{
required: true,
file: { accept: '.pdf,application/pdf', maxSizeMb: 1 },
}],
}
| Ключ | Что ограничивает |
|---|---|
accept | W3C-строка accept ('image/*,.pdf') |
extensions | белый список расширений |
mimeTypes | белый список MIME-типов |
maxSizeMb / maxSizeBytes | размер одного файла; заданы оба — применяется меньший |
maxCount | количество файлов в наборе |
maxTotalSizeMb | суммарный размер набора |
validators | свои FileValidator, в том числе async — идут после встроенных |
Своих проверок у правила нет ни одной: оно собирает те же валидаторы из
../file-validation.md, которые GrFormFile и
v-dropzone запускают на выборе и на drop. Поэтому и текст ошибки один — он
приходит от валидатора и локализуется его ключом gr.fileValidation.*, а не
превращается в отдельное «некорректный файл». rule.message перекрывает его,
как у любого другого правила.
Проверяются значения File и File[]; всё остальное правило пропускает —
придумывать вердикт за строку оно не должно. Пустоту разбирает required.
Если проблемных файлов несколько, поле показывает первую — у поля формы одна
строка ошибки, и файлы тут не исключение. Подробный разбор по каждому файлу
остаётся за GrFormFile.
Сам GrFormFile при этом не меняется. Ограничения на поле (accept, limit,
validators) — быстрая обратная связь: плохой файл не попадает в модель вообще.
Правило формы — гарантия на submit: оно видно validate(), попадает в событие
invalid и участвует в скролле к первой ошибке. Дублирования на экране не
возникает: то, что поле не пустило в модель, правилу формы уже не показывают.
Типичное разделение — ограничения в rules, а на поле accept как фильтр
диалога выбора.
Асинхронные правила
Пока правило ходит на сервер, поле помечается aria-busy и показывает строку
из локали (gr.form.validating) вместо старой ошибки: ошибка относилась к
прежнему значению, и держать её на экране — врать про текущее.
Список полей в проверке доступен как validatingFields — в expose и в
слот-пропах.
Перекрывающиеся прогоны одного поля разруливает счётчик поколений: применяется
результат последнего запуска, а вытесненный прогон отдаёт вызвавшему вердикт
вытеснившего, дождавшись его. Поэтому validate() и submit не могут
проскочить по значению, которое ещё никто не проверил.
clearValidate() и resetFields() летящие проверки отменяют: их ответ
относится к состоянию до сброса, в очищенную форму он не попадёт, и поле
перестаёт считаться проверяемым сразу, не дожидаясь сервера.
Модель может быть чужой
model типизирован дженериком, а не словарём: моделью бывает объект чужой
библиотеки — useForm Inertia, стор, — у которого рядом с полями лежат
собственные методы. Он заходит как есть, без as unknown as, и submit отдаёт
его тем же типом.
const form = useForm({ email: '', password: '' })
<GrForm :model="form" :rules="rules" @submit="form.post('/login')">
Форма адресует только имена, объявленные полями (GrFormField name), поэтому
всё остальное в объекте её не касается. Два следствия, которые стоит знать
заранее:
resetFields()возвращает значения полей и не трогает методы. Снимок строится клоном, а клон отбрасывает функции — без отдельной защиты «нет в снимке» означало бы «удалить», и сброс снёс бы с объектаpost,reset,errors. Собственный сброс внешней формы (form.reset()) при этом остаётся вашим: он знает про свои служебные поля, аGrForm— нет;isDirtyсчитается по всему объекту. У внешней формы рядом с полями живут её собственные реактивные поля (processing,errors), и их изменение форма тоже увидит. Нужен признак «пользователь правил данные» — берите его у самой внешней формы.
Submit асинхронный
Правила бывают промисами, поэтому validate() всегда возвращает Promise, а
submit эмитится после её разрешения. В тестах одного nextTick() не
хватает:
await wrapper.find('form').trigger('submit')
await flushPromises() // не nextTick: правила могли уйти на сервер
expect(onSubmit).toHaveBeenCalled()События и API
submit(model)— только при валидной форме;invalid(errors)— карта сообщений, когда submit не прошёл. Без него «форма невалидна» и «ничего не произошло» выглядят одинаково;validate(name, valid, message)— результат по одному полю.
Императивно: validate(), validateField(name, trigger?), clearValidate(),
resetFields(), setSnapshot(), scrollToField(name), плюс isDirty,
isValid, validatingFields.
Тип для ref — GrFormInstance, а не InstanceType<typeof GrForm>: форма
дженерик, а такой компонент компилируется в функцию, у которой конструктора нет.
const form = ref<GrFormInstance>()
form.value?.resetFields()
Правила и их сообщения — ../file-validation.md для
файлов и GrForm/validation.ts для остального; движок публичный
(runFieldRules, createGrFormMessageResolver), и GrPromptDialog
пользуется тем же.
Playground 4
Загружается…
<GrForm />Установка
npm i @feugene/granularityИмпорт
import { GrForm } from '@feugene/granularity/components/GrForm'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
modelобязательный | TModel | — | Реактивный объект данных формы. Поля адресуются по `name` (`GrFormField`), в т.ч. dot-path. Тип дженерик, а не `Record<string, unknown>`: моделью бывает объект чужой библиотеки (`useForm` Inertia, стор), у которого рядом с полями лежат собственные методы. Под словарь он не подходит, и потребителю пришлось бы приводить его через `as unknown as` в каждой форме. |
rules | GrFormRules | undefined | undefined | Правила валидации по имени поля: `{ email: [{ required: true, type: 'email' }] }`. |
validateOnBlur | boolean | undefined | true | Валидировать поле при потере фокуса. |
validateOnChange | boolean | undefined | false | Валидировать поле при каждом изменении значения. |
scrollToError | boolean | undefined | true | Скроллить к первому невалидному полю после `validate()`. |
scrollBehavior | ScrollBehavior | undefined | "smooth" | — |
disabled | boolean | undefined | false | Выключить форму целиком — типично на время отправки. Доезжает до контролов через контекст поля, поэтому обходить их по одному не нужно. |
Slots
| Slot | Type | Описание |
|---|---|---|
default | { validate: () => Promise<boolean>; errors: Record<string, string | undefined>; isDirty: boolean; isValid: boolean; validatingFields: Set<string>; resetFields: (names?: string | string[] | undefined) => void; setSnapshot: (model?: Record<string, unknown> | undefined) => void; } | Поля формы. Слот-пропы повторяют публичный API инстанса: форма в шаблоне доступна без `ref`, а значит и без `nextTick` после монтирования. |
Events
| Event | Type | Описание |
|---|---|---|
submit | [TModel] | Форма прошла валидацию по submit. Отдаёт `model`. |
validate | [string, boolean, string | undefined] | Результат валидации одного поля. |
invalid | [Record<string, string>] | Submit не прошёл валидацию. Без этого события «форма невалидна» и «ничего не произошло» выглядят для потребителя одинаково. |
Примеры 5
Декларативная проверка и отправка
Модель формы (:model) + декларативные rules по имени поля. GrFormField с name сам подтягивает ошибку и маркер обязательности из формы, а submit эмитится только если форма валидна. Контролы (GrInput) не меняются — они уже читают invalid/id/aria-describedby из контекста поля.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { GrButton, GrForm, GrFormField, GrInput, type GrFormInstance, type GrFormRules } from '@feugene/granularity'
const model = reactive({ name: '', email: '', password: '' })
const rules: GrFormRules = {
name: [{ required: true }],
email: [{ required: true, type: 'email' }],
password: [{ required: true, min: 8 }],
}
const formRef = ref<GrFormInstance>()
const submitted = ref(false)
function onSubmit() {
submitted.value = true
}
function reset() {
formRef.value?.resetFields()
submitted.value = false
}
</script>
<template>
<GrForm
ref="formRef"
:model="model"
:rules="rules"
class="grid max-w-sm gap-4"
@submit="onSubmit"
>
<GrFormField name="name" label="Name">
<GrInput v-model="model.name" placeholder="Ada Lovelace" />
</GrFormField>
<GrFormField name="email" label="Email">
<GrInput v-model="model.email" type="email" placeholder="ada@example.com" />
</GrFormField>
<GrFormField name="password" label="Password" hint="At least 8 characters">
<GrInput v-model="model.password" type="password" />
</GrFormField>
<div class="flex gap-2">
<GrButton type="submit">
Sign up
</GrButton>
<GrButton variant="secondary" type="button" @click="reset">
Reset
</GrButton>
</div>
<p v-if="submitted" class="text-sm text-[var(--gr-success)]">
Submitted — form is valid.
</p>
</GrForm>
</template>Ошибка снимается по мере исправления поля; resetFields() возвращает начальные значения. Императивный API — через template ref: validate() / validateField() / clearValidate() / scrollToField().
Любой контрол плюс свои и асинхронные правила
Ключевой архитектурный смысл: GrSelect, GrAutocomplete, GrInput валидируются одинаково — через GrFormField name, без правок самих контролов. Кастомный (в т.ч. async) validator получает значение и весь model (проверка совпадения паролей). validate() скроллит к первому невалидному полю.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import {
GrAutocomplete,
GrButton,
GrForm,
GrFormField,
GrInput,
GrSelect,
type GrFormInstance,
type GrFormRules,
} from '@feugene/granularity'
const model = reactive({ username: '', country: '', framework: '', password: '', confirm: '' })
const countries = [
{ value: 'us', label: 'United States' },
{ value: 'de', label: 'Germany' },
{ value: 'jp', label: 'Japan' },
]
const frameworks = [
{ value: 'vue', label: 'Vue' },
{ value: 'react', label: 'React' },
{ value: 'svelte', label: 'Svelte' },
]
const rules: GrFormRules = {
username: [{ required: true, min: 3, trigger: 'blur' }],
country: [{ required: true }],
framework: [{ required: true }],
password: [{ required: true, min: 8 }],
confirm: [
{ required: true },
{ validator: value => value === model.password || 'Passwords do not match' },
],
}
const formRef = ref<GrFormInstance>()
const result = ref('')
async function checkValidity() {
const valid = await formRef.value?.validate()
result.value = valid ? 'All fields valid ✓' : 'Fix the highlighted fields'
}
</script>
<template>
<GrForm ref="formRef" :model="model" :rules="rules" class="grid max-w-md gap-4">
<GrFormField name="username" label="Username">
<GrInput v-model="model.username" placeholder="ada" />
</GrFormField>
<div class="grid gap-4 sm:grid-cols-2">
<GrFormField name="country" label="Country">
<GrSelect v-model="model.country" :options="countries" placeholder="Select…" aria-label="Country" />
</GrFormField>
<GrFormField name="framework" label="Framework">
<GrAutocomplete v-model="model.framework" :options="frameworks" placeholder="Search…" aria-label="Framework" />
</GrFormField>
</div>
<GrFormField name="password" label="Password" hint="At least 8 characters">
<GrInput v-model="model.password" type="password" />
</GrFormField>
<GrFormField name="confirm" label="Confirm password">
<GrInput v-model="model.confirm" type="password" />
</GrFormField>
<div class="flex items-center gap-3">
<GrButton type="button" @click="checkValidity">
Validate
</GrButton>
<span class="text-sm text-[var(--gr-muted-fg)]">{{ result }}</span>
</div>
</GrForm>
</template>Правило может иметь trigger: "blur" | "change" | "submit"; правило без триггера срабатывает на любом. Дефолтные сообщения (gr.form.*) локализованы (en/ru/es) и перекрываются rule.message.
Свой контрол и свой валидатор
Свой контрол интегрируется в форму так же, как встроенные: он читает контекст GrFormField через useGrFormFieldContext() (id / aria-describedby / aria-invalid / required) и работает с v-model. Правило brandColor использует кастомный validator, возвращающий строку-ошибку для невалидного hex-цвета.
<!-- CustomColorInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useGrFormFieldContext } from '@feugene/granularity'
const model = defineModel<string>({ default: '' })
// Кастомный контрол сам подключается к GrFormField через контекст: id (связка с
// label `for`), aria-describedby (hint + error), aria-invalid и aria-required —
// ровно так же, как это делают встроенные GrInput / GrSelect / GrAutocomplete.
const field = useGrFormFieldContext()
const invalid = computed(() => Boolean(field?.invalid.value))
const isHex = computed(() => /^#[0-9a-f]{6}$/i.test(model.value))
</script>
<template>
<div class="flex items-center gap-2">
<span
class="h-9 w-9 shrink-0 rounded-lg border border-[var(--gr-brd)]"
:style="{ background: isHex ? model : 'transparent' }"
/>
<input
:id="field?.id.value"
v-model="model"
:aria-describedby="field?.describedById.value"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
placeholder="#3b82f6"
class="h-9 w-full rounded-lg border bg-[var(--gr-bg)] px-3 text-sm outline-none focus:ring-2 focus:ring-[var(--gr-primary)]/40"
:class="invalid ? 'border-[var(--gr-danger)]' : 'border-[var(--gr-brd)]'"
>
</div>
</template>
<!-- GrFormCustomControlDemo.vue -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { GrButton, GrForm, GrFormField, GrInput, type GrFormInstance, type GrFormRules } from '@feugene/granularity'
import CustomColorInput from './CustomColorInput.vue'
const model = reactive({ label: '', brandColor: '' })
// Свой валидатор: возвращает true (ок) или строку с текстом ошибки.
function isHexColor(value: unknown) {
return /^#[0-9a-f]{6}$/i.test(String(value)) || 'Use a 6-digit hex color, e.g. #3b82f6'
}
const rules: GrFormRules = {
label: [{ required: true, min: 2 }],
brandColor: [{ required: true }, { validator: isHexColor }],
}
const formRef = ref<GrFormInstance>()
const saved = ref('')
function onSubmit() {
saved.value = `Saved “${model.label}” · ${model.brandColor}`
}
</script>
<template>
<GrForm
ref="formRef"
:model="model"
:rules="rules"
class="grid max-w-sm gap-4"
@submit="onSubmit"
>
<GrFormField name="label" label="Label">
<GrInput v-model="model.label" placeholder="Primary brand" />
</GrFormField>
<GrFormField name="brandColor" label="Brand color" hint="Custom control — validated like any GrInput">
<CustomColorInput v-model="model.brandColor" />
</GrFormField>
<div class="flex gap-2">
<GrButton type="submit">
Save
</GrButton>
<GrButton variant="secondary" type="button" @click="formRef?.resetFields()">
Reset
</GrButton>
</div>
<p v-if="saved" class="text-sm text-[var(--gr-success)]">
{{ saved }}
</p>
</GrForm>
</template>Тот же приём работает и для async-валидатора (например, проверка на сервере): validator может вернуть Promise. Любой контрол, который читает useGrFormFieldContext(), автоматически получает id/aria и попадает в валидацию.
Форма правки: снимок, «изменено» и асинхронное правило
Данные приходят после монтирования, поэтому форма пересниает снимок через setSnapshot() — иначе «Сбросить» вернул бы пустоту, какой модель была до ответа сервера. isDirty блокирует кнопки, disabled выключает всю форму на время отправки, а асинхронное правило показывает состояние проверки вместо молчания. Поле «Имя» обязательно пропом required, без записи в rules, — и submit это проверяет.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { GrButton, GrForm, GrFormField, GrInput } from '@feugene/granularity'
import type { GrFormInstance, GrFormRules } from '@feugene/granularity'
type FormInstance = GrFormInstance
const form = ref<FormInstance>()
const model = reactive<Record<string, unknown>>({ name: '', login: '' })
const loaded = ref(false)
const saving = ref(false)
const status = ref('—')
// Логин проверяется «на сервере»: пока ответ не пришёл, поле показывает
// состояние проверки, а не молчит.
const rules: GrFormRules = {
login: [{
async validator(value) {
await new Promise(resolve => setTimeout(resolve, 900))
return String(value).trim() === 'taken' ? 'Этот логин уже занят' : true
},
}],
}
async function load() {
status.value = 'Загружаем…'
await new Promise(resolve => setTimeout(resolve, 500))
Object.assign(model, { name: 'Алан Тьюринг', login: 'alan' })
// Снимок из `setup` был снят с пустой модели: без пересъёмки «Сбросить»
// вернул бы форму к пустоте, а не к загруженным данным.
form.value?.setSnapshot()
loaded.value = true
status.value = 'Данные загружены'
}
async function save() {
saving.value = true
status.value = 'Сохраняем…'
await new Promise(resolve => setTimeout(resolve, 800))
form.value?.setSnapshot()
saving.value = false
status.value = 'Сохранено'
}
</script>
<template>
<div class="grid gap-3">
<div class="flex flex-wrap items-center gap-3">
<GrButton variant="outline" :disabled="loaded" @click="load">
Загрузить данные
</GrButton>
<span class="text-xs text-[var(--gr-muted-fg)]">{{ status }}</span>
</div>
<GrForm
ref="form"
:model="model"
:rules="rules"
:disabled="saving"
class="grid gap-3"
@submit="save"
>
<!-- Обязательность объявлена полем, а не правилом: submit её всё равно
проверит. -->
<GrFormField name="name" label="Имя" required>
<GrInput v-model="model.name as string" placeholder="Как вас зовут" />
</GrFormField>
<GrFormField name="login" label="Логин" hint="Введите «taken», чтобы увидеть отказ сервера">
<GrInput v-model="model.login as string" placeholder="alan" />
</GrFormField>
<div class="flex flex-wrap items-center gap-3">
<GrButton type="submit" :disabled="!form?.isDirty || saving">
Сохранить
</GrButton>
<GrButton variant="outline" :disabled="!form?.isDirty || saving" @click="form?.resetFields()">
Сбросить
</GrButton>
<span class="text-xs text-[var(--gr-muted-fg)]">
isDirty: {{ String(Boolean(form?.isDirty)) }} · isValid: {{ String(Boolean(form?.isValid)) }}
</span>
</div>
</GrForm>
</div>
</template>Error Banner
showFieldLabels=true, canRetry=false, tone validation=warning, fieldLabels for nice field captions.
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import {
GrButton,
GrCard,
GrFormErrorBanner,
type ResponseErrorInfo,
useResponseError,
} from '@feugene/granularity'
class FakeHttpError extends Error {
isAxiosError = true
response: { status: number, data: unknown, headers?: Record<string, string> }
constructor(status: number, data: unknown, headers?: Record<string, string>) {
super(`Request failed with status ${status}`)
this.name = 'AxiosError'
this.response = { status, data, headers }
}
}
const formClassifier = useResponseError()
const fakeFormError = shallowRef<ResponseErrorInfo | null>(null)
const fieldLabels = computed(() => ({
email: 'E-mail',
password: 'Password',
}))
async function triggerFormDemo() {
const info = await formClassifier.classify(new FakeHttpError(422, {
message: 'Validation error',
errors: {
email: ['Enter a valid email'],
password: ['Password is too short', 'Must contain digits'],
},
}))
fakeFormError.value = info
}
</script>
<template>
<GrCard class="grid gap-3 p-4">
<p class="text-[12px] text-[var(--gr-muted-fg)]">
showFieldLabels=true, canRetry=false, tone validation=warning, fieldLabels for nice field captions.
</p>
<div class="flex flex-wrap gap-2">
<GrButton size="sm" @click="triggerFormDemo">
Simulate 422 form validation
</GrButton>
<GrButton size="sm" variant="outline" @click="fakeFormError = null">
Hide
</GrButton>
</div>
<GrFormErrorBanner
:error="fakeFormError"
:field-labels="fieldLabels"
@dismiss="fakeFormError = null"
/>
</GrCard>
</template>