Доступность сложных компонентов: что ломается первым
Комбобокс на 10 000 опций перестаёт объявляться скринридером, live-region спамит уведомлениями, фокус улетает в никуда. Разбираем, где доступность рвётся под нагрузкой данными и частыми обновлениями DOM.

Комбобокс с автодополнением работал идеально на демо с двадцатью городами. В проде в него подгрузили справочник на 12 000 позиций — и VoiceOver перестал объявлять активный вариант при навигации стрелками. Пользователь слышит тишину, хотя визуально подсветка бегает по списку. Причина не в разметке ARIA: она валидна. Причина в том, что доступность ломается не от неверных атрибутов, а от объёма данных, частоты обновлений DOM и порядка операций — ровно там, где обычные тесты на пустом стенде ничего не показывают.
Разберём, что именно отказывает первым в сложных интерактивных компонентах — комбобоксах, виртуализированных списках, живых лентах, — когда данных много и они меняются часто. Речь про поведение под нагрузкой, а не про базовую расстановку aria-*: предполагается, что роли и связи у вас уже расставлены корректно.
Почему валидная ARIA перестаёт работать
Скринридер не читает DOM напрямую. Браузер строит из DOM отдельное дерево доступности1, а вспомогательная технология читает уже его через platform accessibility API (UIA на Windows, AX API на macOS). Между вашим изменением DOM и тем, что услышит пользователь, стоят два асинхронных слоя: пересборка дерева доступности браузером и доставка события в скринридер.
На маленьком компоненте эта цепочка укладывается в единицы миллисекунд, и вы её не замечаете. Под нагрузкой каждое звено превращается в узкое место:
- дерево доступности пересобирается на каждую заметную мутацию DOM — при рендере 12 000
<li role="option">это десятки миллисекунд только на построение; - события
aria-activedescendantи обновления live-region ставятся в очередь; при частых изменениях очередь переполняется, и часть событий скринридер просто отбрасывает; - если DOM меняется быстрее, чем скринридер успевает озвучить предыдущее уведомление, старое озвучивание прерывается — пользователь слышит обрывки.
Доступность под нагрузкой — это не про то, правильные ли у вас атрибуты. Это про то, успевает ли скринридер догнать ваш DOM. Обычно не успевает.
Что ломается первым: четыре сценария
1. Виртуализация против дерева доступности
Виртуальный скролл рендерит только видимые строки — 15 из 12 000. Для скринридера это значит, что в списке физически существует 15 элементов, а не 12 000. Навигация по aria-activedescendant ломается, как только активный элемент выходит за пределы отрендеренного окна: DOM-узла с нужным id больше нет, и указатель ведёт в никуда.
Симптом: пользователь жмёт стрелку вниз, доходит до края видимого окна, список визуально проскроллился — а скринридер замолчал, потому что aria-activedescendant ссылается на id, которого уже нет в DOM.
Что делать: держите aria-setsize и aria-posinset на каждом отрендеренном option, чтобы скринридер знал реальный размер набора и позицию. И обновляйте aria-activedescendant только после того, как целевая строка отрендерена — не до.
<ul role="listbox" id="cities">
<!-- отрендерено только видимое окно, но setsize сообщает полный размер -->
<li role="option" id="opt-340" aria-setsize="12000" aria-posinset="341">Казань</li>
<li role="option" id="opt-341" aria-setsize="12000" aria-posinset="342">Калуга</li>
</ul>2. Live-region, который спамит
Живая лента (уведомления, чат, счётчик) с aria-live="polite" на контейнере кажется безопасной. Пока сообщение приходит раз в минуту. Когда их прилетает по десять в секунду, скринридер либо зачитывает всё подряд минутами после того, как контент уже устарел, либо, при assertive, обрывает каждое предыдущее уведомление на полуслове.
Симптом: пользователь не может пользоваться остальной страницей, потому что скринридер непрерывно проговаривает поток обновлений и не даёт вставить ни слова.
Что делать: не вешайте aria-live на поток целиком. Агрегируйте — вместо «пришло сообщение», «пришло сообщение», «пришло сообщение» объявляйте «3 новых сообщения» с задержкой в 300–500 мс через дебаунс. Live-region должен содержать сводку, а не сырой поток.
3. Потеря фокуса при перерисовке
Когда данные обновляются и React (или любой другой фреймворк) перерисовывает поддерево, DOM-узел с фокусом может быть заменён новым узлом. Браузер в этот момент сбрасывает фокус на <body>. Для зрячего это незаметно. Для клавиатурного пользователя — катастрофа: следующий Tab начинает обход с начала страницы.
Под нагрузкой это происходит постоянно, потому что фоновые обновления данных перерисовывают компоненты, пока пользователь в них работает.
Что делать: сохраняйте позицию фокуса до обновления и восстанавливайте после. Ключ — стабильные ключи списка (key) и стабильные id у фокусируемых элементов, чтобы фреймворк не пересоздавал узел без нужды. Проверяйте это отдельным тестом, а не на глаз.
4. Дребезг активного элемента при быстром вводе
Пользователь печатает в комбобоксе, каждое нажатие клавиши инициирует запрос и перерисовку списка. Если между обновлением DOM и объявлением активного элемента нет синхронизации, скринридер объявляет вариант из уже устаревшего набора — тот, которого в списке больше нет.
Как это измерять
Автоматические линтеры (axe, Lighthouse) проверяют статику: есть ли роли, связаны ли label. Они не ловят ничего из описанного выше, потому что проблема во времени и порядке событий. Нужны другие замеры.
| Что мерить | Чем | Тревожный порог |
|---|---|---|
| Время пересборки дерева доступности после мутации | Performance-панель DevTools, вкладка Rendering | десятки мс на одну мутацию — уже заметно |
| Частота обновлений live-region | лог собственных вызовов обновления | чаще ~1 раза в 500 мс |
| Сброс фокуса на body | слушатель focusout с проверкой relatedTarget | любой незапланированный переход на body |
| Реальное поведение | ручной прогон с NVDA / VoiceOver под полным объёмом данных | обрывы озвучки, тишина при навигации |
Последняя строка — единственная, которая ловит настоящие проблемы. Ни один автотест не заменит прогон с включённым скринридером на данных продового объёма. Тестируйте на том количестве строк, которое реально бывает в проде, а не на трёх демо-записях.
Где подход ломается
Даже при аккуратной работе с фокусом и live-region остаются граничные случаи, где вы упираетесь в поведение самих скринридеров, а не в свой код:
- Разные движки ведут себя по-разному. NVDA, JAWS и VoiceOver обрабатывают
aria-activedescendantи очередь live-events неодинаково. Компонент, который звучит корректно в NVDA, может молчать в VoiceOver на том же DOM. - Виртуализация и
aria-setsize— компромисс, а не решение. Пользователь скринридера не может по-настоящему «пролистать глазами» 12 000 позиций. Даже с корректной разметкой навигация по такому списку неудобна. Иногда правильный ответ — не чинить виртуализацию, а добавить фильтрацию, чтобы набор физически стал меньше. - Дебаунс live-region теряет данные для тех, кто читает быстро. Агрегируя уведомления, вы сознательно жертвуете частью деталей в пользу того, чтобы поток вообще был воспринимаем. Это осознанный компромисс, и для критичных сообщений (ошибка, требующая действия) он не годится — их объявляйте отдельно и
assertive.
Что делать дальше
Начните с одного замера: повесьте слушатель focusout, который логирует переходы фокуса на <body>, и погоняйте компонент под фоновыми обновлениями данных. Если в логе появляются такие переходы — у вас уже есть недоступный сценарий, который автотесты не видят.
Дальше — прогон с NVDA (бесплатный, Windows) или VoiceOver (встроен в macOS) на полном объёме данных. Инвалидация и синхронизация асинхронных обновлений — отдельная большая тема, здесь мы разбирали только то, что ломается первым под нагрузкой. За деталями ARIA-паттернов идите в спецификацию APG: там описано ожидаемое поведение каждого компонента, от которого стоит отталкиваться при отладке.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: ARIA Authoring Practices Guide (APG) — паттерны компонентов, MDN — Accessibility tree
Частые вопросы
У меня всё зелёное в axe и Lighthouse — значит, с доступностью порядок?
А если компонент звучит нормально в NVDA, но молчит в VoiceOver?
Не сработало восстановление фокуса после перерисовки — что проверить?
Стоит ли вообще делать виртуализированный список доступным или проще отказаться от виртуализации?
Как часто можно обновлять aria-live, чтобы скринридер успевал?
Работает ли всё описанное с любой версией фреймворков?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.