Урок 20. Домашнее задание: форма профиля

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

⚡ Кратко

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

Задание

В проекте курса сделайте страницу «Профиль» с формой:

  • имя и фамилия (обязательные, от двух символов);
  • почта (валидный адрес, autocomplete);
  • должность (необязательное поле);
  • часовой пояс — Select;
  • «О себе» — Textarea с ограничением 300 символов и счётчиком;
  • переключатели уведомлений (Switch) в горизонтальных полях;
  • кнопка сохранения с состоянием загрузки и сообщением об успехе.

Подготовка окружения

cd tailwind-course
npx shadcn@latest add field input textarea select switch button
npm install react-hook-form zod @hookform/resolvers
npm run dev

Пошаговое решение

Шаг 1. Схема

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

export const profileSchema = z.object({
  firstName: z.string().min(2, "Минимум два символа"),
  lastName: z.string().min(2, "Минимум два символа"),
  email: z.email("Введите корректный адрес"),
  position: z.string().optional(),
  timezone: z.string().min(1, "Выберите часовой пояс"),
  about: z.string().max(300, "Не более 300 символов").optional(),
  emailNotifications: z.boolean(),
  weeklyDigest: z.boolean(),
})

export type ProfileValues = z.infer<typeof profileSchema>

Шаг 2. Разметка полей

Каждое поле — Field с подписью, при необходимости подсказкой и FieldError. Группы разделите FieldSet с FieldLegend: «Личные данные» и «Уведомления».

Шаг 3. Счётчик символов

const about = watch("about") ?? ""

<FieldDescription>
  {about.length} / 300 символов
</FieldDescription>

Счётчик — не украшение: без него ограничение обнаруживается только после отправки.

Шаг 4. Отправка и результат

На время запроса блокируйте кнопку, после успеха покажите сообщение и переведите на него фокус. При ошибке сервера покажите её в форме, а не в консоли.

Как проверить

  • Форма проходится только клавиатурой от начала до конца.
  • Каждая ошибка объясняет, что сделать.
  • Менеджер паролей корректно подставляет почту.
  • Двойной клик по кнопке не отправляет форму дважды.
  • Форма читаема в тёмной теме.

Ориентир

Личные данные

21 / 300 символов

Уведомления
<form class="w-full max-w-lg space-y-5 rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
  <fieldset class="space-y-4">
    <legend class="text-sm font-medium">Личные данные</legend>
    <div class="grid gap-4 sm:grid-cols-2">
      <div class="space-y-1.5">
        <label class="text-sm font-medium" for="hw-first">Имя</label>
        <input id="hw-first" value="Ирина" 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>
      <div class="space-y-1.5">
        <label class="text-sm font-medium" for="hw-last">Фамилия</label>
        <input id="hw-last" value="С" aria-invalid="true" aria-describedby="hw-last-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="hw-last-err" role="alert" class="text-sm text-destructive">Минимум два символа</div>
      </div>
    </div>
    <div class="space-y-1.5">
      <label class="text-sm font-medium" for="hw-about">О себе</label>
      <textarea id="hw-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">21 / 300 символов</p>
    </div>
  </fieldset>
  <fieldset class="space-y-3">
    <legend class="text-sm font-medium">Уведомления</legend>
    <label class="flex items-center justify-between gap-4">
      <span class="text-sm">Письма о новых отчётах</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>
  </fieldset>
  <div class="flex justify-end gap-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>