Доступность в сложных компонентах: что ревьюить и как чинить
Кастомный дропдаун, модалка и таблица с сортировкой чаще всего проваливают доступность. Разбираем, какие замечания писать на ревью и как переписать компонент, чтобы он работал с клавиатуры и скринридером.

Кастомный компонент выбора выглядит идеально в макете, но пользователь с клавиатурой не может открыть список, а VoiceOver зачитывает его как пустой div. Это самая частая история с доступностью: базовые кнопки и ссылки обычно в порядке, а ломаются именно сложные интерактивные компоненты — комбобоксы, модальные окна, вкладки, таблицы с сортировкой. Ниже — что искать на ревью такого кода и как переписывать, а не какие красивые слова добавить в описание задачи.
Речь про фронтенд на любом фреймворке; примеры на голом HTML и JS, чтобы отделить суть от React или Vue. Ориентир — WAI-ARIA Authoring Practices Guide (APG), актуальная редакция. Это не спецификация ARIA, а набор проверенных паттернов взаимодействия, на который стоит смотреть при любом кастомном компоненте.
Почему div с onClick — это первое замечание
Разработчики любят вешать обработчик на div или span, потому что так проще стилизовать. Проблема не в стилях, а в том, что вместе с семантикой кнопки теряется всё поведение, которое браузер даёт бесплатно.
Нативный <button> получает фокус по Tab, срабатывает по Enter и Space, объявляется скринридером как «кнопка», участвует в отправке формы. У div нет ничего из этого. Чтобы вернуть поведение вручную, придётся дописать tabindex, role, обработку клавиш — и легко что-нибудь забыть.
Правило ревью, экономящее половину замечаний: если элемент кликается — это должен быть button или a. div с onClick без role и клавиатурной обработки — это баг, а не стилистика.
Формулировка замечания в PR, которая работает лучше «поправьте доступность»:
- «Здесь
divс обработчиком клика. Замените на<button type="button">— иначе элемент недоступен с клавиатуры и не объявляется скринридером. Стили кнопки сбрасываются черезall: unsetилиappearance: none».
Что реально ломается в сложных компонентах
Разберём три компонента, которые проваливают доступность чаще всего, и что писать на ревью для каждого.
Кастомный dropdown вместо select
Дизайнеру не понравился нативный <select>, и появился кастомный список на div. Типичные дефекты: список не открывается с клавиатуры, стрелки не двигают выделение, состояние «открыт/закрыт» не передаётся скринридеру, при клике вне списка фокус теряется в никуда.
По APG такой компонент — это паттерн combobox или listbox. Минимальный рабочий набор атрибутов:
<!-- Кнопка, открывающая список -->
<button
type="button"
id="city-button"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="city-label city-button">
Выберите город
</button>
<!-- Сам список -->
<ul
role="listbox"
id="city-list"
aria-labelledby="city-label"
tabindex="-1">
<li role="option" id="opt-msk" aria-selected="true">Москва</li>
<li role="option" id="opt-spb" aria-selected="false">Санкт-Петербург</li>
</ul>Что проверять на ревью:
aria-expandedпереключается в JS при открытии и закрытии — не остаётся статичнымfalse;- стрелки вверх/вниз двигают активную опцию, Enter выбирает, Escape закрывает и возвращает фокус на кнопку;
- активная опция помечается через
aria-selected, а не только цветом; - клик вне списка закрывает его и не роняет фокус на
<body>.
Отдельный момент: управление фокусом внутри listbox часто делают через aria-activedescendant, а не переносом реального фокуса. Это отдельная развилка — здесь важно, что состояние опции должно быть в разметке, а не только в CSS-классе.
Модальное окно
Модалка — чемпион по утечке фокуса. Пользователь открыл окно, но Tab уводит его на элементы под окном, которые визуально скрыты оверлеем. Скринридер продолжает зачитывать фоновую страницу.
Что должно быть в коде модалки, и что писать в замечаниях, если этого нет:
- role="dialog" и aria-modal="true" на контейнере окна. Без этого скринридер не понимает, что открылся диалог.
- Фокус переносится внутрь при открытии — на первый интерактивный элемент или на сам контейнер с
tabindex="-1". - Ловушка фокуса: Tab с последнего элемента возвращается на первый, а не уходит на фон.
- Escape закрывает окно.
- После закрытия фокус возвращается на элемент, который открыл модалку. Иначе пользователь клавиатуры оказывается в начале страницы.
- aria-labelledby указывает на заголовок окна, чтобы диалог был объявлен по имени.
Если проект использует нативный <dialog> с методом showModal() (поддержка есть во всех актуальных браузерах с 2022 года), браузер сам делает ловушку фокуса, обработку Escape и aria-modal. Замечание на ревью может звучать так: «Здесь руками написанная ловушка фокуса на 60 строк — рассмотрите нативный <dialog>, он закрывает пункты 2–4 из коробки». Проверьте только возврат фокуса — его <dialog> делает не во всех сценариях.
Таблица с сортировкой и фильтрами
Обычная таблица данных доступна почти бесплатно, если использовать <table>, <th> и атрибут scope. Ломается она, когда добавляют интерактив.
Кнопка сортировки в заголовке должна сообщать текущее состояние через aria-sort на ячейке <th>:
<th scope="col" aria-sort="ascending">
<button type="button">Цена</button>
</th>Значения aria-sort: ascending, descending, none. При клике по кнопке значение обновляется в JS. Частая ошибка — рисуют стрелку через CSS, а aria-sort не трогают: зрячий видит направление сортировки, пользователь скринридера — нет.
Ещё одно замечание, которое стоит писать почти всегда: если строки таблицы подгружаются или фильтруются без перезагрузки, изменение количества результатов должно объявляться через регион aria-live="polite". Иначе после применения фильтра пользователь не понимает, что список обновился.
Сравнение подходов к клавиатурной навигации
Внутри составных виджетов фокусом управляют двумя способами. Выбор влияет на объём кода и на поведение со скринридером.
| Критерий | Реальный фокус (tabindex, roving) | aria-activedescendant |
|---|---|---|
| Что двигается | DOM-фокус переносится между элементами | Фокус на контейнере, активный элемент помечается атрибутом |
| Сложность кода | Управление tabindex у каждого элемента (0 у активного, -1 у остальных) | Меняется одно значение атрибута на контейнере |
| Где удобнее | Тулбары, меню, вкладки | Listbox, combobox, grid с большим числом опций |
| Подводный камень | Легко забыть сбросить tabindex, тогда Tab «застревает» | Не все скринридеры одинаково озвучивают смену активного элемента |
Чек-лист для ревью интерактивного компонента
Пройдитесь по компоненту до того, как читать логику. Большинство дефектов видно за минуту:
- каждый кликабельный элемент —
buttonилиa, а неdivс обработчиком; - по компоненту можно пройти Tab и вернуться Shift+Tab без застреваний;
- состояние (открыт, выбран, отсортирован) отражено в ARIA-атрибуте, а не только в CSS-классе;
- Escape закрывает то, что открывается; Enter и Space срабатывают там, где ожидаешь;
- у иконочных кнопок есть
aria-label— иначе скринридер зачитает пустоту; - динамические изменения без перезагрузки объявляются через
aria-live; - фокус виден:
outlineне убран глобальным*:focus { outline: none }.
Как проверять, а не угадывать
Ревью на глаз ловит не всё. Три инструмента, которые стоит гонять до мержа:
- Клавиатура. Отложите мышь и пройдите сценарий целиком. Если куда-то не попасть или фокус исчез — это дефект.
- Скринридер. VoiceOver на macOS (Cmd+F5) или NVDA на Windows (бесплатный). Послушайте, как объявляется компонент.
- Автопроверка. axe DevTools или Lighthouse в разделе Accessibility ловят отсутствие меток и нарушения контраста. Помните: автотесты покрывают порядка трети проблем — управление фокусом и логику клавиш они не проверяют.
Что делать дальше: заведите в проекте линтер eslint-plugin-jsx-a11y для React или аналог для вашего фреймворка — он отловит часть дефектов ещё до ревью. Для регресса добавьте прогон axe-core в CI на ключевых страницах. А самый честный тест — раз в спринт проходить основной сценарий продукта только с клавиатуры и включённым скринридером: это находит то, что не видит ни один линтер.
Карточки для маркетплейсов: Таблица товаров и папка фотографий на входе — комплект слайдов и описаний на выходе, по требованиям Wildberries и Ozon.
Спросить за 5 ₽Источники: WAI-ARIA Authoring Practices Guide (APG), W3C, The dialog element — MDN Web Docs
Частые вопросы
У нас старый проект без нативного dialog — что делать с модалками?
Автотест axe показал ноль ошибок — значит, компонент доступен?
Дизайнер требует убрать синюю рамку фокуса — как быть?
aria-label или aria-labelledby — что выбрать?
Нужно ли тестировать со скринридером, если я им не пользуюсь постоянно?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.