ARIA: роли, свойства, состояния и правило «лучше без ARIA»
ARIA (Accessible Rich Internet Applications) — набор HTML-атрибутов, которыми вы объясняете вспомогательным технологиям, чем элемент на экране является и в каком он сейчас состоянии. Не «как выглядит» и не «что делает по клику»: кнопка это, вкладка, список или диалог; развёрнут или свёрнут; выбран или нет.
Это самый недопонятый инструмент во всей доступности. Половина команд считает ARIA волшебной присыпкой, которая «делает интерфейс доступным», и щедро посыпает ей div-ы. Вторая половина слышала, что «ARIA — это плохо», и обходит её стороной. Правда посередине и очень конкретна: ARIA — точный хирургический инструмент, нужный там, где HTML не выражает семантику, и вредный везде, где HTML её уже выражает.
Общая картина трека — во вводном Гайде по Accessibility, а фундамент этой статьи разобран в Семантике: дальше я исхожу из того, что вы уже согласны, что <button> лучше <div onclick>. Здесь — про то, что делать, когда нативного элемента объективно не хватает.
Про тон. Дальше много про скринридеры, но доступность — не отдельная благотворительная задача «поддержать незрячих». Это нормальное качество продукта: честная семантика одинаково помогает человеку с NVDA, человеку с голосовым управлением, человеку, который временно держит телефон одной рукой, и вашему же e2e-тесту, который ищет элемент по роли и имени. Пользователи скринридеров — эксперты в своём инструменте: слушают речь на скорости, которую вы не разберёте, и мгновенно замечают, где интерфейс врёт про себя.
Что ARIA делает и чего не делает
ARIA состоит ровно из трёх вещей:
role— какого типа элемент:role="button",role="tab",role="dialog".- Свойства — относительно статичные характеристики:
aria-label,aria-labelledby,aria-describedby,aria-haspopup,aria-required. - Состояния — то, что меняется по ходу взаимодействия:
aria-expanded,aria-checked,aria-pressed,aria-selected,aria-disabled,aria-invalid.
Граница между свойствами и состояниями в спецификации есть, но на практике неважна: все они атрибуты aria-* и читаются одинаково. Важно другое — чего ARIA не делает: не добавляет фокус (role="button" не делает div фокусируемым), не добавляет обработку клавиатуры, не меняет ни пиксела на экране, не меняет поведение браузера. ARIA пишет только в дерево доступности; всё остальное — ваша работа.
Классический пример того, как это ломается:
<!-- Выглядит как кнопка, скринридер говорит «кнопка Удалить». -->
<!-- И при этом сюда нельзя попасть с клавиатуры и нельзя нажать. -->
<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. Порядок приоритетов надо знать наизусть:
и все id разрешаются?"} B -- да --> B1["Имя = текст перечисленных
элементов в порядке ссылок"] B -- нет --> C{"Есть непустой aria-label?"} C -- да --> C1["Имя = значение aria-label"] C -- нет --> D{"Есть нативная подпись?"} D -- да --> D1["label, alt, caption, legend,
содержимое кнопки или ссылки"] D -- нет --> E{"Есть title?"} E -- да --> E1["Имя = title.
Запасной вариант, не план"] E -- нет --> F{"Поле ввода
с placeholder?"} F -- да --> F1["Имя = placeholder.
Худший вариант: исчезает при вводе"] F -- нет --> G["Имени нет.
Скринридер скажет только роль"]
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") снимает семантику самого элемента, оставляя детей; подвох в том, что обязательные потомки роли (table → row → cell, list → listitem) тоже станут презентационными, а на фокусируемом элементе роль просто игнорируется. 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 — он звучит как «переключатель, включено».
Комбобокс: почему сложные виджеты дорогие
Разметка минимального комбобокса по паттерну 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-labelledby→aria-label→ нативная подпись →title→placeholder, и битыйidобнуляет его молча. - Состояние ставится на управляющий элемент:
aria-expandedна кнопке,aria-selectedна опции,aria-currentна текущей ссылке. - Живые области готовятся заранее пустым контейнером:
role="status"для обычных сообщений,role="alert"— только для настоящих ошибок. aria-hiddenпрячет от AT,role="presentation"снимает семантику,inertвыключает поддерево целиком — под модалкой нужен именноinert.- Сложные виджеты — это недели работы и обязательное ручное тестирование; библиотека дешевле самописного решения. Проверять надо не код, а дерево доступности и звук.
Источники
- WAI-ARIA 1.2, Using ARIA, ARIA in HTML, AccName 1.2 — спецификации.
- ARIA Authoring Practices Guide — эталонные паттерны компонентов.
- WCAG 2.2 и Quick Reference; MDN: ARIA и MDN: ARIA Live Regions.
- a11ysupport.io — фактическая поддержка атрибутов в парах «браузер плюс AT»; WebAIM Million — ежегодная статистика ошибок.
- Блог Скотта О’Хары и блог Адриана Розелли — разборы того, где паттерны расходятся с реальностью.
- Смежное на портале: семантика HTML, DOM и события, формы и валидация, E2E и UI-тестирование.
Что дальше
ARIA описывает, чем элемент является. Но пока до элемента нельзя дойти с клавиатуры, все роли и состояния бесполезны — правило 3 не зря стоит в середине списка. Следующая статья про то, как устроен порядок обхода, где теряется фокус, как удерживать его в модалке и не запирать в ловушке: Клавиатура и фокус: порядок обхода, ловушки фокуса, skip-links.