GrForm

Пакет: @feugene/granularityядроГруппа: Формы

Берут, когда полей больше одного и они проверяются.

Когда брать

  • полей больше одного и они проверяются — правила объявляются по имени поля, а не развешиваются руками;
  • поля зависят друг от друга — валидатор видит всю модель: совпадение паролей, «дата конца позже начала»;
  • отправка блокируется до валидности — вместе со скроллом и фокусом к первой ошибке;
  • форма блокируется целиком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 },
  }],
}
КлючЧто ограничивает
acceptW3C-строка 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.

Тип для refGrFormInstance, а не 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

PropTypeпо умолчаниюОписание
modelобязательныйTModelРеактивный объект данных формы. Поля адресуются по `name` (`GrFormField`), в т.ч. dot-path. Тип дженерик, а не `Record<string, unknown>`: моделью бывает объект чужой библиотеки (`useForm` Inertia, стор), у которого рядом с полями лежат собственные методы. Под словарь он не подходит, и потребителю пришлось бы приводить его через `as unknown as` в каждой форме.
rulesGrFormRules | undefinedundefinedПравила валидации по имени поля: `{ email: [{ required: true, type: 'email' }] }`.
validateOnBlurboolean | undefinedtrueВалидировать поле при потере фокуса.
validateOnChangeboolean | undefinedfalseВалидировать поле при каждом изменении значения.
scrollToErrorboolean | undefinedtrueСкроллить к первому невалидному полю после `validate()`.
scrollBehaviorScrollBehavior | undefined"smooth"
disabledboolean | undefinedfalseВыключить форму целиком — типично на время отправки. Доезжает до контролов через контекст поля, поэтому обходить их по одному не нужно.

Slots

SlotTypeОписание
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

EventTypeОписание
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 из контекста поля.

At least 8 characters

Validation
<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() скроллит к первому невалидному полю.

At least 8 characters

Mixed Controls
<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-цвета.

Custom control — validated like any GrInput

Custom Control
<!-- 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 это проверяет.

Введите «taken», чтобы увидеть отказ сервера

isDirty: false · isValid: false

Editing
<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.

Error Banner
<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>

Документация компонентаВсе компоненты