
Фокус и доступность модальных окон
Модальные окна кажутся тривиальной частью интерфейса, пока не случается ситуация, когда клавиатурная навигация пропадает, экранные читалки продолжают произносить фоновые элементы, а мобильная клавиатура закрывает важную форму. Часто именно некорректное управление фокусом и сосредоточенностью внимания приводит к плохой доступности, фремам в пользовательском опыте и ошибкам валидации. Проблема не только в правильных ARIA-атрибутах: за ней стоит сочетание DOM-порядка, порталов, блокировки прокрутки, анимаций и особенностей браузеров.
Модальное окно — визуальный компонент интерфейса, который временно перекрывает основной контент и требует взаимодействия или явного закрытия. Портал — способ рендерить ветку DOM вне иерархии компонента, обычно у корня документа, чтобы избежать ограничений стилей и контекстов наложения. Фокус-трап — механика удержания клавиатурной фокусировки внутри модального контейнера, чтобы переключение по Tab не выводило фокус на скрытый фон. ARIA — набор атрибутов, помогающих сделать интерфейсы доступными для вспомогательных технологий. inert — атрибут, который делает элемент и всё его содержимое недоступным для взаимодействия и фокуса.
Разберём корневые причины проблем и предложим практические подходы, которые работают в реальных проектах, где интерфейс работает на нескольких фронтенд-стэках, есть серверный рендеринг и множество сторонних виджетов.
Почему проблема сложнее, чем кажется
На первый взгляд задача — вставить role=»dialog», добавить aria-modal и закрывать модал по клавише Esc. Но это поверхностный уровень. Основные сложности приходят из пересечения нескольких факторов:
— Порядок в DOM и реальное наличие элементов. Помещение модального окна визуально поверх всего не меняет того, что остальной DOM всё ещё фокусируем и доступен для вспомогательных технологий.
— Порталы. Перенос модального DOM в корень документа решает проблемы с z-index, но нарушает ожидания о соседстве элементов: восстановление фокуса и логика поиска ближайших управляющих элементов усложняются.
— Скрипты сторонних библиотек. Виджеты с глобальными слушателями кликов или интерцепторами клавиатуры могут перехватывать события и ломать фокус-трап.
— Анимации и переходы. Если модальное окно появляется с задержкой из-за анимации, попытка установить фокус до завершения анимации может не сработать.
— Мобильные клавиатуры. На iOS и Android открытие клавиатуры меняет viewport и может сместить модал так, что видимая часть не содержит поля ввода.
— Скрытие фонового контента. aria-hidden скрывает для вспомогательных технологий, но не препятствует кликам или фокусу без inert; полифилы и браузерная поддержка распределены по-разному.
В итоге набор очевидных правил требует дополнения конкретными техническими приёмами и проверками в условиях продукта.
Ключевые элементы решения
Достаточный набор мер строится из нескольких взаимодополняющих блоков. Все они важны: отсутствие хотя бы одного способа часто оставляет уязвимость.
Рендер в порталы и управление фокусом
Портал полезен для корректного наложения модального окна поверх остального контента, особенно при сложных CSS-контекстах. Однако нужно согласовать два момента: куда возвращать фокус при закрытии и как определить логическое предшествующее место в DOM для восстановления. Рекомендация — на момент открытия сохранять активный элемент (document.activeElement) и при закрытии возвращать фокус именно ему, с учётом возможных изменений DOM (удален ли элемент — найти ближайший фокусируемый предок).
Фокус-трап и aria-modal
Фокус-трап (focus trap) удерживает циклически фокус внутри модального контейнера: при нажатии Tab из последнего фокусируемого элемента переводить фокус на первый и наоборот. aria-modal — атрибут, который сигнализирует вспомогательным технологиям, что остальной контент не должен восприниматься как активный. aria-modal не отменяет необходимости серверного или скриптового скрытия фонового контента от фокуса: сочетание aria-modal и inert/aria-hidden даёт более надёжный результат.
Использование inert
Inert — атрибут, делающий содержимое не фокусируемым и игнорируемым для взаимодействия. Пока поддержка браузерами не всегда одинаковая, полифилы помогают. Inert предпочтительнее манипуляций с tabindex=-1 на каждом элементе, особенно в больших приложениях с динамическими элементами.
Управление прокруткой и визуальная стабильность
Блокировка прокрутки (scroll lock) предотвращает работу фона при открытом модале. Простой подход body { overflow: hidden } ломает поведение мобильных клавиатур, где при открытии поля изменяется высота viewport. Более робустный метод — фиксировать контейнер с учётом текущей прокрутки: сохранять scrollTop, применять position: fixed; top: -scrollTop; и после закрытия возвращать скролл. Этот способ сохраняет положение страницы и предотвращает сдвиги.
Анимации и тайминги
Установка фокуса должна происходить после завершения финальной фазы появления, иначе браузер может потерять фокус или пользователю придётся повторять Tab. Использовать события transitionend/animationend либо Promises при использовании анимационных библиотек, чтобы отложить установку фокуса до готовности. Обратная ситуация: при закрытии и одновременной навигации — сохранять флаг закрытия и блокировать множественные попытки восстановления фокуса.
Работа с формами и мобильными клавиатурами
Автофокус на поле ввода в модале на мобильных может вызвать открытие клавиатуры и смещение. Решение — откладывать автофокус либо применять мягкую стратегию: устанавливать фокус после подтверждения, что пользователь ожидает ввода (например, после touchend), или предлагать явный элемент для фокусировки, а не сразу поле ввода.
Вложенные модальные окна и диалоги
Если в интерфейсе возможны вложенные модалы, нужно поддерживать стек состояний: при открытии нового модала предыдущий переводить в inert/aria-hidden и сохранять для восстановления после закрытия верхнего. Фокус-трап должен работать только для верхнего модала. Стек можно реализовать через централизованный менеджер модальных состояний.
Взаимодействие с маршрутами и SSR
При серверном рендеринге (SSR) DOM может отличаться от клиентского после гидрации. Открытие модала в момент навигации по маршруту требует синхронизации: предотвращать автоматическую попытку установить фокус, если идёт переход, и при завершении навигации повторять установку. Также учесть, что при быстром переключении маршрутов сохранённый элемент для восстановления фокуса может исчезнуть.
Тестирование доступности и фокусной логики
Ручное тестирование с клавиатурой и экранными читалками — необходимая часть. Автоматические тесты e2e с программной эмуляцией Tab и проверкой document.activeElement помогают обнаружить регресcии. Тестовые сценарии должны включать: открытие, циклическая табуляция, закрытие через Esc, закрытие кликом вне, работа с вложенными модалами, восстановление фокуса, анимации.
Практические шаги
— Сохранить активный элемент перед открытием модала для последующего восстановления фокуса.
— Рендерить модал через портал у корня документа, чтобы избежать проблем с контекстом наложения.
— Включить aria-modal=»true» на корневом элементе модала и применять inert к фоновой обёртке документа.
— Реализовать фокус-трап: перехватывать Tab/Shift+Tab и циклически переводить фокус внутри модального контейнера.
— Отложить установку первичного фокуса до завершения анимации появления (использовать transitionend/animationend).
— При закрытии восстанавливать фокус на сохранённом элементе; при его отсутствии — сместить фокус на ближайший логичный элемент.
— Для блокировки прокрутки сохранить текущее значение scrollTop, переключить страницу в position: fixed с top: -scrollTop и вернуть при закрытии.
— Для мобильных форм избегать немедленного автофокуса на полях ввода; ставить фокус после пользовательского события или небольшой задержки.
— При вложенных модалах хранить стек состояний и применять inert/aria-hidden только к докерам ниже по стеку.
— Учитывать сторонние слушатели: подписывать события глобально с фильтрацией по целевому модалу, чтобы избежать конфликтов.
— Добавить обработку Esc и клика вне модала с проверкой, что событие не было остановлено другими слушателями.
— Покрывать сценарии автоконфигурацией тестов e2e: проверять циклическую табуляцию, восстановление фокуса и отсутствие фокусируемых элементов в фоне.
Сложные сценарии и отладка
Ни одна система не идеальна с первой попытки. Ниже перечислены типичные сценарии, которые требуют продвинутого подхода, и способы их диагностики.
Конфликты с внешними либами
Сторонние виджеты могут регистрировать глобальные слушатели клавиатуры или программно менять tabindex. Для обнаружения конфликтов использовать временное отключение библиотек, логирование перехватов событий и прослушивание event.stopPropagation в цепочке. Если конкретная библиотека ломает логику, изолировать область взаимодействия и применить фильтрацию событий по селектору корневого модала.
Анимационные гонки
Если модал открывается с CSS-переходом, но JS пытается установить фокус сразу, результат непредсказуем. Логика: выставить CSS-класс открытия, затем слушать transitionend/animationend и после этого запрашивать focus. При использовании JS-анимаций — привязывать Promise или callback на завершение анимации.
Проблемы на мобильных браузерах
iOS-система может менять высоту viewport при появлении клавиатуры, из-за чего фиксированные контейнеры смещаются. Диагностика: эмулировать на реальных устройствах, фиксировать поведение с position: fixed и тестировать с разными версиями ОС и браузеров. Иногда лучше смещать контент модала вверх через translateY, учитывая безопасную зону и клавиатуру.
Отсутствие поддержки inert
Не все браузеры поддерживают inert нативно. Для стабильного поведения применять полифил; при этом важно, чтобы полифил работал с динамически добавляемыми элементами и поддерживал MutationObserver, когда фоновые элементы меняются во время открытого модала.
Проблемы с восстановлением фокуса
Восстановление на удалённый элемент, который к моменту закрытия модала уже отсутствует, приводит к утере фокуса. Решение — предусмотреть алгоритм поиска ближайшего доступного фокусируемого элемента: сначала сохранить элемент, затем проверять его присутствие, и если его нет — искать контейнер-родитель или основной навигационный элемент, или ставить фокус на body с tabindex=-1 и тут же снимать его.
Доступность при множестве модальных типов
Различные типы модалов (подтверждение, форма, уведомление) требуют разного поведения: уведомление может не требовать фокус-трапа, а форма — обязательного автофокуса и сохранения состояния при переключении. Классифицировать модальные типы в библиотеке и задавать политики управления фокусом для каждой категории.
Мониторинг и производительность
Следить за количеством слушателей событий и применяемых манипуляций с DOM: частые операции по установке/снятию tabindex на множестве элементов приводят к перерисовкам. При большом числе однотипных изменений стоит работать с классами и одним обработчиком, изменяющим поведение через CSS или inert-полифил.
Тестовые сценарии
Автоматические тесты e2e — обязательный минимум. Сценарии: открытие модала, Tab проход по всем интерактивным элементам и возврат в начало; нажатие Shift+Tab из первого элемента и переход к последнему; проверка, что фоновый контент не фокусируется; проверка Esc и клика вне; открытие вложенного модала и восстановление состояния. Также проводить тесты с разными режимами увеличения шрифта и низкой контрастностью, чтобы уловить визуальные проблемы.
Примеры организационных паттернов
Централизованный менеджер модальных окон
Реализовать единый менеджер, хранящий стек модалов, состояние inert для фоновых контейнеров и логику восстановления фокуса. Менеджер должен предлагать API: open(modalId, options), close(modalId), registerFocusable(element), unregisterFocusable(element). Такой подход упрощает работу с вложенностью и предотвращает дублирование логики по всему приложению.
Компонент-обёртка для модала
Выделить контейнер, который отвечает за три вещи: рендер через портал, управление inert/aria-hidden на фоне и фокус-трап. Внутри этого компонента можно подключать конфигурации для анимаций, автофокуса и мобильного поведения. Это облегчает тестирование и повторное использование.
Политика автофокуса
Ввести правило: автофокус ставится на интерактивный элемент только если модал открыт по явному пользовательскому действию (клик, Enter, tap) и если модал не содержит задерживающих анимаций, либо если autoFocusForced явно разрешён. Это предотвращает неожиданные сдвиги при навигации и при восстановлении состояний.
Логирование и телеметрия
Собирать метрики: сколько раз модал открывается с автофокусом, сколько раз восстановление фокуса не удалось, частота конфликтов с inert-полифилами. Эта информация помогает принимать решение о доработках и выявлять проблемные сценарии.
Специфика для московских проектов и локальных реалий
В проектах, ориентированных на московский рынок, часто встречаются большие мультибрендовые сайты с интеграцией платёжных и сторонних сервисов, множество форм и мобильная аудитория, использующая разные поколения устройств. Подходы с централизованным менеджером и тщательное тестирование на устройствах с медленным соединением особенно актуальны: медленная загрузка приводит к асинхронному появлению скриптов, что усиливает риск гонок и конфликтов с фокусом.
Финальные замечания по архитектуре
Интеграция правил управления фокусом в дизайн-систему и библиотеку компонентов позволяет избежать расхождений в поведении модалов по проекту. Стандартизировать политику inert/aria-modal, фокус-трапа и блокировки прокрутки — значит снизить технический долг и упростить поддержку доступности при расширении интерфейса.
Спокойное резюме ценности подхода
Упорядоченная стратегия управления фокусом, согласованная работа с порталом, inert, фокус-трапом и блокировкой прокрутки обеспечивают предсказуемое и доступное поведение модальных окон. Внедрение централизованных паттернов и системных тестов уменьшает количество регрессий и делает интерфейс более надёжным в разнообразных условиях использования.