Урок 20. Теория: Field, состояния, валидация

📁 Раздел: shadcn/ui ⏱️ Время изучения: ~55 мин 🎯 Сложность: Продвинутая

⚡ Кратко

Обвязка поля — это Field с частями: FieldLabel, FieldDescription, FieldError. Она задаёт раскладку и связи, а состояние ошибки приходит атрибутами aria-invalid и data-invalid. Схема валидации (zod) описывает правила один раз и даёт типы для TypeScript.

Из чего состоит поле формы

Любое поле — это четыре смысловые части:

ЧастьКомпонентЗачем
ПодписьFieldLabelчто вводим; клик по подписи ставит курсор в поле
Управляющий элементInput, Select, Checkbox…собственно ввод
ПодсказкаFieldDescriptionформат, ограничения, зачем это нужно
ОшибкаFieldErrorчто именно не так и как исправить

Обёртка Field расставляет их по вертикали (или в строку — см. orientation), а FieldGroup задаёт ритм между полями.

// src/components/login-form.tsx
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

export function LoginForm() {
  return (
    <form className="w-full max-w-sm">
      <FieldGroup>
        <Field>
          <FieldLabel htmlFor="email">Рабочая почта</FieldLabel>
          <Input id="email" type="email" placeholder="name@example.com" required />
          <FieldDescription>Используем её для входа и уведомлений.</FieldDescription>
        </Field>

        <Field data-invalid={true}>
          <FieldLabel htmlFor="password">Пароль</FieldLabel>
          <Input id="password" type="password" aria-invalid />
          <FieldError>Пароль должен быть не короче 8 символов</FieldError>
        </Field>

        <Button type="submit">Войти</Button>
      </FieldGroup>
    </form>
  )
}

Используем её для входа и уведомлений.

<form class="w-full max-w-sm space-y-6">
  <div role="group" class="flex w-full flex-col gap-2">
    <label class="flex w-fit items-center gap-2 text-sm leading-snug font-medium" for="th-mail">Рабочая почта</label>
    <input id="th-mail" type="email" placeholder="name@example.com"
           class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
    <p class="text-sm leading-normal text-muted-foreground">Используем её для входа и уведомлений.</p>
  </div>
  <div role="group" class="flex w-full flex-col gap-2 text-destructive">
    <label class="flex w-fit items-center gap-2 text-sm leading-snug font-medium" for="th-pass">Пароль</label>
    <input id="th-pass" type="password" value="123" aria-invalid="true"
           class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20">
    <div role="alert" class="text-sm text-destructive">Пароль должен быть не короче 8 символов</div>
  </div>
  <button type="button" class="inline-flex h-8 w-full items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground">Войти</button>
</form>

Поле формы — как строка в бумажной анкете: есть название графы, место для ответа, примечание мелким шрифтом и красная пометка инспектора. Уберите название — человек не поймёт, что писать. Уберите пометку — не поймёт, почему анкету вернули.

Состояние ошибки живёт в атрибутах

Это ключевая идея. Не «добавить класс красной рамки», а выставить атрибут — оформление подтянется само:

<Input aria-invalid={!!errors.email} />
<Field data-invalid={!!errors.email}>…</Field>

Внутри компонента уже описаны варианты aria-invalid:border-destructive и aria-invalid:ring-destructive/20, а Field по data-invalid красит подпись. Плюс — скринридер объявит поле некорректным, потому что aria-invalid это стандартный атрибут, а не выдумка библиотеки.

Связи, о которых нельзя забывать

  • htmlFor у подписи = id у поля — клик по подписи фокусирует поле;
  • aria-describedby связывает поле с подсказкой и текстом ошибки — скринридер прочитает их вместе с полем;
  • role="alert" у сообщения об ошибке — оно будет объявлено сразу после появления;
  • required и aria-required — обязательность поля.
Компоненты реестра выставляют часть этих связей автоматически, но htmlFor/id вы задаёте сами. Это первое, что стоит проверять в code review формы.

Элементы ввода

<div class="grid max-w-md gap-4">
  <div class="flex flex-col gap-2">
    <label class="text-sm font-medium" for="in-text">Input</label>
    <input id="in-text" placeholder="Обычное поле" class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
  </div>
  <div class="flex flex-col gap-2">
    <label class="text-sm font-medium" for="in-area">Textarea</label>
    <textarea id="in-area" rows="3" placeholder="Многострочный ввод" class="w-full rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"></textarea>
  </div>
  <div class="flex items-center gap-2">
    <input id="in-check" type="checkbox" checked class="size-4 rounded-[4px] accent-primary">
    <label class="text-sm font-medium" for="in-check">Checkbox — согласие</label>
  </div>
  <div class="flex items-center justify-between">
    <label class="text-sm font-medium" for="in-switch">Switch — уведомления</label>
    <input id="in-switch" type="checkbox" checked class="peer sr-only">
    <span class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full bg-input transition-colors peer-checked:bg-primary">
      <span class="ml-0.5 size-4 rounded-full bg-background transition-transform peer-checked:translate-x-4"></span>
    </span>
  </div>
</div>

Визуально это те же компоненты реестра; в демонстрации использованы обычные элементы, чтобы пример работал на статической странице.

Валидация: схема как источник правды

Правила формы описываются один раз схемой, а из неё выводятся и проверки, и типы TypeScript:

// src/lib/schemas.ts
import { z } from "zod"

export const signUpSchema = z
  .object({
    email: z.email("Введите корректный адрес"),
    password: z.string().min(8, "Минимум 8 символов"),
    confirm: z.string(),
    plan: z.enum(["start", "team", "company"]),
    terms: z.literal(true, { message: "Нужно принять условия" }),
  })
  .refine((data) => data.password === data.confirm, {
    message: "Пароли не совпадают",
    path: ["confirm"],
  })

export type SignUpValues = z.infer<typeof signUpSchema>

Плюсы подхода:

  • правила не размазаны по обработчикам — они в одном месте;
  • z.infer даёт тип значений формы: поля не разъедутся с типами;
  • ту же схему можно использовать на сервере — правила не разойдутся;
  • сообщения об ошибках заданы рядом с правилом.

Подключение react-hook-form

// src/components/sign-up-form.tsx
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"

import { signUpSchema, type SignUpValues } from "@/lib/schemas"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

export function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignUpValues>({ resolver: zodResolver(signUpSchema) })

  const onSubmit = async (values: SignUpValues) => {
    await api.signUp(values)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-sm">
      <FieldGroup>
        <Field data-invalid={!!errors.email}>
          <FieldLabel htmlFor="email">Почта</FieldLabel>
          <Input id="email" type="email" aria-invalid={!!errors.email} {...register("email")} />
          <FieldError errors={[errors.email]} />
        </Field>

        <Field data-invalid={!!errors.password}>
          <FieldLabel htmlFor="password">Пароль</FieldLabel>
          <Input id="password" type="password" aria-invalid={!!errors.password} {...register("password")} />
          <FieldError errors={[errors.password]} />
        </Field>

        <Button type="submit" disabled={isSubmitting}>
          {isSubmitting ? "Отправляем…" : "Создать аккаунт"}
        </Button>
      </FieldGroup>
    </form>
  )
}

Что здесь важно:

  1. zodResolver связывает схему с формой;
  2. register подключает поле — значения и валидация работают без состояния в компоненте;
  3. errors.email отдаётся в FieldError, который сам решает, что показать;
  4. isSubmitting блокирует кнопку — защита от двойной отправки.
⚠️ Проверить по документации: точный способ связывания Field с формой (пропсы errors, data-invalid) и версии react-hook-form, zod, @hookform/resolvers — в разделе про формы на ui.shadcn.com. Здесь показан общий принцип.

Когда схема не нужна

Форма из двух полей без сложных правил прекрасно живёт на встроенной валидации браузера: required, type="email", minlength, pattern. Не тащите библиотеку туда, где хватает атрибутов — это тоже инженерное решение.