Урок 22. Решения
⚡ Кратко
Готовые фрагменты: каркас с мобильным меню, метрика со скелетоном, строка таблицы с действиями и чек-лист ревью.
Решение задания 1
// 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
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-label | 18, 21 |
| Диалоги имеют заголовок | 21 |
| Нет жёстких цветов вместо токенов | 13, 19 |
| Тёмная тема проверена на каждом экране | 13 |
| Контраст текста не ниже 4.5:1 | 06 |
Анимации не длиннее 300 мс и уважают prefers-reduced-motion | 14 |
| Повторяющиеся наборы классов вынесены в компоненты | 15 |
Числа выровнены и tabular-nums | 05 |
| Состояния загрузки и пустоты предусмотрены | 14, 22 |