Доступность комбобокса: как устроено дерево доступности внутри
Скринридер объявляет ваш кастомный dropdown как «группа, пусто» вместо списка опций. Разбираем, как браузер строит дерево доступности и почему div с onClick для него не существует.

Пользователь с NVDA открывает ваш кастомный select — красивый, с анимацией, собранный на div и span. Скринридер объявляет: «группа». Ни количества опций, ни того, что элемент раскрывается, ни выбранного значения. Для программы чтения с экрана ваш компонент — пустая коробка. Разберём, что именно видит скринридер, откуда он это берёт и почему семантически правильная кнопка объявляется сама, а div с обработчиком клика — нет.
Что стоит между вашим DOM и скринридером
Между деревом DOM и программой чтения с экрана стоит промежуточная структура — дерево доступности (accessibility tree). Браузер строит его из DOM, вычисленных стилей и ARIA-атрибутов, а затем публикует через платформенные API: UI Automation в Windows, AX API в macOS, AT-SPI в Linux. Скринридер читает не ваш HTML — он читает это дерево через платформенный API.
Каждый узел дерева доступности несёт три вещи, на которых держится вся озвучка:
- role — роль:
button,listbox,option,checkbox. Определяет, как пользователь взаимодействует и что ожидать. - name — доступное имя: то, что скринридер произносит вслух. Вычисляется по алгоритму accessible name computation.
- state/properties — состояния и свойства:
expanded,checked,disabled,selected,aria-activedescendant.
Нативная <button> получает роль button автоматически — браузер знает про этот тег. <div> получает роль generic (или вовсе выпадает из дерева, если у него нет ни имени, ни интерактивности). Отсюда «группа/пусто»: браузеру неоткуда взять роль и состояние.
Accessible name computation — стандартизированный алгоритм (спецификация W3C Accessible Name and Description Computation 1.2), по которому браузер вычисляет доступное имя элемента. Порядок приоритета: aria-labelledby → aria-label → содержимое элемента / связанный <label> → title. Первый непустой источник побеждает.
Как имя вычисляется на самом деле
Самая частая причина «немого» компонента — не отсутствие ARIA, а конфликт источников имени. Алгоритм идёт по приоритету и останавливается на первом сработавшем. Если вы повесили aria-label, то текст внутри элемента для имени уже игнорируется.
Разберём combobox из паттерна ARIA APG. Триггер и список — разные узлы, связанные атрибутами:
<!-- Combobox: кнопка раскрывает listbox -->
<label id="lbl-city">Город доставки</label>
<button
id="combo-city"
role="combobox"
aria-labelledby="lbl-city"
aria-expanded="false"
aria-controls="list-city"
aria-haspopup="listbox">
Москва
</button>
<ul id="list-city" role="listbox" aria-labelledby="lbl-city">
<li role="option" id="opt-msk" aria-selected="true">Москва</li>
<li role="option" id="opt-spb" aria-selected="false">Санкт-Петербург</li>
</ul>
Здесь aria-labelledby="lbl-city" побеждает текст «Москва» внутри кнопки. Скринридер произнесёт «Город доставки, комбинированный список, свёрнут» — а не «Москва». Значение «Москва» уйдёт в отдельную ветку озвучки как текущее значение. Если вы хотели, чтобы читалось и то и другое, aria-labelledby должен ссылаться на оба узла: aria-labelledby="lbl-city combo-city".
Почему aria-activedescendant, а не focus
В combobox фокус клавиатуры остаётся на кнопке или поле ввода, но визуально подсвечивается опция в списке. Как скринридер узнаёт, какую опцию читать? Через aria-activedescendant: атрибут на активном элементе указывает ID «виртуально сфокусированного» потомка.
// При навигации стрелкой вниз меняем не DOM-фокус, а указатель
combobox.setAttribute('aria-activedescendant', 'opt-spb');
// Опция должна иметь состояние выбора
spbOption.setAttribute('aria-selected', 'true');
mskOption.setAttribute('aria-selected', 'false');
Браузер видит изменение атрибута, обновляет соответствующий узел в дереве доступности и шлёт платформенное событие. Скринридер ловит его и объявляет: «Санкт-Петербург, 2 из 2». Реальный DOM-фокус при этом никуда не двигался. Если бы вы двигали focus() по опциям, вы потеряли бы поле ввода — и сломали бы ввод текста в комбобоксе с фильтрацией.
Что запускает перечитывание: события дерева доступности
Ключевая деталь, которую пропускают: изменение атрибута само по себе ничего не озвучивает автоматически по всему экрану. Браузер эмитит платформенные события, а скринридер решает, что читать. Основные события, которые генерирует смена ARIA-состояний:
- смена
aria-expanded→ событие изменения состояния, скринридер говорит «раскрыт» / «свёрнут»; - смена
aria-activedescendant→ событие смены фокуса, читается новая активная опция; - смена
aria-checked/aria-selected→ событие изменения свойства; - изменение контента в
aria-live-области → событие для отложенного объявления.
Отсюда важное следствие: если вы меняете aria-expanded в тот же тик, что и добавляете список в DOM, порядок событий может «слипнуться», и скринридер объявит раскрытие раньше, чем узнает о содержимом. На практике списки в паттерне combobox держат в DOM заранее (со скрытием через CSS), а не вставляют по клику — так дерево доступности стабильно.
Скринридер озвучивает не то, что вы нарисовали, а то, что браузер положил в дерево доступности. Между вашим кликом и словом «раскрыт» стоит платформенное событие — и если вы его не спровоцировали правильным ARIA-атрибутом, слова не будет.
Где дерево доступности ломается первым
Три класса проблем, которые встречаются в кастомных компонентах чаще всего.
Узел выпал из дерева
Браузер выкидывает из дерева доступности элементы с display: none, visibility: hidden, aria-hidden="true", а также узлы без роли и без имени. Частая ловушка: список позиционируется через opacity: 0 и pointer-events: none для анимации — визуально скрыт, но остаётся в дереве доступности. Скринридер прочитает опции невидимого списка. Для скрытия от AT нужен именно aria-hidden или снятие из потока, а не прозрачность.
Роль конфликтует с содержимым
Элемент с role="listbox" должен содержать только потомков с ролью option (или группы). Если внутрь listbox попал <button> «Очистить» или заголовок-<div> без роли, часть скринридеров пропустит их при навигации, потому что модель роли этого не предусматривает. Служебные элементы выносят за пределы listbox.
Имя вычислилось не из того источника
Иконка внутри кнопки с alt="стрелка вниз" склеится с текстом кнопки в доступное имя: «Москва стрелка вниз». Декоративные иконки помечают aria-hidden="true" или пустым alt="", чтобы они не попали в вычисление имени.
Чем это смотреть
Не полагайтесь на догадки — дерево доступности можно увидеть напрямую.
| Инструмент | Что показывает | Где |
|---|---|---|
| DevTools → Elements → Accessibility | Computed name, role, свойства, источник имени | Chrome, Edge |
| Accessibility Inspector | Полное дерево доступности через AX API | macOS (Xcode) |
| Accessibility панель | Дерево и вычисленное имя | Firefox DevTools |
| NVDA + Speech Viewer | Что реально произносится и в каком порядке | Windows, бесплатно |
Панель Accessibility в Chrome показывает поле «Computed Properties» и, что важнее, откуда взялось имя — так вы сразу видите, что aria-label перебил ваш текст. Но финальную проверку делайте живым скринридером: браузерная панель показывает дерево, а не порядок и полноту озвучки.
Что делать дальше
Возьмите один свой сложный компонент — combobox, tabs или tree — и откройте его в панели Accessibility. Сверьте роль, имя и состояния с эталонным паттерном из ARIA Authoring Practices Guide: там для каждого виджета расписаны требуемые роли, атрибуты и клавиатурные события. Затем прогоните навигацию клавиатурой с включённым NVDA или VoiceOver и слушайте, что объявляется при раскрытии и при переходе по опциям. Расхождение между тем, что вы видите в панели, и тем, что слышите, — и есть точка, где событие дерева доступности не сработало.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: ARIA Authoring Practices Guide — Patterns, Accessible Name and Description Computation 1.2 (W3C)
Частые вопросы
У меня VoiceOver на macOS читает компонент иначе, чем NVDA на Windows. Это нормально?
Я добавил aria-label, но текст внутри кнопки всё равно не читается. Почему?
Список у меня появляется по клику через вставку в DOM. Скринридер иногда не объявляет опции. Что не так?
Достаточно ли пройти автоматический аудит вроде axe или Lighthouse?
Не проще ли использовать нативные элементы вместо ARIA?
aria-hidden скрывает элемент, но его всё равно можно сфокусировать табом. Это баг?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.