Урок 15. Примеры: от разметки к компоненту

📁 Раздел: Организация кода ⏱️ Время изучения: ~55 мин 🎯 Сложность: Средняя

⚡ Кратко

Путь от повторяющейся разметки к компоненту с вариантами: бейдж, кнопка, поле ввода и карточка.

1. Было: три одинаковых бейджа

<span class="rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Готово</span>
<span class="rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800">Ожидает</span>
<span class="rounded-full bg-rose-100 px-2.5 py-0.5 text-xs font-medium text-rose-800">Ошибка</span>

2. Стало: компонент с вариантами

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

const badgeVariants = cva(
  "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
  {
    variants: {
      tone: {
        neutral: "bg-slate-100 text-slate-700",
        success: "bg-emerald-100 text-emerald-800",
        warning: "bg-amber-100 text-amber-800",
        danger: "bg-rose-100 text-rose-800",
      },
    },
    defaultVariants: { tone: "neutral" },
  }
)

type BadgeProps = React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>

export function Badge({ className, tone, ...props }: BadgeProps) {
  return <span className={cn(badgeVariants({ tone }), className)} {...props} />
}
<Badge tone="success">Готово</Badge>
<Badge tone="warning">Ожидает</Badge>
<Badge tone="danger">Ошибка</Badge>
<Badge>Черновик</Badge>
Готово Ожидает Ошибка Черновик
<div class="flex flex-wrap gap-2">
  <span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Готово</span>
  <span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-800">Ожидает</span>
  <span class="inline-flex items-center rounded-full bg-rose-100 px-2.5 py-0.5 text-xs font-medium text-rose-800">Ошибка</span>
  <span class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">Черновик</span>
</div>

3. Кнопка со всеми вариантами и размерами

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

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors " +
    "focus-visible:outline-2 focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-slate-900 text-white hover:bg-slate-700",
        outline: "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50",
        ghost: "text-slate-700 hover:bg-slate-100",
        destructive: "bg-rose-600 text-white hover:bg-rose-700",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-9 px-4",
        lg: "h-11 px-6 text-base",
      },
    },
    defaultVariants: { variant: "default", size: "md" },
  }
)

type ButtonProps = React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
<div class="space-y-3">
  <div class="flex flex-wrap items-center gap-3">
    <button class="inline-flex h-8 items-center justify-center rounded-md bg-slate-900 px-3 text-xs font-medium text-white">sm</button>
    <button class="inline-flex h-9 items-center justify-center rounded-md bg-slate-900 px-4 text-sm font-medium text-white">md</button>
    <button class="inline-flex h-11 items-center justify-center rounded-md bg-slate-900 px-6 text-base font-medium text-white">lg</button>
  </div>
  <div class="flex flex-wrap items-center gap-3">
    <button class="inline-flex h-9 items-center justify-center rounded-md border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700">outline</button>
    <button class="inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium text-slate-700 hover:bg-slate-100">ghost</button>
    <button class="inline-flex h-9 items-center justify-center rounded-md bg-rose-600 px-4 text-sm font-medium text-white">destructive</button>
    <button class="inline-flex h-9 items-center justify-center rounded-md bg-slate-900 px-4 text-sm font-medium text-white opacity-50" disabled>disabled</button>
  </div>
</div>

4. Почему нужен tailwind-merge

// без merge: в атрибуте окажутся оба класса
<button className={`px-4 py-2 bg-slate-900 ${className}`} />
// <Button className="px-8" /> → "px-4 py-2 bg-slate-900 px-8" — результат зависит от порядка в CSS

// с merge: остаётся только переданный снаружи
<button className={cn("px-4 py-2 bg-slate-900", className)} />
// → "py-2 bg-slate-900 px-8"
Это не косметика. Без cn() «переопределить отступ снаружи» работает случайным образом: иногда получается, иногда нет — в зависимости от того, какая утилита оказалась ниже в сгенерированном CSS.

5. Поле ввода с состоянием ошибки

// src/components/ui/input.tsx
import { cn } from "@/lib/utils"

type InputProps = React.ComponentProps<"input"> & { invalid?: boolean }

export function Input({ className, invalid, ...props }: InputProps) {
  return (
    <input
      aria-invalid={invalid || undefined}
      className={cn(
        "w-full rounded-md border border-slate-300 px-3 py-2 text-sm",
        "placeholder:text-slate-400 focus:ring-2 focus:ring-sky-500/30 focus:outline-none",
        "aria-invalid:border-rose-500 aria-invalid:bg-rose-50",
        className
      )}
      {...props}
    />
  )
}
<div class="max-w-sm space-y-2">
  <input class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm placeholder:text-slate-400 focus:ring-2 focus:ring-sky-500/30 focus:outline-none" placeholder="Обычное состояние">
  <input aria-invalid="true" value="Неверное значение" class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm aria-invalid:border-rose-500 aria-invalid:bg-rose-50">
</div>

Состояние ошибки описано вариантом aria-invalid:, а не отдельным набором классов: атрибут одновременно и оформляет, и сообщает о проблеме вспомогательным технологиям.

6. Своя утилита вместо @apply

/* src/index.css */
@import "tailwindcss";

/* своя утилита: работает с вариантами, как встроенные */
@utility scrollbar-thin {
  scrollbar-width: thin;
  scrollbar-color: var(--color-slate-400) transparent;
}
<!-- работает с любыми вариантами, как встроенная утилита -->
<div class="scrollbar-thin max-h-64 overflow-y-auto hover:scrollbar-thin">…</div>