Урок 18. Домашнее задание: компоненты по канону

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

⚡ Кратко

Написать два собственных компонента по канону реестра — Kbd и Alert — и расширить Button фирменным вариантом.

Задание

  • расширить Button: вариант brand и размер xl;
  • написать Kbd с двумя размерами;
  • написать Alert из подкомпонентов (Alert, AlertTitle, AlertDescription) с вариантами info, success, warning, danger;
  • все компоненты: data-slot, cva, cn(), семантические токены вместо конкретных цветов;
  • проверить работу в обеих темах.

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

cd tailwind-course
npm run dev

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

Шаг 1. Изучите образец

Откройте button.tsx и держите его рядом: ваши компоненты должны выглядеть так же по структуре — импорты, cva, функция, экспорт.

Шаг 2. Kbd

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

Шаг 3. Alert из частей

📄 src/components/ui/alert.tsx
// src/components/ui/alert.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const alertVariants = cva(
  "relative grid w-full grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border p-4 text-sm " +
    "[&_svg]:size-4 [&_svg]:translate-y-0.5",
  {
    variants: {
      variant: {
        info: "border-border bg-card text-card-foreground",
        success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
        warning: "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300",
        danger: "border-destructive/30 bg-destructive/10 text-destructive",
      },
    },
    defaultVariants: { variant: "info" },
  }
)

export function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
  return <div data-slot="alert" role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
}

export function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="alert-title" className={cn("col-start-2 font-medium", className)} {...props} />
}

export function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
  return <div data-slot="alert-description" className={cn("col-start-2 text-sm opacity-90", className)} {...props} />
}

Обратите внимание на role="alert": сообщение будет объявлено скринридером при появлении.

Шаг 4. Проверка в двух темах

Переключите тему и убедитесь, что все варианты читаемы. Если пришлось добавлять dark: — подумайте, нельзя ли выразить цвет через прозрачность (bg-emerald-500/10), которая работает в обеих темах.

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

  • Каждый компонент принимает className и корректно его сливает.
  • У каждой части есть data-slot.
  • TypeScript подсказывает варианты.
  • В обеих темах контраст текста достаточный.

Ориентир

<div class="space-y-3">
  <div role="alert" class="grid w-full grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border border-border bg-card p-4 text-sm text-card-foreground">
    <span class="translate-y-0.5">ℹ️</span>
    <div class="col-start-2 font-medium">Обновление доступно</div>
    <div class="col-start-2 text-sm opacity-90">Версия 4.3 уже вышла.</div>
  </div>
  <div role="alert" class="grid w-full grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm text-emerald-800 dark:text-emerald-300">
    <span class="translate-y-0.5">✅</span>
    <div class="col-start-2 font-medium">Сохранено</div>
    <div class="col-start-2 text-sm opacity-90">Изменения применены.</div>
  </div>
  <div role="alert" class="grid w-full grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
    <span class="translate-y-0.5">⛔</span>
    <div class="col-start-2 font-medium">Не удалось сохранить</div>
    <div class="col-start-2 text-sm opacity-90">Проверьте соединение и повторите.</div>
  </div>
</div>