GrForm
Orchestrates form validation via declarative rules and the GrFormField context.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- there is more than one field and they are validated — the rules are declared by field name rather than hung by hand;
- the fields depend on one another — a validator sees the whole model: matching passwords, “the end date is later than the start”;
- submission is blocked until it is valid — together with scrolling and focus to the first error;
- the form is blocked as a whole —
disabledon the form dims every control inside.
When to take something else
| Need | Take |
|---|---|
| There is a single field and no rules | GrFormField + GrInput |
| Ask for a single value with a window | GrPromptDialog |
| Only the layout and the labels, the checking is on the server | GrFormField |
The controls know nothing about the form: the orchestration is connected through
GrFormField by name. Any existing control therefore enters the validation with no edits
at all — one of your own, written on top of the form-control contract, included.
Being required arrives from two places
A field is required if it has a required rule or if the GrFormField itself carries
the required prop. Both sources take part both in the * marker and in the validation:
the second one used to draw the asterisk while the submit went through with an empty field.
A field with no rules is in that case given an implicit { required: true } — with the
same message from the locale as an explicit rule.
The snapshot and the reset
resetFields() returns the model to the snapshot. The snapshot is taken on mounting, so an
edit form needs to retake it after the data has loaded:
const form = ref<GrFormInstance>()
const data = await api.load()
Object.assign(model, data)
form.value?.setSnapshot()
Without that the “reset” would return the empty object the model was before the server
answered. resetFields(names?) can also do a pointed reset; a key that is not in the
snapshot is deleted rather than turned into undefined. The exception is methods: they
do not enter the snapshot by construction, and deleting them would mean breaking the model
(see “The model may be someone else’s”).
isDirty compares the model with the snapshot, and isValid means “there are no known
errors” — until the first validate() that is not yet “the form is valid” but “nobody has
checked”. Both are available through the ref of the form and in the slot props.
Switching the form off
The disabled of the form travels into the context of the field and further into the
controls: “switch everything off for the duration of the submission” no longer requires
walking the controls one by one. The prop of a control and the prop of a field add to it by
“or” — the way it is already done with readonly.
Files as a rule of the form
The constraints on a file are described where the rest of the rules are — with the file
rule:
const rules: GrFormRules = {
contract: [{
required: true,
file: { accept: '.pdf,application/pdf', maxSizeMb: 1 },
}],
}
| Key | What it limits |
|---|---|
accept | the W3C accept string ('image/*,.pdf') |
extensions | a whitelist of extensions |
mimeTypes | a whitelist of MIME types |
maxSizeMb / maxSizeBytes | the size of one file; if both are set, the smaller one applies |
maxCount | the number of files in the set |
maxTotalSizeMb | the total size of the set |
validators | your own FileValidators, asynchronous ones included — they run after the built-in ones |
The rule has no checks of its own at all: it assembles the same validators from
../file-validation.md that GrFormFile and v-dropzone run on
a choice and on a drop. The text of the error is therefore one as well — it comes from the
validator and is localised with its gr.fileValidation.* key rather than turning into a
separate “invalid file”. rule.message overrides it, as with any other rule.
File and File[] values are checked; everything else the rule lets through — it must not
invent a verdict for a string. Emptiness is dealt with by required.
If there are several problematic files, the field shows the first one — a form field has
one error line, and files are no exception here. A detailed breakdown per file remains the
job of GrFormFile.
GrFormFile itself does not change in the process. The constraints on the field (accept,
limit, validators) are quick feedback: a bad file does not reach the model at all. The
rule of the form is a guarantee at submit: it is visible to validate(), enters the
invalid event and takes part in the scroll to the first error. No duplication appears on
the screen: what the field did not let into the model is not shown to the rule of the form.
The typical division is the constraints in rules and accept on the field as a filter for
the choice dialog.
Asynchronous rules
While a rule is travelling to the server, the field is marked aria-busy and shows a string
from the locale (gr.form.validating) instead of the old error: the error belonged to the
previous value, and keeping it on the screen means lying about the current one.
The list of fields being checked is available as validatingFields — in the expose and in
the slot props.
Overlapping runs of one field are settled by a generation counter: the result of the last
run applies, and a run that was displaced gives the caller the verdict of the one that
displaced it, having waited for it. validate() and submit therefore cannot slip
through on a value nobody has checked yet.
clearValidate() and resetFields() cancel the checks in flight: their answer belongs
to the state before the reset, it will not reach the cleared form, and the field stops being
counted as under validation at once, without waiting for the server.
The model may be someone else's
model is typed with a generic rather than as a dictionary: a model is sometimes an object
of someone else’s library — Inertia’s useForm, a store — where methods of its own lie
beside the fields. It goes in as it is, with no as unknown as, and submit gives it away
with the same type.
const form = useForm({ email: '', password: '' })
<GrForm :model="form" :rules="rules" @submit="form.post('/login')">
The form addresses only the names declared by fields (GrFormField name), so everything
else in the object does not concern it. Two consequences are worth knowing in advance:
resetFields()returns the values of the fields and does not touch the methods. The snapshot is built as a clone, and a clone drops functions — without a separate guard “not in the snapshot” would mean “delete”, and the reset would sweeppost,resetanderrorsoff the object. The external form’s own reset (form.reset()) remains yours in the process: it knows about its service fields, andGrFormdoes not;isDirtyis computed over the whole object. An external form has reactive fields of its own beside the data ones (processing,errors), and the form will see their changes too. If a “the user edited the data” flag is needed, take it from the external form itself.
Submit is asynchronous
Rules are sometimes promises, so validate() always returns a Promise, and submit is
emitted after it resolves. In tests a single nextTick() is not enough:
await wrapper.find('form').trigger('submit')
await flushPromises() // not nextTick: the rules may have gone to the server
expect(onSubmit).toHaveBeenCalled()The events and the API
submit(model)— only when the form is valid;invalid(errors)— a map of messages when the submit did not go through. Without it “the form is invalid” and “nothing happened” look the same;validate(name, valid, message)— the result for a single field.
Imperatively: validate(), validateField(name, trigger?), clearValidate(),
resetFields(), setSnapshot(), scrollToField(name), plus isDirty, isValid,
validatingFields.
The type for a ref is GrFormInstance rather than InstanceType<typeof GrForm>: the form
is generic, and such a component compiles into a function, which has no constructor.
const form = ref<GrFormInstance>()
form.value?.resetFields()
The rules and their messages — ../file-validation.md for files
and GrForm/validation.ts for the rest; the engine is public
(runFieldRules, createGrFormMessageResolver), and GrPromptDialog uses the same one.
Playground 4
Loading…
<GrForm />Install
npm i @feugene/granularityImport
import { GrForm } from '@feugene/granularity/components/GrForm'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelrequired | TModel | — | The reactive object of the form data. The fields are addressed by `name` (`GrFormField`), a dot path included. The type is generic rather than `Record<string, unknown>`: a model is sometimes an object of someone else’s library (Inertia’s `useForm`, a store) where methods of its own lie beside the fields. It does not fit a dictionary, and the consumer would have to cast it through `as unknown as` in every form. |
rules | GrFormRules | undefined | undefined | The validation rules by field name: `{ email: [{ required: true, type: 'email' }] }`. |
validateOnBlur | boolean | undefined | true | Validate a field on the loss of focus. |
validateOnChange | boolean | undefined | false | Validate a field on every change of the value. |
scrollToError | boolean | undefined | true | Scroll to the first invalid field after `validate()`. |
scrollBehavior | ScrollBehavior | undefined | "smooth" | — |
disabled | boolean | undefined | false | Switch the whole form off — typically for the duration of a submission. It reaches the controls through the context of the field, so there is no need to walk them one by one. |
Slots
| Slot | Type | Description |
|---|---|---|
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; } | The fields of the form. The slot props repeat the public API of the instance: the form is available in the template without a `ref`, and therefore without a `nextTick` after mounting. |
Events
| Event | Type | Description |
|---|---|---|
submit | [TModel] | The form passed validation on submit. It gives away `model`. |
validate | [string, boolean, string | undefined] | The result of the validation of a single field. |
invalid | [Record<string, string>] | The submit did not pass validation. Without this event "the form is invalid" and "nothing happened" look the same to the consumer. |
Examples 5
Declarative validation & submit
<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>Any control + custom / async rules
<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>Custom control + custom validator
<!-- 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>Editing form: snapshot, dirty state and async rule
<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>