GrFormField

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

Берут, когда у поля есть подпись.

Когда брать

  • у поля есть подпись — она связывается с контролом по 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

PropTypeпо умолчаниюОписание
disabledboolean | undefinedfalseПоле недоступно. Складывается с `disabled` формы по «или».
readonlyboolean | undefinedfalseВсё поле только для чтения: контролы внутри перестают редактироваться.
requiredboolean | undefinedfalseПомечает поле обязательным (маркер `*` + `aria-required` у контрола).
size"xs" | "sm" | "md" | "lg" | undefinedundefinedРазмер подписи, подсказки и ошибки. Не задан — из `GrConfigProvider`, иначе `md`.
namestring | undefinedundefinedИмя поля в модели `GrForm` (в т.ч. dot-path `address.city`). Когда поле внутри `GrForm` и задан `name`, ошибка и признак обязательности берутся из формы по этому имени, а потеря фокуса триггерит валидацию поля.
labelPosition"start" | "top" | undefinedundefinedПодпись сверху (по умолчанию) или сбоку — для плотных форм.
labelstring | undefinedundefined
errorstring | string[] | undefinedundefinedЯвная ошибка (или несколько). Перекрывает ошибку из `GrForm` — ручной режим без формы тоже здесь. Массив нужен там, где источник ошибок один, а претензий несколько: ответ сервера, валидация файла.
labelWidthstring | number | undefinedundefinedШирина колонки подписи при `labelPosition="start"`. Число — пиксели.
forIdstring | undefinedundefinedЯвный id контрола. Если не задан — генерируется автоматически.
hintstring | undefinedundefinedПодсказка под лейблом/над контролом (можно также через слот `#hint`).
showMessageboolean | undefinedtrueПоказывать текст ошибки. `false` — поле остаётся невалидным для контрола и AT (`aria-invalid`), но сообщение не занимает места: так делают в плотных таблицах-формах, где ошибка объясняется сводкой сверху.
labelClassLabelClass | undefinedundefined

Slots

SlotTypeОписание
defaultanyКонтрол поля.
labelanyПодпись вместо пропа `label`.
hintanyПодсказка под контролом вместо пропа `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.

Context
<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="[email protected]" />
    </GrFormField>
  </div>
</template>

GrInput / GrSelect / GrTextarea внутри GrFormField подхватывают контекст автоматически — id/aria прокидывать не нужно.

Базовая подпись и связка через `forId`

Минимальный сценарий показывает, как GrFormField связывает label и control, не навязывая конкретный input-тип.

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

Error
<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 живёт уже более сложная композиция.

I verified rollout steps and stakeholder approvals.
This pattern works well when the label behaves like a section heading instead of a per-input caption.

Custom Label
<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.

Custom Control
<!-- 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 базы

Inline Label
<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>

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