Доступные интерактивные компоненты
Прежде чем писать role="tablist", спросите себя: может, здесь хватит <button> и <details>? Разбираем клавиатурные контракты трёх самых частых виджетов.
Первое правило ARIA: не используйте ARIA. Если задачу решает нативный HTML-элемент с подходящей семантикой и поведением — берите его. Он приносит роль, состояние и клавиатуру бесплатно.
Зачем это нужно на практике
Главное недоразумение вокруг ARIA: люди думают, что она что-то делает. Не делает. ARIA (Accessible Rich Internet Applications) — это набор атрибутов, которые меняют только то, что скринридер произносит. Ни одна клавиша от них не заработает, ни один элемент не станет фокусируемым, ни одна панель не откроется.
Напишите role="button" на <div> — скринридер честно скажет «кнопка». Пользователь нажмёт Tab, чтобы до неё дойти… и не дойдёт: <div> не фокусируется. Добавите tabindex="0" — дойдёт, нажмёт Enter, и ничего не произойдёт: <div> не реагирует на Enter. Добавите обработчик Enter — останется Space, который у кнопок тоже обязан работать, а по умолчанию просто прокручивает страницу. Вы уже написали 15 строк, чтобы получить то, что <button> даёт нулём строк — и лучше, потому что заодно умеет disabled, отправку формы и работу в режиме высокой контрастности.
Отсюда практическое правило: ARIA — это не улучшение, а компенсация. Она нужна там, где нативного элемента с таким поведением просто нет.
Что уже есть в HTML
| Задача | Нативно | Что получаете бесплатно |
| Действие на странице | <button type="button"> | Фокус, Enter, Space, роль, disabled |
| Переход по адресу | <a href> | Фокус, Enter, «открыть в новой вкладке», контекстное меню |
| Раскрывающийся блок | <details><summary> | Фокус, Enter/Space, состояние «развёрнуто» |
| Выбор из списка | <select> | Стрелки, поиск по первой букве, нативный пикер на телефоне |
| Флажок, переключатель | <input type="checkbox"/"radio"> | Space, стрелки внутри группы, состояние, отправка в форме |
| Модальное окно | <dialog> + showModal() | Ловушка фокуса, Escape, отключение фона |
| Таб-панель | нативного нет | Роли, состояния и клавиши пишете сами |
| Комбобокс с автодополнением | нативного нет | Полноценный ARIA-паттерн |
Верхние шесть строк — те случаи, где ARIA не нужна вовсе. Нижние две — где без неё не обойтись.
Аккордеон: почти нативный
Аккордеон — это набор заголовков-кнопок, каждая из которых разворачивает свою панель. Клавиатурный контракт минимальный:
| Клавиша | Что делает |
| Tab / Shift+Tab | Переход между заголовками и элементами внутри раскрытых панелей |
| Enter / Space | Развернуть или свернуть панель текущего заголовка |
| ↑ / ↓, Home / End | Необязательно: переход между заголовками аккордеона |
Обратите внимание: Tab здесь ходит обычным образом, стрелки — приятный бонус. Это самый простой из трёх виджетов, и большую его часть даёт нативный <details>:
<details name="faq">
<summary>Как отменить подписку?</summary>
<p>В разделе «Оплата» нажмите «Отменить»…</p>
</details>
<details name="faq">
<summary>Вернут ли деньги за неиспользованный период?</summary>
<p>Да, пропорционально остатку…</p>
</details>
Одинаковый атрибут name делает группу эксклюзивной: открытие одного пункта закрывает остальные — ровно то, чего обычно и хотят от аккордеона. Фокус, Enter, Space и объявление состояния уже внутри.
Если нужен полный контроль над анимацией и разметкой, собирайте руками — но по тому же принципу: кнопка, а не <div> с обработчиком клика.
<h3>
<button type="button"
id="acc-1"
aria-expanded="false"
aria-controls="panel-1">
Как отменить подписку?
</button>
</h3>
<div id="panel-1" role="region" aria-labelledby="acc-1" hidden>
<p>В разделе «Оплата» нажмите «Отменить»…</p>
</div>
document.querySelectorAll('[aria-controls][aria-expanded]').forEach(btn => {
btn.addEventListener('click', () => {
const panel = document.getElementById(btn.getAttribute('aria-controls'));
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded; // hidden, а не opacity: иначе фокус уйдёт в скрытую панель
});
});
Два момента, на которых ошибаются: aria-expanded живёт на кнопке (это она развёрнута/свёрнута с точки зрения пользователя), а не на панели. И панель скрывается атрибутом hidden — если спрятать её через opacity: 0 или height: 0, ссылки внутри останутся в порядке обхода, и Tab провалится в невидимое.
Таб-панель: здесь ARIA обязательна
Нативных табов в HTML нет, поэтому весь контракт — на вас. И он строже, чем у аккордеона: Tab не ходит по вкладкам. Он один раз заводит фокус в группу вкладок, дальше переключение идёт стрелками, а следующий Tab уводит в содержимое панели. Это называется roving tabindex — «кочующий tabindex».
| Клавиша | Что обязано произойти |
| Tab | Фокус входит в группу вкладок — сразу на активную вкладку (не на первую!) |
| ← / → | Переход между вкладками, с зацикливанием: с последней на первую |
| Home / End | Первая / последняя вкладка |
| Tab ещё раз | Фокус уходит из группы в содержимое открытой панели |
| Enter / Space | Активирует вкладку — если выбрана ручная активация |
Про последнюю строчку. Есть два режима. Автоматическая активация: панель переключается сразу при переходе стрелкой. Хорошо для лёгких панелей. Ручная активация: стрелки только перемещают фокус, панель открывается по Enter или Space. Обязательна, если содержимое панели тяжёлое или грузится с сервера — иначе пользователь, проходящий стрелками до пятой вкладки, запустит четыре ненужных запроса.
<div role="tablist" aria-label="Настройки проекта">
<button type="button" role="tab"
id="tab-general" aria-controls="panel-general"
aria-selected="true" tabindex="0">Общие</button>
<button type="button" role="tab"
id="tab-access" aria-controls="panel-access"
aria-selected="false" tabindex="-1">Доступ</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general" tabindex="0">
<p>Название, описание, обложка проекта.</p>
</div>
<div role="tabpanel" id="panel-access" aria-labelledby="tab-access" tabindex="0" hidden>
<p>Участники и их роли.</p>
</div>
Ключевая деталь разметки: tabindex="0" ровно у одной вкладки — активной. У остальных tabindex="-1": они не в обходе Tab, но доступны стрелками и через focus(). Именно это позволяет Tab «перепрыгнуть» весь набор вкладок целиком, а не пройти по каждой.
const tablist = document.querySelector('[role="tablist"]');
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
function selectTab(tab) {
tabs.forEach(t => {
const selected = t === tab;
t.setAttribute('aria-selected', String(selected));
t.tabIndex = selected ? 0 : -1; // roving tabindex
document.getElementById(t.getAttribute('aria-controls')).hidden = !selected;
});
tab.focus();
}
tablist.addEventListener('keydown', (e) => {
const i = tabs.indexOf(document.activeElement);
if (i < 0) return;
let next = null;
if (e.key === 'ArrowRight') next = tabs[(i + 1) % tabs.length];
if (e.key === 'ArrowLeft') next = tabs[(i - 1 + tabs.length) % tabs.length];
if (e.key === 'Home') next = tabs[0];
if (e.key === 'End') next = tabs[tabs.length - 1];
if (next) {
e.preventDefault(); // иначе Home/End прокрутят страницу
selectTab(next); // автоматическая активация
}
});
tabs.forEach(tab => tab.addEventListener('click', () => selectTab(tab)));
Оператор % здесь и даёт зацикливание: с последней вкладки стрелка вправо ведёт на первую. Пользователь не должен упираться в стену.
Выпадающее меню: два разных виджета под одним словом
Тут ошибаются чаще, чем везде. «Выпадающее меню» в вебе — это два совершенно разных паттерна с разными клавиатурными контрактами. Перепутать их — значит сломать компонент вернее, чем если бы вы не трогали ARIA вообще.
1. Disclosure — кнопка, открывающая блок
Это 90% того, что называют меню: пункт шапки, раскрывающий подпункты; кнопка «Ещё ▾» со списком ссылок; фильтр, раскрывающий чекбоксы. Внутри — ссылки или обычные контролы. Никаких role="menu".
<button type="button" aria-expanded="false" aria-controls="submenu">
Обучение
</button>
<ul id="submenu" hidden>
<li><a href="/tutorials">Учебники</a></li>
<li><a href="/paths">Маршруты</a></li>
<li><a href="/challenges">Задачи</a></li>
</ul>
Контракт: Enter/Space открывает, Tab ходит по ссылкам как обычно, Escape закрывает и возвращает фокус на кнопку. Всё. Это та же механика, что у аккордеона, — и в этом её прелесть: она предсказуема и почти ничего не требует.
2. Menu button — меню приложения
Роли menu и menuitem описывают меню как в десктопной программе: «Файл», «Правка», контекстное меню по правой кнопке. Взяв эти роли, вы подписываетесь на весь контракт:
| Клавиша | Что обязано произойти |
| Enter / Space / ↓ на кнопке | Открыть меню и поставить фокус на первый пункт |
| ↑ / ↓ | Переход между пунктами с зацикливанием (пункты — не в обходе Tab!) |
| Home / End | Первый / последний пункт |
| Enter | Активировать пункт и закрыть меню |
| Escape | Закрыть меню, вернуть фокус на кнопку |
| Tab | Закрыть меню и уйти к следующему элементу страницы |
Типичная катастрофа выглядит так: разработчик вешает role="menu" на <ul> навигации и role="menuitem" на ссылки, потому что «это же меню». Скринридер послушно объявляет: «меню, пункт 1 из 5» — и пользователь, зная контракт, жмёт стрелку вниз. Ничего. Стрелки никто не написал. Жмёт Tab — а роль menuitem в некоторых связках вырезает пункты из обычного обхода. Человек застрял в виджете, который обещал одно поведение и не выполняет никакого.
Практическое правило: если внутри — ссылки на другие страницы, это не menu. Это disclosure. Роль menu берите только для меню команд, где вы готовы написать стрелки, Home/End, Escape и roving tabindex.
Как это работает
Браузер строит дерево доступности (accessibility tree) — параллельную DOM структуру, которую видит скринридер. У каждого узла там есть роль («кнопка», «вкладка»), имя («Сохранить») и состояния («развёрнуто», «выбрано»). ARIA-атрибуты правят исключительно это дерево. Поведение — фокус, клавиши, показ и скрытие — остаётся полностью на JavaScript и HTML.
Отсюда логика распределения ролей внутри виджета:
aria-expanded— на элементе, которым управляют (кнопка), а не на том, чем управляют (панель).aria-controls— связывает управляющий элемент с управляемым поid. Ссылка на несуществующийid(частая беда после перерисовки компонента) молча ломает связь.aria-selected— про выбор одного из нескольких (вкладки),aria-checked— про флажки,aria-current="page"— про текущую страницу в навигации. Их путают постоянно.
Для перемещения фокуса внутри составного виджета есть два подхода. Roving tabindex (мы использовали его в табах): фокус физически переезжает, tabindex="0" кочует по элементам. Проще, работает везде. aria-activedescendant: фокус остаётся на контейнере, а атрибут указывает id «виртуально активного» потомка. Нужен там, где фокус обязан остаться в поле ввода — например, в комбобоксе с подсказками, где вы продолжаете печатать, перебирая варианты стрелками.
Эталонные контракты для всех этих виджетов не надо изобретать: они описаны в ARIA Authoring Practices Guide (APG) — там для каждого паттерна есть таблица обязательных клавиш. Сверяйтесь с ней, а не с интуицией.
Частые ошибки
<div onclick>вместо<button>. Корень половины проблем: нет фокуса, нет Enter, нет Space, нет роли.role="button"без обработки Space. Даже если добавилиtabindexи Enter — Space по умолчанию прокручивает страницу, и его надо перехватывать черезpreventDefault().- ARIA как украшение. Роли расставлены, клавиши не написаны. Скринридер обещает пользователю поведение, которого нет, — и это хуже, чем отсутствие ролей.
role="menu"на навигации со ссылками. Классика. Нужен disclosure: кнопка +aria-expanded+ обычный список ссылок.aria-expandedна панели вместо кнопки. Состояние принадлежит тому элементу, который пользователь нажимает.- Меню закрывается по
blur. Кажется удобным: ушёл фокус — закрыли. На практике клавиатурный пользователь не может войти внутрь: первый же Tab уводит фокус в пункт, срабатываетblurкнопки, меню схлопывается. - Панель спрятана через
opacity: 0илиvisibility-хаки. Ссылки внутри остаются фокусируемыми — Tab проваливается в невидимое. Используйтеhidden,display: noneилиinert. - Все вкладки с
tabindex="0". Tab перебирает их по одной вместо стрелок — контракт таб-панели нарушен, а пользователь, привыкший к стрелкам, решит, что виджет сломан. - Нет
preventDefault()на Home/End и стрелках. Виджет переключается, а страница заодно прокручивается в начало. - Escape не возвращает фокус на кнопку-триггер. Меню закрылось, фокус остался на удалённом пункте и улетел на
<body>.
Итоги
- ARIA ничего не делает — она только меняет то, что произносит скринридер. Клавиши и фокус пишете вы.
- Начинайте с нативного элемента:
<button>,<a href>,<details>,<select>,<dialog>. Роль, состояние и клавиатура — бесплатно. - Аккордеон: Tab по заголовкам, Enter/Space переключает,
aria-expanded— на кнопке. - Таб-панель: Tab заводит в группу, стрелки и Home/End переключают, roving tabindex — только у активной вкладки
tabindex="0". - «Выпадающее меню» — почти всегда disclosure (кнопка + список ссылок + Escape), а не
role="menu". - Взяли ARIA-роль — обязаны выполнить весь её клавиатурный контракт. Полумеры хуже, чем отсутствие ролей.
- Скрывайте панели через
hidden/display: none, иначе фокус уйдёт в невидимое. - Проверка: уберите мышь и пройдите виджет Tab, стрелками, Enter, Space, Escape. Всё, что не сработало, — ваш баг-лист.