Модуль 6. Overlay и навигация (с a11y-аудитом)

Цель модуля: реализовать модальные окна, тултипы, навигацию, индикаторы загрузки и провести формальный a11y-аудит через axe-core.

К концу модуля у вас:

  • Modal (с focus-trap, Esc, блокировкой скролла), Tooltip, Loader, Skeleton, Tabs.
  • Все overlay-компоненты проходят axe-аудит (0 violations).
  • Skeleton для MessageBubble — критичен для CLS (М10).

📌 Перед началом: Модули 1-5. 🛠 Инструменты: focus-trap, axe-core, createPortal. ⏱ Время: ~6 ч.

🔥 Инцидент: Modal провалил WCAG

Пользователь с Screen Reader открывает ваш Modal. Фокус уходит за пределы диалога. Tab перемещается по всей странице под затемнением. Esc не закрывает. ARIA-атрибуты отсутствуют. WCAG 2.1 AA — провал. Этот модуль строит доступные оверлеи: focus-trap, Esc, role="dialog", возврат фокуса и формальный axe-аудит.

📖 Теория

6.1 Modal — focus trap + Esc + блокировка скролла

tsx
// ui-kit/src/components/Modal/Modal.tsx
import { useEffect, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import styles from './Modal.module.css'

interface ModalProps {
  open: boolean
  onClose: () => void
  title?: string
  children: ReactNode
}

export function Modal({ open, onClose, title, children }: ModalProps) {
  const overlayRef = useRef<HTMLDivElement>(null)
  const modalRef = useRef<HTMLDivElement>(null)
  const previousFocus = useRef<HTMLElement | null>(null)

  useEffect(() => {
    if (!open) return
    previousFocus.current = document.activeElement as HTMLElement

    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') { onClose(); return }
      if (e.key === 'Tab' && modalRef.current) {
        // Focus trap: табуляция не выходит за пределы модалки
        const focusable = modalRef.current.querySelectorAll<HTMLElement>(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        )
        if (focusable.length === 0) return
        const first = focusable[0]
        const last = focusable[focusable.length - 1]
        if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
        else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
      }
    }

    document.addEventListener('keydown', handleKey)
    document.body.style.overflow = 'hidden'
    modalRef.current?.focus()

    return () => {
      document.removeEventListener('keydown', handleKey)
      document.body.style.overflow = ''
      previousFocus.current?.focus()  // возвращаем фокус на триггер
    }
  }, [open, onClose])

  if (!open) return null

  return createPortal(
    <div ref={overlayRef} className={styles.overlay} onClick={onClose} role="presentation">
      <div
        ref={modalRef}
        className={styles.modal}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
      >
        {title && (
          <div className={styles.header}>
            <h2 className={styles.title}>{title}</h2>
            <button onClick={onClose} aria-label="Закрыть" className={styles.closeBtn}>✕</button>
          </div>
        )}
        <div className={styles.content}>{children}</div>
      </div>
    </div>,
    document.body,
  )
}
css
/* ui-kit/src/components/Modal/Modal.module.css */
.overlay {
  position: fixed; inset: 0; z-index: var(--z-overlay);
  background: rgba(0, 0, 0, 0.6);
  display: flex; align-items: center; justify-content: center;
  padding: var(--s-md);
  animation: fadeIn 0.15s ease-out;
}
.modal {
  background: var(--c-surface); border-radius: var(--radius-lg);
  max-width: 90vw; max-height: 90vh; width: 480px;
  box-shadow: var(--shadow-md);
  overflow: hidden; display: flex; flex-direction: column;
  animation: slideUp 0.2s ease-out;
  outline: none;
}
.header { display: flex; justify-content: space-between; align-items: center; padding: var(--s-md); border-bottom: 1px solid var(--c-border); }
.title { font-size: var(--font-size-lg); font-weight: 700; margin: 0; color: var(--c-text); }
.closeBtn { background: none; border: none; font-size: 1.25rem; cursor: pointer; color: var(--c-text-muted); padding: var(--s-xs); border-radius: var(--radius-sm); }
.closeBtn:hover { background: var(--c-surface-2); color: var(--c-text); }
.content { padding: var(--s-md); overflow-y: auto; flex: 1; }

@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } }

@media (prefers-reduced-motion: reduce) {
  .overlay, .modal { animation: none; }
}

Ключевые a11y-инварианты Modal

ПоведениеРеализацияЗачем
Esc закрываетkeydown listenerПользователь без мыши может закрыть
Tab не выходитfocus-trapФокус остаётся внутри модалки
Возврат фокусаpreviousFocus.current?.focus()После закрытия фокус на кнопке, которая открыла
role="dialog" + aria-modalАтрибутыScreen reader объявляет «диалоговое окно»
Блокировка скроллаbody.style.overflow = 'hidden'Фон не прокручивается
prefers-reduced-motionCSS media queryУважение к настройкам пользователя

6.2 Tooltip — hover/focus с задержкой

tsx
// ui-kit/src/components/Tooltip/Tooltip.tsx
import { useState, useRef, type ReactNode } from 'react'
import styles from './Tooltip.module.css'

interface TooltipProps {
  content: string
  children: ReactNode
  delay?: number
}

export function Tooltip({ content, children, delay = 500 }: TooltipProps) {
  const [show, setShow] = useState(false)
  // useRef вместо let — React 19 + ESLint react-hooks/immutability запрещает
  // мутировать переменные в замыканиях обработчиков.
  const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)

  const onEnter = () => { timerRef.current = setTimeout(() => setShow(true), delay) }
  const onLeave = () => { clearTimeout(timerRef.current); setShow(false) }

  return (
    <span className={styles.wrapper} onMouseEnter={onEnter} onMouseLeave={onLeave} onFocus={onEnter} onBlur={onLeave}>
      {children}
      {show && <span className={styles.tooltip} role="tooltip">{content}</span>}
    </span>
  )
}

Важно: tooltip должен показываться и на focus (клавиатурная навигация), не только на mouseenter.


6.3 Skeleton — критично для CLS

Skeleton занимает точно место контента до загрузки → нет layout shift → CLS = 0.

tsx
// ui-kit/src/components/Skeleton/Skeleton.tsx
import styles from './Skeleton.module.css'

interface SkeletonProps {
  width?: string | number
  height?: string | number
  radius?: string
  className?: string
}

export function Skeleton({ width = '100%', height = 20, radius = 'var(--radius-sm)', className }: SkeletonProps) {
  return (
    <div
      className={[styles.skeleton, className].filter(Boolean).join(' ')}
      style={{ width, height, borderRadius: radius }}
      aria-hidden="true"
    />
  )
}
css
/* ui-kit/src/components/Skeleton/Skeleton.module.css */
.skeleton {
  background: linear-gradient(90deg, var(--c-surface), var(--c-surface-2), var(--c-surface));
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

Skeleton для MessageBubble

tsx
function MessageSkeleton() {
  return (
    <div style={{ display: 'flex', gap: 'var(--s-sm)', padding: 'var(--s-md)' }}>
      <Skeleton width={32} height={32} radius="50%" />
      <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--s-xs)' }}>
        <Skeleton width="40%" height={12} />
        <Skeleton width="80%" height={16} />
        <Skeleton width="60%" height={16} />
      </div>
    </div>
  )
}

6.4 Tabs — клавиатурная навигация

tsx
// ui-kit/src/components/Tabs/Tabs.tsx
import { useState, type ReactNode, type KeyboardEvent } from 'react'
import styles from './Tabs.module.css'

interface Tab { id: string; label: string; content: ReactNode }

interface TabsProps {
  tabs: Tab[]
  defaultTab?: string
  onChange?: (id: string) => void
}

export function Tabs({ tabs, defaultTab, onChange }: TabsProps) {
  const [active, setActive] = useState(defaultTab ?? tabs[0]?.id ?? '')
  const activeIndex = Math.max(0, tabs.findIndex((t) => t.id === active))

  const handleKey = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'ArrowRight') {
      const next = tabs[(activeIndex + 1) % tabs.length]
      setActive(next.id); onChange?.(next.id)
    } else if (e.key === 'ArrowLeft') {
      const prev = tabs[(activeIndex - 1 + tabs.length) % tabs.length]
      setActive(prev.id); onChange?.(prev.id)
    }
  }

  return (
    <div>
      <div role="tablist" className={styles.tablist} onKeyDown={handleKey}>
        {tabs.map((tab) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={tab.id === active}
            aria-controls={`panel-${tab.id}`}
            tabIndex={tab.id === active ? 0 : -1}
            className={[styles.tab, tab.id === active && styles.active].filter(Boolean).join(' ')}
            onClick={() => { setActive(tab.id); onChange?.(tab.id) }}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={tab.id !== active}
        >
          {tab.content}
        </div>
      ))}
    </div>
  )
}

A11y: role="tablist" / role="tab" / role="tabpanel" + aria-selected + aria-controls/aria-labelledby — стандартный паттерн WAI-ARIA для вкладок. Стрелки ←/→ переключают, Tab — переход в панель.


6.5 Loader — spinner

tsx
// ui-kit/src/components/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(' ')} role="status" aria-label="Загрузка" />
}
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); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } }

6.6 A11y-аудит (формальный)

Axe-core в Storybook

Addon @storybook/addon-a11y уже настроен. Откройте любую story → внизу панель Accessibility. Axe проверяет WCAG 2.1 AA: контраст, ARIA, фокус, семантика.

Критерий приёмки: axe показывает 0 violations для каждой story.

Axe-core в Playwright

В М13 добавим полный e2e-a11y, но уже сейчас можно:

ts
// e2e/a11y.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'

test('UI Gallery passes axe audit', async ({ page }) => {
  await page.goto('/ui-gallery')
  const results = await new AxeBuilder({ page }).analyze()
  expect(results.violations).toEqual([])
})

6.7 Тесты

tsx
// ui-kit/src/components/Modal/Modal.test.tsx
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { Modal } from './Modal'

describe('Modal', () => {
  it('does not render when closed', () => {
    const { container } = render(<Modal open={false} onClose={() => {}}>Content</Modal>)
    expect(container.querySelector('[role="dialog"]')).toBeNull()
  })

  it('closes on Escape', () => {
    const onClose = vi.fn()
    render(<Modal open onClose={onClose}>Content</Modal>)
    fireEvent.keyDown(document, { key: 'Escape' })
    expect(onClose).toHaveBeenCalledOnce()
  })

  it('renders with aria-modal and role=dialog', () => {
    render(<Modal open onClose={() => {}} title="Test">Content</Modal>)
    const dialog = screen.getByRole('dialog')
    expect(dialog).toHaveAttribute('aria-modal', 'true')
  })
})

6.8 Антипаттерны

АнтипаттернКак правильно
Modal без focus-trapРеализовать Tab cycling внутри modalRef
Тосты без role="status"role="status" на контейнере тостов
Tooltip без role="tooltip"role="tooltip" на элементе подсказки
Анимация без prefers-reduced-motion@media (prefers-reduced-motion: reduce) { animation: none; }
display: none для скрытия модалки (вместо unmount)if (!open) return null — remove из DOM
Tabs без aria-selected и arrow-navПолный WAI-ARIA tabs pattern

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

РешениеПлюсыМинусы
createPortal в bodyИзоляция от overflow/transformСложнее тестировать в jsdom
Свой focus-trap (не библиотека)Полный контроль, меньше зависимостейБольше кода, шанс багов
prefers-reduced-motionУважение к пользователюАнимация пропадает для части аудитории
Axe в Storybook (не только e2e)Ранний фидбекНе ловит сценарные баги

💻 6.9 Практика

  1. Реализуйте Modal, Tooltip, Skeleton, Tabs, Loader — каждый с тестом и stories.
  2. Добавьте MessageSkeleton на ui-gallery.
  3. Создайте модалку «Создать чат» с Input внутри.
  4. Проверьте axe в каждой story — 0 violations.
bash
npm run verify

6.10 Артефакты сдачи М6

АртефактКритерий
verify зелёныйВсе overlay-тесты проходят
axe-отчёт0 violations для Modal, Tooltip, Tabs, Skeleton
Скриншот ModalОткрытая модалка с focus-trap (Tab не выходит)
Скриншот SkeletonMessageSkeleton занимает точное место MessageBubble

Вперёд → Модуль 7: контракты API и Next.js Route Handlers.


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

1. Порталы (createPortal) для overlay — зачем?
2. ARIA-атрибуты на навигации (role="nav", aria-label) — для чего?
3. Focus-trap в модальном окне — зачем?
4. A11y-аудит (WCAG) — минимум для overlay (modal/dialog)?
5. aria-live="polite" для toast уведомлений — зачем?

🔗 Мостик к Модулю 7

Оверлеи доступны: Modal с focus-trap, Tooltip с hover/focus, Skeleton с точной геометрией, Tabs с arrow-nav. Теперь определим контракт между фронтендом и бэкендом: OpenAPI → Zod → автогенерация типов.