GrFormField
Берут, когда у поля есть подпись.
Когда брать
- у поля есть подпись — она связывается с контролом по
idавтоматически, без ручногоfor; - поле показывает ошибку — текст, красная рамка и
aria-invalidприезжают в контрол через контекст; - поле участвует в валидации формы —
nameподключает его кGrForm; - подпись стоит сбоку —
labelPositionиlabelWidthдают горизонтальную раскладку без своей сетки.
Когда взять другое
| Нужно | Берите |
|---|---|
| Правила и блокировка отправки | GrForm |
| Контрол без подписи и ошибки | сам контрол: GrInput, GrSelect, … |
| Блок полей с заголовком | GrFormSection |
| Ошибка относится ко всей форме, а не к полю | GrResponseErrorBanner |
Ошибка объявляется, а не появляется
Контейнер ошибки живёт в DOM всегда и меняет только текст, а aria-describedby
контрола всегда содержит его id. Причина: часть AT не перечитывает описание
после смены самого атрибута — ошибка, добавленная вместе с новым
aria-describedby, могла остаться непрочитанной. Пустой контейнер уходит в
sr-only, поэтому пустой строки в разметке поля не появляется.
error принимает и строку, и массив: у одного поля бывает несколько претензий
(ответ сервера, валидация файла). Внутри формы ошибка берётся из GrForm по
name, явный проп её перекрывает.
showMessage: false оставляет поле невалидным для контрола и AT, но текст не
показывает — для плотных форм, где ошибки объясняет сводка сверху.
Валидация по blur — только при реальном уходе
focusout всплывает и когда фокус переезжает внутри поля: с input на его же
кнопку очистки, между чекбоксами группы. Поле валидируется, только если фокус
ушёл за пределы корня (relatedTarget вне поля или null) — иначе оно
краснело раньше, чем пользователь его дозаполнил.
Связь подписи с контролом
<label for> указывает на id из контекста, а вешает этот id на себя сам
контрол (useGrFormFieldContext()). Если внутри поля такого элемента нет,
компонент в dev-режиме предупреждает в консоль: иначе клик по подписи молча
ничего не делает, а для скринридера связи нет вовсе.
Виджеты с ARIA-ролью (GrCheckbox, GrRadioGroup, GrCheckboxGroup)
<label for> не поддерживают — они берут имя через aria-labelledby на
field.labelId.
Раскладка
labelPosition="start" ставит подпись слева, labelWidth задаёт ширину её
колонки — иначе колонки контролов в форме разъезжаются по длине подписей.
Подсказка и ошибка остаются рядом с контролом: они про него, а не про подпись.
size (xs…lg) масштабирует подпись, подсказку, ошибку и вертикальный ритм;
берётся из GrConfigProvider, если не задан локально.
Слоты
#label, #hint, #error (получает errors: string[]) и default — сам
контрол.
Чего нет
validateStatus (validating/success): у GrForm нет канала состояния
асинхронного правила, и проп был бы чисто ручным — сначала нужен статус на
стороне формы.
Playground 10
Загружается…
<GrFormField />Установка
npm i @feugene/granularityИмпорт
import { GrFormField } from '@feugene/granularity/components/GrFormField'API
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
disabled | boolean | undefined | false | Поле недоступно. Складывается с `disabled` формы по «или». |
readonly | boolean | undefined | false | Всё поле только для чтения: контролы внутри перестают редактироваться. |
required | boolean | undefined | false | Помечает поле обязательным (маркер `*` + `aria-required` у контрола). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | Размер подписи, подсказки и ошибки. Не задан — из `GrConfigProvider`, иначе `md`. |
name | string | undefined | undefined | Имя поля в модели `GrForm` (в т.ч. dot-path `address.city`). Когда поле внутри `GrForm` и задан `name`, ошибка и признак обязательности берутся из формы по этому имени, а потеря фокуса триггерит валидацию поля. |
labelPosition | "start" | "top" | undefined | undefined | Подпись сверху (по умолчанию) или сбоку — для плотных форм. |
label | string | undefined | undefined | — |
error | string | string[] | undefined | undefined | Явная ошибка (или несколько). Перекрывает ошибку из `GrForm` — ручной режим без формы тоже здесь. Массив нужен там, где источник ошибок один, а претензий несколько: ответ сервера, валидация файла. |
labelWidth | string | number | undefined | undefined | Ширина колонки подписи при `labelPosition="start"`. Число — пиксели. |
forId | string | undefined | undefined | Явный id контрола. Если не задан — генерируется автоматически. |
hint | string | undefined | undefined | Подсказка под лейблом/над контролом (можно также через слот `#hint`). |
showMessage | boolean | undefined | true | Показывать текст ошибки. `false` — поле остаётся невалидным для контрола и AT (`aria-invalid`), но сообщение не занимает места: так делают в плотных таблицах-формах, где ошибка объясняется сводкой сверху. |
labelClass | LabelClass | undefined | undefined | — |
Slots
| Slot | Type | Описание |
|---|---|---|
default | any | Контрол поля. |
label | any | Подпись вместо пропа `label`. |
hint | any | Подсказка под контролом вместо пропа `hint`. |
error | { errors: string[]; } | Текст ошибки вместо стандартного. Получает уже разрешённый список. |
Примеры 6
Автоматический id, подсказка, обязательность и ошибка
Поле само генерирует id (связка с label for) и через provide/inject отдаёт контролу aria-describedby (hint + error), aria-invalid и aria-required — без ручного forId. Ошибка анонсируется через role="alert".
We'll never share your email.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrFormField, GrInput } from '@feugene/granularity'
const email = ref('john')
const error = computed(() =>
email.value && !email.value.includes('@') ? 'Enter a valid email address' : undefined,
)
</script>
<template>
<!--
Контрол сам получает id (связка с label `for`), aria-describedby (hint + error),
aria-invalid и aria-required через inject-контекст `GrFormField` — без `forId` вручную.
-->
<div class="grid max-w-sm gap-4">
<GrFormField
label="Email"
required
hint="We'll never share your email."
:error="error"
>
<GrInput v-model="email" type="email" placeholder="you@example.com" />
</GrFormField>
</div>
</template>GrInput / GrSelect / GrTextarea внутри GrFormField подхватывают контекст автоматически — id/aria прокидывать не нужно.
Базовая подпись и связка через `forId`
Минимальный сценарий показывает, как GrFormField связывает label и control, не навязывая конкретный input-тип.
<script setup lang="ts">
import { ref } from 'vue'
import { GrFormField, GrInput } from '@feugene/granularity'
const name = ref('Operations dashboard')
</script>
<template>
<GrFormField label="Workspace name" for-id="workspace-name">
<GrInput id="workspace-name" v-model="name" placeholder="Enter workspace name" />
</GrFormField>
</template>Сообщение проверки рядом с полем
Отдельно документируем ответственность GrFormField за error copy, когда сам control лишь сигнализирует invalid-state.
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrFormField, GrInput } from '@feugene/granularity'
const slug = ref('')
const error = computed(() => {
if (!slug.value)
return 'Slug is required for deploy previews.'
return /^[a-z0-9-]+$/.test(slug.value)
? undefined
: 'Use lowercase latin letters, numbers and dashes only.'
})
</script>
<template>
<GrFormField label="Preview slug" for-id="preview-slug" :error="error">
<GrInput id="preview-slug" v-model="slug" :invalid="Boolean(error)" placeholder="team-dashboard" />
</GrFormField>
</template>Подписи-заголовки через `labelClass`
Компонент можно использовать и как мини-секцию формы: label становится heading-строкой, а внутри slot живёт уже более сложная композиция.
<script setup lang="ts">
import { ref } from 'vue'
import { GrCheckbox, GrFormField } from '@feugene/granularity'
const approvals = ref(false)
</script>
<template>
<GrFormField
label="Release checklist"
label-class="font-semibold uppercase tracking-[0.12em] text-[length:var(--gr-text-xs)] leading-[var(--gr-leading-xs)] text-[var(--gr-fg)]"
>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrCheckbox v-model="approvals">
I verified rollout steps and stakeholder approvals.
</GrCheckbox>
<div class="text-sm text-[var(--gr-muted-fg)]">
This pattern works well when the label behaves like a section heading instead of a per-input caption.
</div>
</div>
</GrFormField>
</template>Свой контрол и своё правило без GrForm
Даже без GrForm поле связывает свой контрол и ошибку: кастомный контрол (звёздный рейтинг) читает контекст через useGrFormFieldContext(), а валидация делается вручную — своя функция-правило вычисляет :error, который GrFormField показывает через role="alert".
Custom control + custom rule, without GrForm.
<!-- StarRatingInput.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useGrFormFieldContext } from '@feugene/granularity'
const model = defineModel<number>({ default: 0 })
// Даже без GrForm кастомный контрол читает контекст GrFormField, чтобы получить
// id (label `for`), aria-describedby (hint + error) и aria-invalid.
const field = useGrFormFieldContext()
const invalid = computed(() => Boolean(field?.invalid.value))
const stars = [1, 2, 3, 4, 5]
</script>
<template>
<div
:id="field?.id.value"
role="radiogroup"
:aria-describedby="field?.describedById.value"
:aria-invalid="invalid || undefined"
:aria-required="field?.required.value || undefined"
class="flex gap-1"
>
<button
v-for="star in stars"
:key="star"
type="button"
role="radio"
:aria-checked="model === star"
:aria-label="`${star} stars`"
class="text-2xl leading-none transition-transform hover:scale-110"
:class="star <= model ? 'text-[var(--gr-warning)]' : 'text-[var(--gr-muted-fg)]'"
@click="model = star"
>
★
</button>
</div>
</template>
<!-- GrFormFieldCustomControlDemo.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrButton, GrFormField } from '@feugene/granularity'
import StarRatingInput from './StarRatingInput.vue'
const rating = ref(0)
const touched = ref(false)
// Кастомное правило без GrForm: валидируем сами и отдаём текст в `:error`.
function validateRating(value: number): string | undefined {
if (value < 1)
return 'Please pick a rating.'
if (value < 3)
return 'We would love at least 3 stars 🙂'
return undefined
}
const error = computed(() => (touched.value ? validateRating(rating.value) : undefined))
function submit() {
touched.value = true
}
</script>
<template>
<div class="grid max-w-sm gap-4">
<GrFormField
label="Satisfaction"
required
hint="Custom control + custom rule, without GrForm."
:error="error"
>
<StarRatingInput v-model="rating" />
</GrFormField>
<div>
<GrButton type="button" @click="submit">
Send feedback
</GrButton>
</div>
</div>
</template>Тот же приём (чтение useGrFormFieldContext()) делает любой контрол совместимым и с GrForm — тогда правило описывается декларативно в rules, а не вручную.
Плотная форма: подпись сбоку, несколько ошибок, размер
labelPosition="start" с labelWidth собирает плотную форму, error принимает массив претензий, а showMessage: false помечает поле невалидным без текста.
Домен или IP базы
<script setup lang="ts">
import { computed, ref } from 'vue'
import { GrFormField, GrInput, GrSegmented, GrSwitch } from '@feugene/granularity'
const size = ref<'sm' | 'md'>('md')
const compact = ref(true)
const host = ref('')
const port = ref('5432')
// Несколько претензий к одному полю: массив вместо склеенной строки.
const hostErrors = computed<string[]>(() => {
const issues: string[] = []
if (!host.value)
issues.push('Хост обязателен')
else if (host.value.includes(' '))
issues.push('Пробелы в хосте недопустимы')
if (host.value.endsWith('.'))
issues.push('Точка в конце — опечатка')
return issues
})
const portError = computed(() => (Number(port.value) > 0 ? undefined : 'Порт — положительное число'))
</script>
<template>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<GrSegmented
v-model="size"
size="sm"
:options="[{ value: 'sm', label: 'size=sm' }, { value: 'md', label: 'size=md' }]"
/>
<GrSwitch v-model="compact" size="sm">
Подпись сбоку
</GrSwitch>
</div>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrFormField
label="Хост"
hint="Домен или IP базы"
:error="hostErrors"
:size="size"
:label-position="compact ? 'start' : 'top'"
:label-width="140"
required
>
<GrInput v-model="host" :size="size" placeholder="db.internal" />
</GrFormField>
<!-- `showMessage: false` — поле остаётся невалидным для контрола и AT,
но текст не занимает места: объяснение живёт в сводке формы. -->
<GrFormField
label="Порт"
:error="portError"
:show-message="false"
:size="size"
:label-position="compact ? 'start' : 'top'"
:label-width="140"
>
<GrInput v-model="port" :size="size" />
</GrFormField>
</div>
</div>
</template>