Из чего состоит поле формы
Любое поле — это четыре смысловые части:
| Часть | Компонент | Зачем |
|---|---|---|
| Подпись | 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>
)
}
Что здесь важно:
zodResolverсвязывает схему с формой;registerподключает поле — значения и валидация работают без состояния в компоненте;errors.emailотдаётся вFieldError, который сам решает, что показать;isSubmittingблокирует кнопку — защита от двойной отправки.
Field с формой (пропсы
errors, data-invalid) и версии
react-hook-form, zod, @hookform/resolvers
— в разделе про формы на ui.shadcn.com. Здесь показан общий принцип.Когда схема не нужна
Форма из двух полей без сложных правил прекрасно живёт на встроенной
валидации браузера: required, type="email",
minlength, pattern. Не тащите библиотеку туда,
где хватает атрибутов — это тоже инженерное решение.