Модуль 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-motion | CSS 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
tsxfunction 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 Практика
- Реализуйте
Modal,Tooltip,Skeleton,Tabs,Loader— каждый с тестом и stories. - Добавьте
MessageSkeletonнаui-gallery. - Создайте модалку «Создать чат» с Input внутри.
- Проверьте axe в каждой story — 0 violations.
bashnpm run verify
6.10 Артефакты сдачи М6
| Артефакт | Критерий |
|---|---|
verify зелёный | Все overlay-тесты проходят |
| axe-отчёт | 0 violations для Modal, Tooltip, Tabs, Skeleton |
| Скриншот Modal | Открытая модалка с focus-trap (Tab не выходит) |
| Скриншот Skeleton | MessageSkeleton занимает точное место MessageBubble |
🧩 Проверь себя
🔗 Мостик к Модулю 7
Оверлеи доступны: Modal с focus-trap, Tooltip с hover/focus, Skeleton с точной геометрией, Tabs с arrow-nav. Теперь определим контракт между фронтендом и бэкендом: OpenAPI → Zod → автогенерация типов.