Доступность (a11y) ARIA: роли, свойства, состояния и правило «лучше без ARIA»
0%

ARIA: роли, свойства, состояния и правило «лучше без ARIA»

ARIA: роли, свойства, состояния и правило «лучше без ARIA»

ARIA (Accessible Rich Internet Applications) — набор HTML-атрибутов, которыми вы объясняете вспомогательным технологиям, чем элемент на экране является и в каком он сейчас состоянии. Не «как выглядит» и не «что делает по клику»: кнопка это, вкладка, список или диалог; развёрнут или свёрнут; выбран или нет.

Это самый недопонятый инструмент во всей доступности. Половина команд считает ARIA волшебной присыпкой, которая «делает интерфейс доступным», и щедро посыпает ей div-ы. Вторая половина слышала, что «ARIA — это плохо», и обходит её стороной. Правда посередине и очень конкретна: ARIA — точный хирургический инструмент, нужный там, где HTML не выражает семантику, и вредный везде, где HTML её уже выражает.

Общая картина трека — во вводном Гайде по Accessibility, а фундамент этой статьи разобран в Семантике: дальше я исхожу из того, что вы уже согласны, что <button> лучше <div onclick>. Здесь — про то, что делать, когда нативного элемента объективно не хватает.

Про тон. Дальше много про скринридеры, но доступность — не отдельная благотворительная задача «поддержать незрячих». Это нормальное качество продукта: честная семантика одинаково помогает человеку с NVDA, человеку с голосовым управлением, человеку, который временно держит телефон одной рукой, и вашему же e2e-тесту, который ищет элемент по роли и имени. Пользователи скринридеров — эксперты в своём инструменте: слушают речь на скорости, которую вы не разберёте, и мгновенно замечают, где интерфейс врёт про себя.

Что ARIA делает и чего не делает

ARIA состоит ровно из трёх вещей:

  1. role — какого типа элемент: role="button", role="tab", role="dialog".
  2. Свойства — относительно статичные характеристики: aria-label, aria-labelledby, aria-describedby, aria-haspopup, aria-required.
  3. Состояния — то, что меняется по ходу взаимодействия: aria-expanded, aria-checked, aria-pressed, aria-selected, aria-disabled, aria-invalid.

Граница между свойствами и состояниями в спецификации есть, но на практике неважна: все они атрибуты aria-* и читаются одинаково. Важно другое — чего ARIA не делает: не добавляет фокус (role="button" не делает div фокусируемым), не добавляет обработку клавиатуры, не меняет ни пиксела на экране, не меняет поведение браузера. ARIA пишет только в дерево доступности; всё остальное — ваша работа.

Путь от HTML-разметки до речи скринридера: DOM, вычисление роли и имени, дерево доступности, платформенный API, скринридер

Классический пример того, как это ломается:

<!-- Выглядит как кнопка, скринридер говорит «кнопка Удалить». -->
<!-- И при этом сюда нельзя попасть с клавиатуры и нельзя нажать. -->
<div class="btn" role="button" onclick="remove()">Удалить</div>

Чтобы код перестал врать, нужно дописать фокусируемость и клавиатуру:

<div class="btn" role="button" tabindex="0" onclick="remove()"
     onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); remove(); }">
  Удалить
</div>

И даже так остаются отличия: элемент не участвует в отправке формы, у него нет состояния :disabled, он игнорирует form и formaction, а в режиме высокой контрастности Windows его не покрасят как элемент управления. Всё это бесплатно даёт одна строка <button type="button" class="btn" onclick="remove()">Удалить</button>. Это и есть первое правило ARIA, только показанное, а не рассказанное.

Дерево доступности: где на самом деле живёт ARIA

Браузер строит из DOM не только дерево отрисовки, но и дерево доступности — параллельную структуру, где у каждого узла небольшой набор полей:

Поле Что это Откуда берётся
Role тип элемента тег или role
Name доступное имя алгоритм AccName
Description уточнение aria-describedby, title
State состояние aria-*, нативные checked, disabled, open
Value значение value, aria-valuenow, текст поля

Дальше дерево транслируется в платформенный API — UI Automation в Windows, AT-SPI в Linux, NSAccessibility в macOS — и уже оттуда его читает скринридер. Практическое следствие: поддержка ARIA — это пересечение поддержки в браузере, в платформенном API и в конкретном скринридере. Атрибут может быть в спецификации десять лет и всё ещё игнорироваться конкретной парой. Таблицы фактической поддержки лежат на a11ysupport.io — сверяйтесь с ними до того, как строить архитектуру компонента вокруг экзотического атрибута.

Смотреть дерево можно без всякого скринридера: в Chrome и Edge это DevTools → Elements → панель Accessibility (там же чекбокс Enable full-page accessibility tree), в Firefox — отдельная вкладка Accessibility с кнопкой Check for issues, в Safari — Web Inspector → Elements → Node → Accessibility. Возьмите за привычку: любое изменение ARIA проверяется не чтением кода, а взглядом на это дерево. В коде видно намерение, в дереве — результат.

Откуда взялась ARIA и где искать нормы

Четыре документа в закладки: WAI-ARIA 1.2 — сама спецификация ролей и свойств; Using ARIA — короткая заметка с пятью правилами, читается за двадцать минут и экономит месяцы; ARIA in HTML — какие роли допустимы на каком теге (именно на неё ругаются линтеры); ARIA Authoring Practices Guide — эталонные паттерны компонентов. Про APG важная оговорка: он показывает, как устроен паттерн, а не гарантирует, что тот одинаково хорошо звучит во всех скринридерах.

Со стороны требований это WCAG 2.2: критерий 4.1.2 Name, Role, Value (уровень A) — у каждого элемента управления должны быть определимые имя, роль и значение; 4.1.3 Status Messages (AA) — важные изменения сообщаются без перевода фокуса; 1.3.1 Info and Relationships (A) на структурные связи; 2.5.3 Label in Name (A) — видимая подпись должна входить в доступное имя. Разбор самих критериев и бизнес-аргументов — в Зачем это нужно.

Отрезвляющий факт: ежегодный отчёт WebAIM Million из года в год показывает, что главные страницы, где используется ARIA, в среднем набирают больше автоматически обнаруживаемых ошибок, чем страницы без неё. Не потому, что ARIA плоха, — потому что её ставят на сложные самописные компоненты и ставят неправильно.

Пять правил использования ARIA

Каркас из Using ARIA с практическими комментариями.

Правило 1. Если есть нативный элемент — берите его. Нужна кнопка — <button>. Чекбокс — <input type="checkbox">. Выпадающий список — начните с <select>. Раскрывающийся блок — <details>/<summary>. Модальный диалог — <dialog> с showModal(). Нативный элемент бесплатно даёт роль, фокусируемость, клавиатуру, состояния :disabled и :checked, поддержку режима высокой контрастности, работу с голосовым управлением, интеграцию с формой и одинаковое поведение во всех AT. Ваш ARIA-аналог даёт только роль — остальное вы дописываете и потом поддерживаете годами. Честные причины отказаться: нужен внешний вид, которого нативный элемент не даёт; нужно поведение, которого у него нет (автодополнение, дерево, сетка); или аналога в HTML просто не существует.

Правило 2. Не переопределяйте нативную семантику. role заменяет нативную роль целиком:

<!-- Плохо: заголовок перестал быть заголовком и исчез из списка заголовков -->
<h2 role="tab">Доставка</h2>

<!-- Хорошо: роль на кнопке, заголовок остаётся заголовком -->
<h2>
  <button role="tab" aria-selected="true" aria-controls="panel-delivery" id="tab-delivery">Доставка</button>
</h2>

Написав role="presentation" на <table> с данными, вы лишите пользователя навигации по ячейкам. Написав role="button" на <a href>, получите элемент, который звучит кнопкой, но по Enter уходит по ссылке и не реагирует на Space.

Правило 3. Любой интерактивный ARIA-элемент работает с клавиатуры. Поставили роль виджета — button, checkbox, tab, menuitem, option, slider — элемент обязан быть достижим и управляем по правилам своего паттерна: у tablist стрелки переключают вкладки, у menu ходят по пунктам, Escape закрывает. Отдельная большая тема — в Клавиатуре и фокусе.

Правило 4. Не прячьте фокусируемые элементы. aria-hidden="true" на поддереве с фокусируемым содержимым — гарантированный баг: пользователь табом попадает «в никуда», скринридер молчит, потому что для него этого узла нет.

Правило 5. У каждого интерактивного элемента есть доступное имя. Иконка-кнопка, ссылка-стрелка, крестик закрытия без имени звучат просто как «кнопка» и «ссылка»: пользователь слышит роль и ничего больше.

Роли: карта территории

Практический слой поверх карты — таблица соответствий. Левая колонка почти всегда предпочтительнее правой:

Задача Нативный HTML ARIA-эквивалент
Шапка и подвал страницы <header>, <footer> в корне role="banner", role="contentinfo"
Навигация, основное содержимое <nav>, <main> role="navigation", role="main"
Форма поиска <form role="search"> role="search"
Заголовок <h1><h6> role="heading" aria-level="2"
Список <ul>/<li> role="list" + role="listitem"
Кнопка <button> role="button" + tabindex="0" + клавиатура
Чекбокс <input type="checkbox"> role="checkbox" + aria-checked + клавиатура
Диалог <dialog> role="dialog" + aria-modal + ловушка фокуса
Прогресс <progress> role="progressbar" + aria-valuenow/min/max

Нюансы, на которых спотыкаются:

Ориентиры — единственная категория, где ARIA часто оправдана и при наличии нативных тегов, потому что нескольким одинаковым ориентирам нужны разные имена: <nav aria-label="Основная">, <nav aria-label="Хлебные крошки">, <nav aria-label="В подвале">. В имени ориентира не пишут слово «навигация» — роль и так озвучится, получится «навигация Основная навигация».

<section> становится ориентиром region только с доступным именем. Голый <section> для скринридера — ничто, а <section aria-labelledby="stats-title"> — блок, к которому можно перейти.

role="list" иногда действительно нужен: со стилем list-style: none Safari с VoiceOver может убрать семантику списка, и явный role="list" её возвращает. Это не суеверие, а известное поведение, описанное Скоттом О’Харой.

role="application" — почти всегда ошибка. Он выключает у скринридера режим просмотра, и привычные горячие клавиши перестают работать. Оправдан для полноэкранных редакторов и игр, где вы берёте на себя вообще всю клавиатуру.

Имя, описание, значение

Доступное имя вычисляется по спецификации AccName. Порядок приоритетов надо знать наизусть:

aria-label перебивает видимый текст — это его главная опасность:

<!-- Пользователь видит «Отправить», но команда голосового управления -->
<!-- «нажать Отправить» не сработает: доступное имя элемента другое. -->
<button aria-label="Отправить форму обратной связи">Отправить</button>

Это прямое нарушение 2.5.3 Label in Name: видимая подпись должна входить в доступное имя, желательно с него начинаться. Правильно — не трогать имя вообще либо дополнить описанием: <button aria-describedby="hint-send">Отправить</button> плюс <p id="hint-send">Мы ответим в течение рабочего дня.</p>.

Кроме того, aria-label не переводится машинными переводчиками так же надёжно, как текст, не находится поиском по странице и не виден в интерфейсе — то есть незаметно протухает при рефакторинге. Правило простое: если текст можно показать на экране — покажите его. aria-label уместен там, где видимого текста объективно нет: иконка-кнопка, ориентир, поле поиска без подписи.

aria-labelledby собирает имя из нескольких кусков и работает даже со скрытым текстом:

<h2 id="col-city">Город</h2>
<button id="edit-1" aria-labelledby="edit-1 col-city">Изменить</button>
<!-- Скринридер: «Изменить Город, кнопка» -->

Самоссылка edit-1 в списке — законный приём, чтобы собственный текст элемента шёл первым; порядок в атрибуте задаёт порядок в имени. И ключевое: aria-labelledby побеждает всё, включая <label for>, поэтому опечатка в id мгновенно оставляет элемент без имени. Это самая частая ARIA-ошибка в проде.

aria-describedby добавляет описание, которое скринридер читает после имени и роли, с паузой, и которое можно пропустить: подсказки к полям, требования к паролю, текст ошибки. Подробнее — в Доступных формах. А вот title как единственный источник имени — плохой план: он не показывается на тач-устройствах, не показывается при клавиатурной навигации в большинстве браузеров и озвучивается непредсказуемо.

Состояния и свойства, которые действительно нужны

Атрибут Где применяется Что важно помнить
aria-expanded кнопка, раскрывающая что-либо ставится на управляющий элемент, не на панель
aria-pressed кнопка-переключатель «нажато/отжато» для одной кнопки
aria-checked checkbox, radio, switch есть третье значение mixed
aria-selected option, tab, ячейки сетки «выбран из набора», не путать с checked
aria-current навигация, пагинация значения page, step, date, true
aria-disabled любой виджет не блокирует клик — блокируйте в коде
aria-invalid поля ввода ставить после попытки отправки, не на каждый символ
aria-haspopup кнопка меню, комбобокс значения menu, listbox, dialog, true
aria-controls связь управляющего и управляемого поддержка слабая, не единственный сигнал
aria-activedescendant составные виджеты «виртуальный» фокус без переноса DOM-фокуса
aria-live области обновлений контейнер должен существовать заранее

disabled против aria-disabled

Нативный <button disabled> не фокусируется, и часть скринридеров пропускает его при навигации. <button aria-disabled="true"> фокусируется и читается («Сохранить, недоступно»), но клик нужно гасить самому:

// Заблокированная, но заметная кнопка: пользователь дойдёт до неё табом,
// услышит состояние и получит объяснение, почему ничего не происходит.
button.addEventListener('click', (event) => {
  if (button.getAttribute('aria-disabled') === 'true') {
    event.preventDefault();
    event.stopPropagation();
    announce('Заполните обязательные поля, чтобы сохранить.');
  }
});

Практическое правило: для обычных полей формы достаточно нативного disabled; для главной кнопки действия (submit, «Далее», кнопка в тулбаре) лучше aria-disabled — иначе пользователь просто не найдёт кнопку и не поймёт, почему форма не отправляется.

Жизненный цикл раскрывающегося блока

Главное здесь: aria-expanded живёт на кнопке, а не на панели. Скринридер озвучивает состояние в момент, когда пользователь стоит на кнопке, — до того как решит нажимать. Повесите атрибут на панель — его никто не услышит.

aria-controls, aria-owns, aria-activedescendant

Три атрибута, вокруг которых больше всего мифов. aria-controls описывает «этот элемент управляет вон тем»: идея прекрасная, поддержка так себе — JAWS предлагает по нему перейти, NVDA и VoiceOver в основном игнорируют. Ставить можно и нужно (это часть паттернов APG), но не рассчитывайте, что он один донесёт связь. aria-owns переставляет узлы в дереве доступности, делая элемент логическим потомком другого, даже если в DOM он лежит в стороне; дорогая операция и известный источник багов — почти всегда правильнее исправить порядок в DOM. aria-activedescendant даёт «виртуальный» фокус: DOM-фокус остаётся на контейнере, а атрибут указывает на id активного потомка. Нужен там, где перенос настоящего фокуса разрушил бы ввод текста, то есть в комбобоксах.

Скрытие: aria-hidden, role="presentation" и inert

Три разных инструмента с тремя разными смыслами, которые постоянно путают:

<!-- 1. Декоративная иконка: не нужна в озвучке, текст рядом всё объясняет -->
<button><svg aria-hidden="true" focusable="false" width="16" height="16">…</svg> Скачать отчёт</button>

<!-- 2. Обёртка ради вёрстки: убираем лишнюю семантику, содержимое остаётся -->
<ul role="presentation"><li role="presentation"><a href="/docs">Документация</a></li></ul>

<!-- 3. Фон под модальным окном: видим глазами, но полностью выключен -->
<div id="page" inert>…</div>
<dialog open>…</dialog>

aria-hidden="true" убирает элемент и всё поддерево из дерева доступности; визуально ничего не меняется и фокус по-прежнему туда попадает — отсюда правило 4. role="presentation" (синоним role="none") снимает семантику самого элемента, оставляя детей; подвох в том, что обязательные потомки роли (tablerowcell, listlistitem) тоже станут презентационными, а на фокусируемом элементе роль просто игнорируется. inert — уже нативный HTML-атрибут, поддержанный всеми актуальными браузерами: делает поддерево одновременно невидимым для AT, нефокусируемым и некликабельным. Это правильный инструмент для фона под модалкой, скрытых слайдов карусели и неактивных панелей табов; <dialog> с showModal() делает это сам.

Отдельно: hidden, display: none и visibility: hidden убирают элемент из дерева доступности сами — дописывать к ним aria-hidden не нужно. А приём «визуально скрытый текст» (класс .sr-only с обрезкой в один пиксель) оставляет элемент в дереве — ровно то, что нужно для подписей, не помещающихся в дизайн.

Живые области: сообщить об изменении, не забирая фокус

Пользователь нажал «Применить фильтры», список перерисовался, фокус остался на кнопке. Зрячий человек видит изменение. Пользователь скринридера — нет: он читает страницу последовательно, и обновление где-то ниже для него не существует. Решение — живые области, они же WCAG 2.2 4.1.3 Status Messages.

<!-- Контейнеры лежат в разметке С САМОГО НАЧАЛА и пустые. -->
<div id="live-polite" aria-live="polite" aria-atomic="true" class="sr-only"></div>
<div id="live-alert" role="alert" class="sr-only"></div>
// Общая функция объявлений на всё приложение.
// Ключевая деталь: элемент уже в DOM, мы меняем только текст внутри него.
function announce(message, { assertive = false } = {}) {
  const region = document.getElementById(assertive ? 'live-alert' : 'live-polite');

  // Если текст совпал с предыдущим, часть скринридеров промолчит:
  // сбрасываем содержимое, ждём кадр отрисовки и вставляем заново.
  region.textContent = '';
  requestAnimationFrame(() => { region.textContent = message; });
}

announce('Найдено 24 товара');                                  // дождётся паузы в речи
announce('Не удалось сохранить черновик', { assertive: true }); // перебьёт чтение

Что нужно знать про живые области:

  • aria-live="polite" — дождаться паузы; значение по умолчанию для 95 % случаев. aria-live="assertive" перебивает всё немедленно и уместно только для настоящих ошибок: каждое такое сообщение — прерывание человека на полуслове.
  • role="status"aria-live="polite" aria-atomic="true", role="alert"aria-live="assertive" aria-atomic="true". Роли короче и надёжнее поддержаны — предпочитайте их.
  • aria-atomic="true" заставляет читать область целиком, а не только изменившийся кусок: для «Найдено 24 товара» правильно, для потока логов — нет.
  • Контейнер обязан существовать до вставки текста. Если создать <div role="alert">Ошибка</div> и сразу вставить в документ, часть скринридеров появления не заметит.
  • Не сыпьте объявлениями. Область, срабатывающая на каждое нажатие клавиши в поиске, делает интерфейс неюзабельным; дебаунс на 300–500 мс обязателен. И не помещайте внутрь фокусируемые элементы.

Подробнее — MDN про ARIA live regions.

Как это звучит: путь одного нажатия

Неочевидный момент: у NVDA и JAWS есть два режима. В режиме просмотра стрелки ходят по виртуальной копии страницы и клавиши работают как команды скринридера; в режиме форм нажатия уходят прямо в страницу. Переключение происходит автоматически, когда фокус попадает на роль, требующую ввода: textbox, combobox, listbox, grid, menu, application. Поэтому неправильная роль ломает не только озвучку, но и механику навигации: поставили role="application" на лендинг — и пользователь потерял возможность ходить по заголовкам. Как это выглядит вживую — в Скринридерах.

Разбор: раскрывающийся блок и переключатель

Сначала вариант, которому ARIA не нужна совсем: <details><summary>Условия доставки</summary><p>…</p></details> — роль, клавиатура и состояние нативные. Если дизайн требует анимации и полного контроля над разметкой, минимальный корректный ARIA-вариант такой:

<h3>
  <button type="button" id="acc-btn-1" aria-expanded="false" aria-controls="acc-panel-1">
    Условия доставки
  </button>
</h3>
<div id="acc-panel-1" role="region" aria-labelledby="acc-btn-1" hidden>
  <p>Доставляем по будням с 10 до 20.</p>
</div>
// Ровно два действия: переключить атрибут и показать/скрыть панель.
// Ни tabindex, ни обработки клавиш — <button> всё делает сам.
const button = document.getElementById('acc-btn-1');
const panel = document.getElementById('acc-panel-1');

button.addEventListener('click', () => {
  const isOpen = button.getAttribute('aria-expanded') === 'true';
  button.setAttribute('aria-expanded', String(!isOpen));
  panel.hidden = isOpen;
});

Обратите внимание: <button> внутри <h3>, а не наоборот. Заголовок остаётся заголовком (по нему можно перейти клавишей H), кнопка остаётся кнопкой.

Кнопка-переключатель устроена так же просто: вместо <div class="toggle on">Уведомления</div>, где состояние передано только цветом, пишем <button type="button" aria-pressed="true">Уведомления</button>. Скринридер прочитает «Уведомления, кнопка-переключатель, нажато». Имя кнопки не меняется при переключении — меняется только состояние; частая ошибка «Включить уведомления» / «Выключить уведомления» заставляет имя прыгать, путает пользователя и ломает автотесты. Если элемент семантически ближе к настройке, чем к действию, берите role="switch" с aria-checked — он звучит как «переключатель, включено».

Комбобокс: почему сложные виджеты дорогие

Анатомия комбобокса: поле ввода, список, опции и ARIA-атрибуты на каждой части

Разметка минимального комбобокса по паттерну APG:

<label for="city">Город</label>
<input id="city" type="text" role="combobox" aria-expanded="true" aria-controls="city-list"
       aria-autocomplete="list" aria-activedescendant="city-opt-1" autocomplete="off">

<ul id="city-list" role="listbox" aria-labelledby="city-label">
  <li id="city-opt-1" role="option" aria-selected="true">Казань</li>
  <li id="city-opt-2" role="option" aria-selected="false">Казбек</li>
  <li id="city-opt-3" role="option" aria-selected="false">Казачинское</li>
</ul>

<div aria-live="polite" class="sr-only">Найдено 3 варианта</div>

Разметка — процентов двадцать работы. Остальное: стрелки перемещают aria-activedescendant и прокручивают список, Enter выбирает, Escape закрывает и возвращает исходный текст, Tab закрывает и уходит дальше, клик мышью не должен уводить фокус из поля, на мобильных нужно проверять жесты TalkBack и VoiceOver, объявления результатов требуют дебаунса, а пустой результат — отдельного сообщения.

Отсюда вывод: комбобокс, дерево, сетка и меню — не «компоненты на день». Если нет ресурса тестировать их скринридером, берите проверенную библиотеку с явно заявленной поддержкой (Radix UI, React Aria, Ark UI, Downshift) и всё равно прогоняйте руками. Полный разбор — в Доступных компонентах.

Антипаттерны, которые встречаются чаще всего

Антипаттерн Что ломается Как чинить
ARIA вместо семантики: страница из div role="button" всё сразу: фокус, клавиатура, контрастные темы переписать на <button>, <a>, <ul>, <nav>
Роль без клавиатуры: role="tab" без стрелок интерфейс обещает паттерн и не выполняет реализовать клавиатуру паттерна или отказаться от роли
Битые id в aria-labelledby элемент молча теряет имя линтер плюс скрипт-аудит из следующего раздела
aria-label поверх видимого текста ломает голосовое управление, нарушает 2.5.3 убрать либо перенести в aria-describedby
Избыточность: <nav role="navigation"> шум и риск рассинхронизации при рефакторинге удалить дублирующие роли
aria-hidden на фокусируемом (меню с opacity: 0) фокус уходит в невидимое inert, hidden или display: none
Состояние только визуальное: подсветка без aria-selected состояние существует лишь для зрячих добавить aria-selected, aria-current, aria-pressed
aria-live на весь контейнер результатов список зачитывается заново при каждой перерисовке вынести короткое сообщение в отдельную область
role="presentation" на таблице с данными пропадает навигация по строкам и столбцам вернуть нативную семантику таблицы
Состояние не на том элементе: aria-expanded на панели никто его не услышит перенести на управляющую кнопку

Проверка руками за пять минут

Это не замена аудиту (о нём — в Тестировании и процессе), а минимум, который разработчик делает сам перед отправкой задачи в ревью.

Шаг 1. Таб-проход (60 с). Уберите руку с мыши и пройдите по компоненту клавишей Tab. Видно ли, где фокус? Попадает ли он на всё интерактивное? Не проваливается ли в скрытое? Не застревает ли?

Шаг 2. Дерево доступности (60 с). DevTools → Accessibility, выберите элемент, проверьте Role, Name и состояния. Пустое имя или имя вида «button» — баг.

Шаг 3. Скрипт-аудит (30 с). Вставьте в консоль:

// Быстрая проверка трёх самых частых ARIA-ошибок на текущей странице.
const problems = [];
const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const WIDGETS = '[role="button"], [role="link"], [role="checkbox"], [role="tab"], [role="menuitem"]';

// 1. Ссылки aria-labelledby и aria-describedby в никуда
for (const el of document.querySelectorAll('[aria-labelledby], [aria-describedby]')) {
  for (const attr of ['aria-labelledby', 'aria-describedby']) {
    for (const id of (el.getAttribute(attr) || '').trim().split(/\s+/).filter(Boolean)) {
      if (!document.getElementById(id)) problems.push([`${attr}: нет элемента с id "${id}"`, el]);
    }
  }
}

// 2. Фокусируемое внутри aria-hidden — фокус уходит в пустоту
for (const el of document.querySelectorAll('[aria-hidden="true"]')) {
  const inside = el.matches(FOCUSABLE) ? [el] : Array.from(el.querySelectorAll(FOCUSABLE));
  const reachable = inside.filter((n) => !n.disabled && n.offsetParent !== null);
  if (reachable.length) problems.push([`aria-hidden скрывает ${reachable.length} фокусируемых`, el]);
}

// 3. Виджет без имени или недостижимый с клавиатуры
for (const el of document.querySelectorAll(WIDGETS)) {
  const named = el.getAttribute('aria-label')?.trim() || el.getAttribute('aria-labelledby') || el.textContent.trim();
  if (!named) problems.push(['виджет без доступного имени', el]);
  const composite = el.closest('[role="tablist"], [role="menu"], [role="menubar"], [role="listbox"]');
  if (el.tabIndex < 0 && !composite) problems.push(['виджет недостижим с клавиатуры', el]);
}

console.table(problems.map(([text]) => ({ Проблема: text })));
problems.forEach(([text, el]) => console.log(text, el));

Шаг 4. Автопроверка (60 с). Расширение axe DevTools или консольная команда:

# Проверка страницы по критериям WCAG 2.2 уровней A и AA
npx @axe-core/cli https://example.com --tags wcag2a,wcag2aa,wcag22aa

Помните ограничение: автоматика ловит около трети проблем, в основном формальные — битые ссылки атрибутов, недопустимые роли, отсутствующие имена. «Роль соответствует смыслу» ни один линтер не проверит.

Шаг 5. Послушать (90 с). Windows: NVDA (бесплатный) плюс Firefox, Insert+вниз читает с текущего места, F7 открывает список элементов. macOS: VoiceOver по Cmd+F5, ротор — VO+U. Пройдите по своему компоненту и спросите себя: из озвучки понятно, что это и что с этим делать? Если нет — вопрос не в ARIA, а в том, что интерфейс не объясняет себя.

Мини-итог

  • ARIA описывает семантику для дерева доступности и не даёт ни фокуса, ни клавиатуры, ни стилей.
  • Первое правило — не использовать ARIA там, где есть нативный элемент: HTML даёт правильную семантику во всех браузерах и AT сразу и бесплатно.
  • Три кита: роль (что это), имя (как называется), состояние (что с ним сейчас). Отсутствие любого — нарушение WCAG 2.2 SC 4.1.2. Имя считается по приоритету aria-labelledbyaria-label → нативная подпись → titleplaceholder, и битый id обнуляет его молча.
  • Состояние ставится на управляющий элемент: aria-expanded на кнопке, aria-selected на опции, aria-current на текущей ссылке.
  • Живые области готовятся заранее пустым контейнером: role="status" для обычных сообщений, role="alert" — только для настоящих ошибок.
  • aria-hidden прячет от AT, role="presentation" снимает семантику, inert выключает поддерево целиком — под модалкой нужен именно inert.
  • Сложные виджеты — это недели работы и обязательное ручное тестирование; библиотека дешевле самописного решения. Проверять надо не код, а дерево доступности и звук.

Источники

Что дальше

ARIA описывает, чем элемент является. Но пока до элемента нельзя дойти с клавиатуры, все роли и состояния бесполезны — правило 3 не зря стоит в середине списка. Следующая статья про то, как устроен порядок обхода, где теряется фокус, как удерживать его в модалке и не запирать в ловушке: Клавиатура и фокус: порядок обхода, ловушки фокуса, skip-links.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов