Доступные интерактивные компоненты

Прежде чем писать 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. Всё, что не сработало, — ваш баг-лист.
Проверьте себя
1. В шапке сайта есть кнопка «Обучение», раскрывающая список из трёх ссылок на другие страницы. Какая разметка правильная?
Arole="menu" на списке и role="menuitem" на каждой ссылке — это же меню
BDisclosure: <button aria-expanded aria-controls> плюс обычный <ul> со ссылками; Escape закрывает и возвращает фокус на кнопку
Crole="tablist" на кнопке и role="tabpanel" на списке
D<div onclick> с классом .menu — ARIA тут не нужна
2. Почему в таб-панели у неактивных вкладок ставят tabindex="-1"?
AЧтобы скринридер их не читал
BЧтобы они не отображались на экране
CЧтобы Tab не перебирал все вкладки по очереди: он один раз заводит фокус на активную вкладку, а переключение идёт стрелками (roving tabindex)
DЧтобы вкладки нельзя было активировать мышью