Это разбор реального опыта внедрения доступности в крупном веб-продукте с десятками микросервисов и сложным фронтендом. Без лозунгов, зато с кодом, ошибками, переработками дизайн-системы и неожиданными проблемами в CI. Поговорим про ARIA, серверный рендеринг, мобильные скринридеры, автоматическое тестирование и про то, почему доступность — это не про alt у картинок, а про архитектуру.
Когда я впервые услышал фразу сделаем доступность, я честно подумал: окей, добавим alt, поправим контраст, закроем задачу. Спойлер — это был самый наивный момент за весь проект.
Мы работали над крупной B2B-платформой. Много форм, таблиц, кастомных контролов, графиков, drag-and-drop, модалки внутри модалок. И в какой-то момент заказчик сказал: продукт должен соответствовать WCAG 2.1 AA. Причём не формально, а чтобы им реально могли пользоваться люди с ограничениями по зре��ию и моторике.
Если вы думаете, что это история про дизайнеров, то нет. Это история про архитектуру, состояние UI, серверный рендеринг, события клавиатуры, правильный фокус и даже про бэкенд-валидацию.
Давайте по порядку.
Доступность — это не слой поверх UI, а часть архитектуры
Первая ошибка — попытка прикрутить доступность к уже существующим компонентам. У нас была собственная дизайн-система на React с кучей кастомных контролов: Select, DatePicker, Dropdown, Tooltip. Всё красиво, анимировано, управляется через div.
И вот тут начинаются проблемы. Скринридер не понимает div с onClick. Он ожидает семантику. Кнопка — это button. Поле ввода — это input. Таблица — это table.
Мы решили не чинить каждый компонент по отдельности, а пересобрать базовый слой UI. Сделали правило: если есть нативный HTML-элемент — используем его. Только если нет альтернативы — добавляем ARIA.
Пример. Был кастомный селект:
// JavaScript (React) function CustomSelect({ options, onChange }) { return ( <div className="select"> <div className="select-trigger">Выбрать</div> <div className="select-menu"> {options.map(o => ( <div key={o.value} onClick={() => onChange(o.value)}> {o.label} </div> ))} </div> </div> ); }
Скринридер видит просто набор div. Клавиатура не работает. Фокус теряется.
Переделали так:
// JavaScript (React) function AccessibleSelect({ options, value, onChange, id }) { return ( <div className="select-wrapper"> <label htmlFor={id}>Выберите значение</label> <select id={id} value={value} onChange={(e) => onChange(e.target.value)} > {options.map(o => ( <option key={o.value} value={o.value}> {o.label} </option> ))} </select> </div> ); }
Да, визуально пришлось дорабатывать стили. Да, дизайнер сначала был не в восторге. Но это сразу решило 80 процентов проблем: фокус, управление стрелками, озвучивание.
Иногда самый сложный технический шаг — отказаться от красивого хака.
Фокус, табуляция и ловушки клавиатуры
Если вы не пробовали пользоваться своим продуктом только клавиатурой — попробуйте. Без мыши. Вообще.
В нашем продукте был сложный модальный мастер из пяти шагов. Изначально фокус просто улетал в body после закрытия шага. Скринридер терял контекст. Пользователь нажимал Tab и оказывался где-то в футере.
Решение оказалось не косметическим. Мы внедрили централизованный менеджер фокуса.
// JavaScript class FocusManager { constructor() { this.stack = []; } trap(container) { const focusable = container.querySelectorAll( 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])' ); const first = focusable[0]; const last = focusable[focusable.length - 1]; container.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); first.focus(); this.stack.push(container); } release() { this.stack.pop(); } } export const focusManager = new FocusManager();
Мы подключили его ко всем модалкам. Плюс добавили aria-modal, role dialog, aria-labelledby.
Интересный момент: если у вас SSR и гидратация, фокус может сбрасываться при ререндере. Нам пришлось добавить проверку на сервере, чтобы не вызывать фокусировку до полной инициализации клиента.
А вы когда-нибудь проверяли, что происходит с фокусом при ошибке валидации формы? Он перескакивает к первому полю или просто появляется красный текст? Скринридеру красный цвет не виден.
Валидация и ошибки: бэкенд тоже участвует
Одна из неожиданных вещей — доступность упирается в API. Если сервер возвращает ошибку, её нужно связать с конкретным полем через aria-describedby.
Мы договорились о едином формате ошибок:
{ "errors": [ { "field": "email", "code": "INVALID_FORMAT", "message": "Некорректный формат email" } ] }
На фронте:
// JavaScript (React) function InputField({ id, error, ...props }) { return ( <div className="input-wrapper"> <input id={id} aria-invalid={!!error} aria-describedby={error ? `${id}-error` : undefined} {...props} /> {error && ( <div id={`${id}-error`} role="alert"> {error} </div> )} </div> ); }
role alert важен — скринридер сразу озвучивает сообщение. Без этого пользователь может вообще не узнать, что что-то пошло не так.
И тут начинается интересное. Если у вас сложная форма с условными полями, нужно следить, чтобы скрытые поля не оставались в таб-порядке. display none — ок. Но visibility hidden или offscreen-позиционирование может оставить их доступными для фокуса.
Мы нашли пару багов, которые существовали годами, просто потому что никто не проверял поведение без мыши.
Графики, сложные таблицы и данные
Самая больная часть — аналитические дашборды. Canvas-графики, интерактивные чарты, кастомные тултипы.
Canvas сам по себе для скринридера пустота. Нам пришлось генерировать альтернативное текстовое представление данных.
Мы сделали вспомогательную таблицу, скрытую визуально, но доступную для ассистивных технологий:
<table class="sr-only"> <caption>Продажи по месяцам</caption> <thead> <tr> <th>Месяц</th> <th>Сумма</th> </tr> </thead> <tbody> <tr> <td>Январь</td> <td>120000</td> </tr> <tr> <td>Февраль</td> <td>95000</td> </tr> </tbody> </table>
Класс sr-only реализован так, чтобы элемент был доступен скринридеру, но не ломал верстку.
Да, это дополнительный код. Да, нужно синхронизировать данные. Но альтернатива — полностью недоступный график.
Ещё интересный кейс — сортировка таблиц. Если пользователь нажимает на заголовок, нужно обновлять aria-sort и давать понятную текстовую индикацию направления сортировки. Иначе скринридер просто скажет столбец и всё.
Автоматизация, тесты и CI
Мы не могли проверять всё вручную. Подключили axe-core в e2e-тестах на Cypress. И тут началось веселье: десятки ошибок на старых страницах.
Самое сложное — договориться внутри команды, что падение теста из-за нарушения доступности — это такой же баг, как и 500 на API.
Пример интеграции:
// JavaScript (Cypress) import 'cypress-axe'; describe('Accessibility check', () => { it('Home page should have no a11y violations', () => { cy.visit('/'); cy.injectAxe(); cy.checkA11y(null, { runOnly: { type: 'tag', values: ['wcag2aa'] } }); }); });
Сначала все ворчали. Потом привыкли. А потом стало проще писать правильно сразу, чем чинить тесты.
Что в итоге
Инклюзивность в большом проекте — это не один спринт и не задача на фронтендера. Это пересмотр подхода к компонентам, событиям, API, тестированию. Это иногда больно, иногда скучно, иногда неожиданно сложно.
Но знаете, что меня больше всего зацепило? Когда мы дали продукт на тест пользователю со скринридером, и он сказал: наконец-то я могу сам пройти этот процесс без помощи.
В этот момент все aria и tabindex перестают быть абстракцией.
