Урок 21. Решения
⚡ Кратко
Готовый диалог, меню строки и разбор выбора компонента.
Решение задания 1
📄 src/components/delete-record-dialog.tsx
// src/components/delete-record-dialog.tsx
import { useState } from "react"
import {
Dialog, DialogClose, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function DeleteRecordDialog({ onConfirm }: { onConfirm: () => Promise<void> }) {
const [busy, setBusy] = useState(false)
const handleDelete = async () => {
setBusy(true)
try {
await onConfirm()
} finally {
setBusy(false)
}
}
return (
<Dialog>
<DialogTrigger render={<Button variant="destructive">Удалить</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>Удалить запись?</DialogTitle>
<DialogDescription>
Запись и связанные файлы будут удалены безвозвратно.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="ghost">Отмена</Button>} />
<Button variant="destructive" disabled={busy} onClick={handleDelete}>
{busy ? "Удаляем…" : "Удалить"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Что здесь важно: заголовок сформулирован вопросом, описание объясняет необратимость, кнопка блокируется на время запроса. Всё поведение — от примитива, вам осталась логика.
Решение задания 2
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" aria-label="Действия со строкой" />}
>
⋯
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>Действия</DropdownMenuLabel>
<DropdownMenuItem onSelect={onOpen}>Открыть</DropdownMenuItem>
<DropdownMenuItem onSelect={onDuplicate}>Дублировать</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={onDelete}>Удалить</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Договор поставки — 2026-114.pdf
2,4 МБ · изменён вчера
<div class="flex w-full max-w-lg items-center justify-between gap-3 rounded-lg bg-card p-3 text-sm text-card-foreground ring-1 ring-border">
<div class="min-w-0">
<p class="truncate font-medium">Договор поставки — 2026-114.pdf</p>
<p class="text-muted-foreground">2,4 МБ · изменён вчера</p>
</div>
<button aria-label="Действия со строкой" class="inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground">⋯</button>
</div>
Почему aria-label обязателен: внутри кнопки
только символ «⋯». Без подписи скринридер прочитает «кнопка» — и всё.
Решение задания 3
| Случай | Компонент | Почему |
|---|---|---|
| (а) фильтры на мобильном | Sheet | много контента, нужна прокрутка и полный экран |
| (б) пояснение к иконке | Tooltip | короткий текст, появляется по наведению и фокусу |
| (в) выход без сохранения | AlertDialog | требует явного решения, нельзя закрыть случайно |
| (г) «Активные / Архив» | Tabs | это разделы одного экрана, а не отдельный слой |
| (д) «Скопировано» | уведомление (Sonner) | сообщение о результате, не требует действия |
Разница между Dialog и AlertDialog:
обычный диалог закрывается кликом по подложке и Esc,
а
AlertDialog требует нажать одну из кнопок. Второй —
для случаев, где случайное закрытие означает потерю данных.