Урок 22. Решения

📁 Раздел: Итоговый проект ⏱️ Время изучения: ~3–4 часа 🎯 Сложность: Продвинутая

⚡ Кратко

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

Решение задания 1

📄 src/components/app-shell.tsx
// src/components/app-shell.tsx
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { AppSidebar } from "./app-sidebar"

export function AppShell({ children, current }: { children: React.ReactNode; current: string }) {
  return (
    <div className="grid min-h-svh grid-rows-[auto_1fr] bg-background text-foreground lg:grid-cols-[16rem_1fr]">
      <AppSidebar current={current} className="hidden lg:col-start-1 lg:row-span-2 lg:flex" />

      <header className="sticky top-0 z-10 flex items-center gap-3 border-b border-border
                         bg-background/95 px-4 py-2 lg:col-start-2">
        <Sheet>
          <SheetTrigger render={<Button variant="ghost" size="icon-sm" className="lg:hidden" aria-label="Меню" />}>
            ☰
          </SheetTrigger>
          <SheetContent side="left" className="w-64 p-0">
            <SheetTitle className="sr-only">Навигация</SheetTitle>
            <AppSidebar current={current} className="flex h-full" />
          </SheetContent>
        </Sheet>

        <Input placeholder="Поиск" className="min-w-0 flex-1" />
        <Button size="sm">Новый заказ</Button>
      </header>

      <main className="overflow-y-auto p-4 lg:col-start-2 lg:p-6">{children}</main>
    </div>
  )
}

Ключевая деталь: один и тот же AppSidebar используется и в раскладке, и внутри Sheet. Дублировать навигацию не нужно — иначе пункты рано или поздно разойдутся. Заголовок панели скрыт sr-only, но существует: этого требует доступность диалога (урок 21).

Решение задания 2

📄 src/components/metric-card.tsx
// src/components/metric-card.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"

export function MetricCard({ label, value, delta, trend, loading }: MetricProps) {
  if (loading) {
    return (
      <Card>
        <CardHeader>
          <Skeleton className="h-3 w-20" />
          <Skeleton className="h-7 w-28" />
        </CardHeader>
        <CardContent><Skeleton className="h-5 w-14" /></CardContent>
      </Card>
    )
  }

  return (
    <Card>
      <CardHeader>
        <CardDescription>{label}</CardDescription>
        <CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
      </CardHeader>
      <CardContent>
        <Badge variant={trend === "up" ? "secondary" : "destructive"}>
          {trend === "up" ? "▲" : "▼"} {delta}
        </Badge>
      </CardContent>
    </Card>
  )
}

Выручка

1,24 млн

▲ 12%
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
  <div class="rounded-xl bg-card p-4 ring-1 ring-border">
    <div class="h-3 w-20 animate-pulse rounded-md bg-muted"></div>
    <div class="mt-2 h-7 w-28 animate-pulse rounded-md bg-muted"></div>
    <div class="mt-2 h-5 w-14 animate-pulse rounded-md bg-muted"></div>
  </div>
  <div class="rounded-xl bg-card p-4 text-card-foreground ring-1 ring-border">
    <p class="text-xs text-muted-foreground">Выручка</p>
    <p class="mt-1 text-2xl font-semibold tabular-nums">1,24 млн</p>
    <span class="mt-2 inline-flex h-5 items-center rounded-4xl bg-secondary px-2 text-xs font-medium text-secondary-foreground">▲ 12%</span>
  </div>
</div>

Почему скелетон повторяет геометрию: когда данные придут, ничего не сдвинется. Скелетон произвольного размера создаёт «скачок» раскладки — раздражает сильнее, чем пустое место.

Решение задания 3

// src/components/order-row.tsx
<TableRow>
  <TableCell className="font-medium">{order.number}</TableCell>
  <TableCell className="text-muted-foreground">{order.customer}</TableCell>
  <TableCell>
    <Badge variant={order.status === "error" ? "destructive" : "secondary"}>
      {STATUS_LABELS[order.status]}
    </Badge>
  </TableCell>
  <TableCell className="text-right tabular-nums">{formatMoney(order.total)}</TableCell>
  <TableCell className="text-right">
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Действия с заказом ${order.number}`} />}>
        ⋯
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onSelect={() => open(order)}>Открыть</DropdownMenuItem>
        <DropdownMenuItem onSelect={() => duplicate(order)}>Дублировать</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive" onSelect={() => askDelete(order)}>Удалить</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  </TableCell>
</TableRow>

Деталь для доступности: aria-label содержит номер заказа. Иначе на странице окажется двадцать кнопок «Действия», неразличимых для скринридера.

Решение задания 4: чек-лист ревью

ПроверкаУрок
На 320px нет горизонтальной прокрутки11
Сетки перестраиваются на всех брейкпоинтах09, 11
Длинный текст обрезается, иконки не сжимаются08
Все интерактивные элементы достижимы с клавиатуры12
Фокус виден везде07, 12
Кнопки-иконки имеют aria-label18, 21
Диалоги имеют заголовок21
Нет жёстких цветов вместо токенов13, 19
Тёмная тема проверена на каждом экране13
Контраст текста не ниже 4.5:106
Анимации не длиннее 300 мс и уважают prefers-reduced-motion14
Повторяющиеся наборы классов вынесены в компоненты15
Числа выровнены и tabular-nums05
Состояния загрузки и пустоты предусмотрены14, 22