UI Kit и дизайн-токены

Из M2: мы заложили FSD, где shared — слой переиспользуемых компонентов. Теперь строим настоящий UI kit: дизайн-токены, базовые компоненты, документация. Структура — с прицелом на вынесение в отдельный пакет.

Перед началом: Модули 1–10. Инструменты: CSS Variables, Storybook. Время: ~8 ч.

Канон. Полная дисциплина UI Kit — публикация npm-пакета, exports-карта, size-limit, Storybook-галерея как единственный канон, измеримые KPI — в курсе «UI Kit и мессенджер». Здесь — рабочий минимум, которого достаточно для этого приложения и который не противоречит канону: токены → примитивы → составные компоненты, нейтральность компонентов, слой shared.


🔥 Инцидент. В чате 5 видов кнопок, 3 цвета primary, 2 шрифта. Дизайнер просит «сделать как в макете», но макет обновился, а код — нет. Нужен UI Kit: токены (colors, spacing, typography) → примитивы (Button, Input, Avatar) → молекулы (ChatMessage, InputPanel). Единый источник правды для всей команды.


📖 Теория

Зачем нужен UI kit

Без UI kit: каждый компонент пишет <input className="input"> или <button className="btn-primary">. Стиили дублируются, компоненты выглядят по-разному, переиспользовать между проектами невозможно.

С UI kit: стандартизированные <Button>, <Input>, <Loader>, <Toast>, основанные на дизайн-токенах. Один источник правды — переиспользуется в любом проекте.

Дизайн-токены

Базовый слой абстракции: цвет, типографика, отступы, скругления — как CSS-переменные.

css
/* shared/ui/tokens.css — единый источник дизайн-системы */
:root {
  /* Colors */
  --c-bg:         #0d1117;
  --c-surface:    #161b22;
  --c-surface-2:  #21262d;
  --c-text:       #c9d1d9;
  --c-text-muted: #8b949e;
  --c-primary:    #2563eb;
  --c-primary-hover: #1d4ed8;
  --c-danger:     #f85149;
  --c-success:    #3fb950;
  --c-border:     #30363d;

  /* Typography */
  --f-size-sm:  0.85rem;
  --f-size-md:  1rem;
  --f-size-lg:  1.25rem;
  --f-family:   system-ui, -apple-system, sans-serif;

  /* Spacing */
  --s-1: 4px;
  --s-2: 8px;
  --s-3: 12px;
  --s-4: 16px;
  --s-6: 24px;
  --s-8: 32px;

  /* Radius */
  --r-sm: 6px;
  --r-md: 8px;
  --r-lg: 12px;
  --r-full: 9999px;

  /* Shadows */
  --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
  --shadow-md: 0 4px 12px rgba(0,0,0,0.12);
}

Эти токены — уже по сути есть в styles.css проекта (как --bg, --surface, --accent). UI kit формализует их и делает переиспользуемыми.

Структура UI kit в FSD

src/shared/
├── ui/                        ← переиспользуемые компоненты
│   ├── Button/
│   │   ├── Button.tsx
│   │   ├── Button.module.css
│   │   └── index.ts           ← публичный API
│   ├── Input/
│   │   ├── Input.tsx
│   │   ├── Input.module.css
│   │   └── index.ts
│   ├── Loader/
│   │   └── ...
│   ├── Toast/
│   │   └── ...
│   ├── Badge/
│   │   └── ...
│   └── index.ts               ← barrel: экспортирует все компоненты
├── tokens.css                 ← дизайн-токены
└── index.ts                   ← публичный API всего shared

Каждый компонент:

  • Имеет index.ts (публичный API) — другие слои импортируют только отсюда.
  • Не зависит от бизнес-логики, стора, API.
  • Принимает стандартные props (variant, size, disabled, className, children).

Базовые компоненты

Button

tsx
// shared/ui/Button/Button.tsx
import { type ButtonHTMLAttributes } from "react"
import styles from "./Button.module.css"

type Variant = "primary" | "secondary" | "danger" | "ghost"
type Size = "sm" | "md" | "lg"

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant
  size?: Size
  loading?: boolean
}

export function Button({
  variant = "primary",
  size = "md",
  loading = false,
  disabled,
  children,
  className,
  ...rest
}: ButtonProps) {
  return (
    <button
      className={[styles.button, styles[variant], styles[size], className]
        .filter(Boolean).join(" ")}
      disabled={disabled || loading}
      {...rest}
    >
      {loading ? <Loader size="sm" /> : children}
    </button>
  )
}
css
/* shared/ui/Button/Button.module.css */
.button {
  border: none;
  border-radius: var(--r-md);
  cursor: pointer;
  font-family: inherit;
  font-weight: 500;
  transition: all 0.2s;
}
.button:disabled { opacity: 0.4; cursor: not-allowed; }

.primary { background: var(--c-primary); color: #fff; }
.primary:hover:not(:disabled) { background: var(--c-primary-hover); }
.secondary { background: var(--c-surface-2); color: var(--c-text); }
.danger { background: var(--c-danger); color: #fff; }
.ghost { background: transparent; color: var(--c-text-muted); }

.sm { padding: var(--s-1) var(--s-2); font-size: var(--f-size-sm); }
.md { padding: var(--s-2) var(--s-4); font-size: var(--f-size-md); }
.lg { padding: var(--s-3) var(--s-6); font-size: var(--f-size-lg); }

Input

tsx
// shared/ui/Input/Input.tsx
import { type InputHTMLAttributes, forwardRef } from "react"
import styles from "./Input.module.css"

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string
  error?: string
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className, ...rest }, ref) => (
    <div className={styles.wrapper}>
      {label && <label className={styles.label}>{label}</label>}
      <input ref={ref} className={[styles.input, error && styles.error, className]
        .filter(Boolean).join(" ")} {...rest} />
      {error && <span className={styles.errorText}>{error}</span>}
    </div>
  )
)

Loader

tsx
// shared/ui/Loader/Loader.tsx
import styles from "./Loader.module.css"

export function Loader({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
  return <div className={[styles.spinner, styles[size]].join(" ")} />
}
css
/* shared/ui/Loader/Loader.module.css */
.spinner {
  border: 2px solid var(--c-border);
  border-top-color: var(--c-primary);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}
.sm { width: 16px; height: 16px; }
.md { width: 24px; height: 24px; }
.lg { width: 40px; height: 40px; }
@keyframes spin { to { transform: rotate(360deg); } }

Toast (уведомления)

tsx
// shared/ui/Toast/Toast.tsx
import { create } from "zustand"
import styles from "./Toast.module.css"

type ToastType = "success" | "error" | "info"
interface ToastItem { id: string; type: ToastType; message: string }

// Стор для тостов (можно использовать без prop drilling)
const useToastStore = create<{ toasts: ToastItem[]; add: (t: ToastItem) => void; remove: (id: string) => void }>((set) => ({
  toasts: [],
  add: (t) => set((s) => ({ toasts: [...s.toasts, t] })),
  remove: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))

export const toast = {
  success: (msg: string) => useToastStore.getState().add({ id: crypto.randomUUID(), type: "success", message: msg }),
  error: (msg: string) => useToastStore.getState().add({ id: crypto.randomUUID(), type: "error", message: msg }),
  info: (msg: string) => useToastStore.getState().add({ id: crypto.randomUUID(), type: "info", message: msg }),
}

export function ToastContainer() {
  const { toasts, remove } = useToastStore()
  return (
    <div className={styles.container}>
      {toasts.map((t) => (
        <div key={t.id} className={[styles.toast, styles[t.type]].join(" ")}
          onClick={() => remove(t.id)}>
          {t.message}
        </div>
      ))}
    </div>
  )
}

Barrel export (публичный API)

ts
// shared/ui/index.ts
export { Button } from "./Button"
export { Input } from "./Input"
export { Loader } from "./Loader"
export { Badge } from "./Badge"
export { ToastContainer, toast } from "./Toast"

Использование в компонентах:

tsx
// features/auth/ui/LoginForm.tsx
import { Button, Input, toast } from "@/shared/ui"

function LoginForm() {
  return (
    <form onSubmit={handleSubmit}>
      <Input label="Email" type="email" value={email} onChange={...} />
      <Input label="Пароль" type="password" value={password} onChange={...} error={error} />
      <Button type="submit" loading={isLoading}>Войти</Button>
    </form>
  )
}

Раньше: <input className="input" /> + <button className="btn-primary" /> — raw HTML. Теперь: <Input label="..." error="..." /> + <Button loading /> — типизированные компоненты.

Autocomplete (компонент автокомплита)

Автокомплит — composite-компонент: Input + debounce + список результатов + клавиатурная навигация. Это самый полезный UI kit компонент для чата (поиск чатов, пригласить пользователя).

tsx
// shared/ui/Autocomplete/Autocomplete.tsx
import { useState, useEffect, useRef, useCallback } from "react"
import { Input } from "../Input"
import { Loader } from "../Loader"
import styles from "./Autocomplete.module.css"

interface SearchOptions {
  signal?: AbortSignal
}

interface AutocompleteProps<T> {
  value: string
  onChange: (value: string) => void
  onSearch: (query: string, options?: SearchOptions) => Promise<T[]>
  onSelect: (item: T) => void
  renderItem: (item: T, query: string) => React.ReactNode
  getKey: (item: T) => string
  placeholder?: string
  label?: string
  debounceMs?: number
  minQueryLength?: number
  emptyText?: string
  loadingText?: string
}

export function Autocomplete<T>({
  value, onChange, onSearch, onSelect, renderItem, getKey,
  placeholder, label, debounceMs = 300, minQueryLength = 2,
  emptyText = "Начните вводить для поиска",
  loadingText = "Поиск...",
}: AutocompleteProps<T>) {
  const [results, setResults] = useState<T[]>([])
  const [isLoading, setIsLoading] = useState(false)
  const [isOpen, setIsOpen] = useState(false)
  const [highlightIndex, setHighlightIndex] = useState(-1)
  const [error, setError] = useState("")
  const requestIdRef = useRef(0)
  const containerRef = useRef<HTMLDivElement>(null)
  const abortRef = useRef<AbortController | null>(null)

  // Debounced search
  useEffect(() => {
    if (value.trim().length < minQueryLength) {
      setResults([])
      setIsOpen(false)
      return
    }

    // Abort previous in-flight request (user typed again before it returned)
    if (abortRef.current) abortRef.current.abort()
    const controller = new AbortController()
    const signal = controller.signal

    const reqId = ++requestIdRef.current
    setIsLoading(true)
    setError("")

    const timer = setTimeout(async () => {
      try {
        const items = await onSearch(value, { signal })
        if (reqId !== requestIdRef.current) return
        setResults(items)
        setIsOpen(true)
        setHighlightIndex(-1)
      } catch (e) {
        if (signal.aborted) return // Request cancelled by AbortController, not an error
        if (reqId !== requestIdRef.current) return // Obsolete request
        setError(e instanceof Error ? e.message : "Ошибка поиска")
        setResults([])
      } finally {
        if (reqId === requestIdRef.current) setIsLoading(false)
      }
    }, debounceMs)

    return () => {
      clearTimeout(timer)
      controller.abort() // Cleanup: cancel request on unmount or value change
    }
  }, [value, debounceMs, minQueryLength, onSearch])

  // Click outside → close
  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setIsOpen(false)
        if (abortRef.current) abortRef.current.abort()
      }
    }
    document.addEventListener("mousedown", handler)
    return () => document.removeEventListener("mousedown", handler)
  }, [])

  // Keyboard navigation
  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (!isOpen || results.length === 0) return

    if (e.key === "ArrowDown") {
      e.preventDefault()
      setHighlightIndex((i) => Math.min(i + 1, results.length - 1))
    } else if (e.key === "ArrowUp") {
      e.preventDefault()
      setHighlightIndex((i) => Math.max(i - 1, 0))
    } else if (e.key === "Enter" && highlightIndex >= 0) {
      e.preventDefault()
      handleSelect(results[highlightIndex])
    } else if (e.key === "Escape") {
      setIsOpen(false)
      setHighlightIndex(-1)
    }
  }, [isOpen, results, highlightIndex])

  const handleSelect = (item: T) => {
    onSelect(item)
    setIsOpen(false)
    setHighlightIndex(-1)
  }

  return (
    <div ref={containerRef} className={styles.wrapper}>
      <Input
        label={label}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={handleKeyDown}
        onFocus={() => results.length > 0 && setIsOpen(true)}
        placeholder={placeholder}
        error={error}
      />

      {isOpen && (
        <div className={styles.dropdown}>
          {isLoading ? (
            <div className={styles.state}><Loader size="sm" /> {loadingText}</div>
          ) : results.length === 0 ? (
            <div className={styles.state}>{emptyText}</div>
          ) : (
            results.map((item, i) => (
              <div
                key={getKey(item)}
                className={[styles.item, i === highlightIndex && styles.highlighted]
                  .filter(Boolean).join(" ")}
                onMouseDown={(e) => { e.preventDefault(); handleSelect(item) }}
                onMouseEnter={() => setHighlightIndex(i)}
              >
                {renderItem(item, value)}
              </div>
            ))
          )}
        </div>
      )}
    </div>
  )
}
css
/* shared/ui/Autocomplete/Autocomplete.module.css */
.wrapper { position: relative; }
.dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin-top: var(--s-1);
  background: var(--c-surface);
  border: 1px solid var(--c-border);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-md);
  max-height: 320px;
  overflow-y: auto;
  z-index: 100;
}
.state {
  padding: var(--s-3) var(--s-4);
  color: var(--c-text-muted);
  font-size: var(--f-size-sm);
  display: flex;
  align-items: center;
  gap: var(--s-2);
}
.item {
  padding: var(--s-2) var(--s-4);
  cursor: pointer;
  font-size: var(--f-size-md);
  transition: background 0.1s;
}
.item:hover, .highlighted {
  background: var(--c-surface-2);
}

Использование в чате (поиск комнат)

tsx
import { Autocomplete, Badge } from "@/shared/ui"

function ChatSearch({ onSelectRoom }: { onSelectRoom: (room: Room) => void }) {
  return (
    <Autocomplete<Room>
      value={query}
      onChange={setQuery}
      onSearch={async (q, { signal }) => {
        const res = await fetch(`/api/rooms/search?q=${q}&limit=10`, { signal })
        return res.json()
      }}
      onSelect={onSelectRoom}
      getKey={(room) => room.id}
      renderItem={(room, query) => (
        <div style={{ display: "flex", justifyContent: "space-between" }}>
          <span>{highlightPrefix(room.name, query)}</span>
          <Badge>{room.memberCount} участников</Badge>
        </div>
      )}
      placeholder="Поиск чата..."
      label="Поиск"
      debounceMs={300}
      minQueryLength={2}
      emptyText="Ничего не найдено"
    />
  )
}

// Подсветка совпадений (из курса алгоритмов — Trie + prefix highlight)
function highlightPrefix(text: string, prefix: string) {
  const idx = text.toLowerCase().indexOf(prefix.toLowerCase())
  if (idx < 0) return text
  return (<>{text.slice(0, idx)}<mark>{text.slice(idx, idx + prefix.length)}</mark>{text.slice(idx + prefix.length)}</>)
}

Autocomplete — чеклист

  • Debounce (встроенный, не нужен внешний хук)
  • Race condition protection (requestId — только последний запрос показывает)
  • AbortController — отмена in-flight запроса при новом вводе, размонтировании, click outside
  • Клавиатурная навигация (Arrow Up/Down, Enter, Escape)
  • Click outside → close
  • Loading state (Loader внутри)
  • Empty state (текст)
  • Error state (передаётся в Input.error)
  • Подсветка prefix в результатах
  • Generic <T> — работает с любым типом данных
  • minQueryLength (не искать при < 2 символов — guard на UI уровне)

Связь с курсом алгоритмов: Autocomplete — идеальное место для применения Trie (F1 bài Search & Autocomplete). Вместо fetch на каждый запрос — Trie на клиенте для мгновенного поиска по локальному кэшу, fetch только при промахе кэша.

Вынесение в отдельный пакет

Когда UI kit стабилен — вынести в npm-пакет, чтобы переиспользовать в разных проектах:

Структура пакета

@myorg/ui-kit/
├── src/
│   ├── Button/
│   ├── Input/
│   ├── Loader/
│   ├── Toast/
│   ├── tokens.css
│   └── index.ts
├── package.json
├── tsconfig.json
└── tsup.config.ts          ← сборщик (esm + cjs + dts)

package.json

json
{
  "name": "@myorg/ui-kit",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
    "./tokens.css": "./dist/tokens.css"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm --dts --external react",
    "dev": "tsup src/index.ts --format esm --dts --external react --watch"
  },
  "peerDependencies": { "react": "^19.0.0" }
}

Использование в другом проекте

bash
npm install @myorg/ui-kit
tsx
import { Button, Input, toast } from "@myorg/ui-kit"
import "@myorg/ui-kit/tokens.css"

function App() {
  return <Button variant="primary" onClick={() => toast.success("Привет!")}>
    Жми
  </Button>
}

tsup.config.ts

ts
import { defineConfig } from "tsup"

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  dts: true,
  external: ["react", "react-dom"],
  loader: { ".css": "copy" },
})

Рефакторинг существующих компонентов

После создания UI kit — заменить raw HTML в компонентах:

tsx
// Было (Login.tsx):
<input className="input" type="email" placeholder="Email" />
<button className="btn-primary">Войти</button>

// Стало:
<Input type="email" placeholder="Email" label="Email" />
<Button type="submit" loading={isLoading}>Войти</Button>

Чеклист UI kit

  • Дизайн-токены (tokens.css) — единый источник цветов, отступов, типографики
  • Button (variants: primary/secondary/danger/ghost, sizes: sm/md/lg, loading)
  • Input (label, error, forwardRef — для react-hook-form совместимости)
  • Loader (sizes: sm/md/lg)
  • Badge (для тегов, статусов)
  • Toast (store-based, без prop drilling)
  • Barrel export (shared/ui/index.ts)
  • CSS Modules (изолированные стили, не глобальные классы)
  • Storybook (опционально — документация компонентов)
  • package.json + tsup (готовность к выносу в пакет)

Практика

  • Создать Button, Input, Loader по дизайн-токенам.
  • Рефакторить LoginForm и ChatRoom — заменить raw HTML на UI kit компоненты.
  • Создать ToastContainer — добавить в App.tsx.
  • Проверить тёмную тему: токены меняются → все компоненты адаптируются.
  • (Опц.) Настроить tsup сборку → npm pack → установить в другой проект.

Результат: UI kit в shared/ui/ — переиспользуется внутри проекта и готов к выносу в отдельный npm-пакет @myorg/ui-kit. Дизайн-токены — единый источник правды. Тёмная тема работает автоматически (меняются только CSS-переменные).


💱 Компромиссы (Trade-offs)

РешениеПлюсыМинусы
Дизайн-токены (CSS Variables)Единый источник цветов/отступов, авто-темизацияНужна дисциплина — не хардкодить значения
UI kit в shared/ui (in-repo)Быстрые итерации, нет версионированияРасползается между проектами при копировании
Вынос в npm-пакет (@myorg/ui-kit)Versioning, переиспользование, изоляцияСборщик (tsup), peerDeps, релизные накладные
CSS ModulesИзоляция стилей, tree-shakingДинамическая темизация сложнее, чем CSS-in-JS
StorybookКаталог компонентов, visual-regressionОтдельная инфраструктура, замедляет CI

🧩 Проверь себя

1. CSS-in-JS vs CSS Modules для UI Kit — trade-off?
2. Storybook в UI Kit — для чего?
3. peerDependencies для UI Kit lib — зачем?
4. UI Kit как npm-библиотека (отдельный package) — зачем?
5. Design tokens (CSS custom properties) — зачем?

🎓 Мостик в Реальный мир

🎓 Вы построили фронтенд мессенджера: React + FSD + Zustand/Effector + PWA + Push + тесты + деплой + UI Kit. Система готова к продакшену.