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

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

⚡ Кратко

Готовый диалог, меню строки и разбор выбора компонента.

Решение задания 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 требует нажать одну из кнопок. Второй — для случаев, где случайное закрытие означает потерю данных.