Урок 20. Примеры: пять форм

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

⚡ Кратко

Пять форм: вход, регистрация с ошибками, настройки с переключателями, выбор тарифа карточками и форма в две колонки.

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
// 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
// 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. Форма в две колонки

Не более 300 символов.

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