Урок 20. Примеры: пять форм
⚡ Кратко
Пять форм: вход, регистрация с ошибками, настройки с переключателями, выбор тарифа карточками и форма в две колонки.
1. Вход
// 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-4 rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="ex-login-mail">Рабочая почта</label>
<input id="ex-login-mail" type="email" autocomplete="email" placeholder="name@example.com"
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="space-y-1.5">
<div class="flex items-center justify-between">
<label class="text-sm font-medium" for="ex-login-pass">Пароль</label>
<button type="button" class="text-xs text-primary underline-offset-4 hover:underline">Забыли?</button>
</div>
<input id="ex-login-pass" type="password" autocomplete="current-password"
class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">
</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 hover:bg-primary/80">Войти</button>
</form>
Атрибуты autocomplete="email" и
autocomplete="current-password" — не формальность: без них
менеджеры паролей и автозаполнение браузера работают плохо.
2. Регистрация с ошибками
// 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>
// 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>
)
}
<form class="w-full max-w-sm space-y-4 rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="ex-su-mail">Почта</label>
<input id="ex-su-mail" type="email" value="petrov@" aria-invalid="true" aria-describedby="ex-su-mail-err"
class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20">
<div id="ex-su-mail-err" role="alert" class="text-sm text-destructive">Введите корректный адрес</div>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="ex-su-pass">Пароль</label>
<input id="ex-su-pass" type="password" value="12345" aria-invalid="true" aria-describedby="ex-su-pass-err"
class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20">
<div id="ex-su-pass-err" role="alert" class="text-sm text-destructive">Минимум 8 символов</div>
</div>
<div class="flex items-start gap-2">
<input id="ex-su-terms" type="checkbox" class="mt-0.5 size-4 rounded-[4px] accent-primary">
<label class="text-sm text-muted-foreground" for="ex-su-terms">Я принимаю условия использования</label>
</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>
Ошибка связана с полем через aria-describedby: скринридер
прочитает её вместе с названием поля, а не как отдельный текст неизвестно
о чём.
3. Настройки: поля в строку
Для переключателей удобна горизонтальная ориентация: слева описание, справа управляющий элемент.
<div class="w-full max-w-md divide-y divide-border rounded-xl bg-card text-card-foreground ring-1 ring-border">
<label class="flex items-center justify-between gap-4 p-4">
<span class="flex flex-col gap-0.5">
<span class="text-sm font-medium">Уведомления по почте</span>
<span class="text-sm text-muted-foreground">Раз в сутки, дайджестом</span>
</span>
<input 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>
</label>
<label class="flex items-center justify-between gap-4 p-4">
<span class="flex flex-col gap-0.5">
<span class="text-sm font-medium">Двухфакторная аутентификация</span>
<span class="text-sm text-muted-foreground">Код подтверждения при входе</span>
</span>
<input type="checkbox" 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>
</label>
</div>
Вся строка обёрнута в <label> — нажатие в любом месте
переключает настройку. Зона нажатия становится большой, что особенно важно
на мобильных.
4. Выбор тарифа карточками
<div class="grid w-full max-w-2xl gap-3 sm:grid-cols-3">
<label class="cursor-pointer rounded-xl border border-border p-4 transition-colors has-checked:border-primary has-checked:bg-primary/5">
<input type="radio" name="ex-plan" class="sr-only">
<p class="text-sm font-medium">Старт</p>
<p class="mt-1 text-xs text-muted-foreground">Для одного человека</p>
<p class="mt-3 text-lg font-semibold tabular-nums">0 ₽</p>
</label>
<label class="cursor-pointer rounded-xl border border-border p-4 transition-colors has-checked:border-primary has-checked:bg-primary/5">
<input type="radio" name="ex-plan" class="sr-only" checked>
<p class="text-sm font-medium">Команда</p>
<p class="mt-1 text-xs text-muted-foreground">До 10 участников</p>
<p class="mt-3 text-lg font-semibold tabular-nums">990 ₽</p>
</label>
<label class="cursor-pointer rounded-xl border border-border p-4 transition-colors has-checked:border-primary has-checked:bg-primary/5">
<input type="radio" name="ex-plan" class="sr-only">
<p class="text-sm font-medium">Компания</p>
<p class="mt-1 text-xs text-muted-foreground">Без ограничений</p>
<p class="mt-3 text-lg font-semibold tabular-nums">по договору</p>
</label>
</div>
Радиокнопки скрыты через sr-only, но остаются в разметке:
работает клавиатура, стрелки переключают варианты, форма отправляет значение.
5. Форма в две колонки
<form class="grid w-full max-w-2xl gap-4 rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border sm:grid-cols-2">
<div class="space-y-1.5">
<label class="text-sm font-medium" for="ex-first">Имя</label>
<input id="ex-first" class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" value="Ирина">
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium" for="ex-last">Фамилия</label>
<input id="ex-last" class="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" value="Соколова">
</div>
<div class="space-y-1.5 sm:col-span-2">
<label class="text-sm font-medium" for="ex-about">О себе</label>
<textarea id="ex-about" rows="3" class="w-full rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50">Продуктовый аналитик</textarea>
<p class="text-sm text-muted-foreground">Не более 300 символов.</p>
</div>
<div class="flex justify-end gap-2 sm:col-span-2">
<button type="button" class="inline-flex h-8 items-center justify-center rounded-lg px-3 text-sm font-medium hover:bg-muted">Отмена</button>
<button type="button" class="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground">Сохранить</button>
</div>
</form>