Ловушка фокуса в модальных окнах

Модалка, из которой Tab уводит на страницу под ней, — не модалка, а декорация. Разбираем контракт по-настоящему модального диалога.

Ловушка фокуса (focus trap) — намеренное удержание фокуса внутри открытого диалога: Tab с последнего элемента возвращает на первый, Shift+Tab с первого — на последний. Это единственный случай, когда «запереть» пользователя не только можно, но и нужно.

Зачем это нужно на практике

Возьмём обычную модалку «Удалить проект?» с кнопками «Отмена» и «Удалить». Для зрячего пользователя мыши всё честно: фон затемнён, окно поверх, кликать по фону бессмысленно. Теперь то же окно глазами клавиатуры.

Пользователь нажал Enter на кнопке «Удалить проект». Модалка появилась — но фокус остался на кнопке под затемнением. Он жмёт Tab: фокус идёт дальше по странице за модалкой. Кольца не видно, потому что оно под полупрозрачной подложкой. Ещё Tab — и он в шапке сайта, потом в футере, потом уходит в адресную строку браузера. Модалка на экране висит. Что в ней написано — неизвестно: скринридер до неё не добрался, он читает страницу, которая для всех остальных уже недоступна.

Итог: диалог, который для мыши блокирующий, а для клавиатуры и скринридера — просто ещё один блок где-то в разметке. Человек не может ни прочитать вопрос, ни ответить на него, ни закрыть окно. Ровно поэтому у модалки есть жёсткий контракт поведения — и он одинаковый для всех библиотек, фреймворков и самописных решений.

Контракт доступного диалога

СобытиеЧто обязано произойти
ОткрытиеФокус переезжает внутрь диалога: на первый интерактивный элемент, или на заголовок с tabindex="-1", или на сам контейнер
Tab на последнем элементеФокус переходит на первый элемент диалога, а не на страницу под ним
Shift+Tab на первом элементеФокус переходит на последний элемент диалога
EscapeДиалог закрывается
Клик по подложкеЗакрывает — если диалог необязательный (для деструктивных подтверждений часто отключают)
ЗакрытиеФокус возвращается на элемент, который открыл диалог
Фон, пока диалог открытНедоступен ни мыши, ни клавиатуре, ни скринридеру
РазметкаРоль dialog, aria-modal="true" и доступное имя (aria-labelledby)

Обратите внимание на последний пункт про Escape. Ловушка без выхода — это уже настоящая ловушка: пользователь заперт и не может ничего сделать. Escape обязателен всегда, даже в «важных» диалогах: закрыть окно и не выполнить действие — не то же самое, что подтвердить его.

Разметка

<button type="button" id="delete-btn">Удалить проект</button>

<div class="overlay" hidden>
  <div class="dialog"
       role="dialog"
       aria-modal="true"
       aria-labelledby="dlg-title"
       aria-describedby="dlg-desc">
    <h2 id="dlg-title" tabindex="-1">Удалить проект?</h2>
    <p id="dlg-desc">Проект и все его задачи будут удалены безвозвратно.</p>
    <button type="button" data-close>Отмена</button>
    <button type="button" data-confirm>Удалить</button>
  </div>
</div>

Что здесь важно:

  • role="dialog" — скринридер объявит «диалог».
  • aria-modal="true" — подсказка скринридеру, что содержимое вне диалога сейчас не существует. Это подсказка, а не механизм: сам по себе атрибут не мешает Tab уйти на фон.
  • aria-labelledby — имя диалога. Без него скринридер скажет просто «диалог» и замолчит.
  • tabindex="-1" на заголовке — чтобы можно было поставить фокус именно туда: тогда при открытии человек услышит вопрос целиком, а не сразу «кнопка Отмена».

Нативный <dialog> — почти всё бесплатно

Если можете позволить себе нативный элемент, берите его. Метод showModal() даёт из коробки: ловушку фокуса, закрытие по Escape, отрисовку поверх всего (top layer — никаких войн с z-index и overflow: hidden у предков), подложку ::backdrop, автоматическое отключение фона и возврат фокуса на элемент, с которого диалог открыли.

<button type="button" id="open">Удалить проект</button>

<dialog id="confirm" aria-labelledby="t">
  <h2 id="t">Удалить проект?</h2>
  <form method="dialog">
    <button value="cancel" autofocus>Отмена</button>
    <button value="confirm">Удалить</button>
  </form>
</dialog>
const dialog = document.getElementById('confirm');

document.getElementById('open').addEventListener('click', () => {
  dialog.showModal(); // не show()! show() открывает НЕмодально, без ловушки
});

dialog.addEventListener('close', () => {
  if (dialog.returnValue === 'confirm') {
    deleteProject();
  }
});

Две ловушки в самом <dialog>: show() и showModal() — разные вещи (первый не запирает фокус и не блокирует фон), а autofocus лучше ставить на безопасную кнопку. В диалоге удаления фокус на «Удалить» — приглашение снести проект случайным нажатием Enter.

Ловушка руками: когда нативный <dialog> не подходит

Дизайн-система, анимации, сложная вложенность — причины бывают. Тогда цикл Tab придётся замкнуть самому.

const FOCUSABLE = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])'
].join(',');

function getFocusable(root) {
  return Array.from(root.querySelectorAll(FOCUSABLE))
    // отсеиваем то, что скрыто: у скрытых элементов нет прямоугольников
    .filter(el => el.getClientRects().length > 0);
}

function openDialog(overlay, trigger) {
  const previouslyFocused = trigger || document.activeElement;
  const dialog = overlay.querySelector('[role="dialog"]');

  overlay.hidden = false;
  document.body.append(overlay); // выносим из-под возможных overflow/transform

  // 1. Фон недоступен целиком: и для Tab, и для мыши, и для скринридера
  document.getElementById('app-root').inert = true;

  // 2. Фокус — внутрь. Заголовок с tabindex="-1" читается целиком
  const title = dialog.querySelector('h2');
  (title || getFocusable(dialog)[0] || dialog).focus();

  function onKeydown(e) {
    if (e.key === 'Escape') {
      e.preventDefault();
      close();
      return;
    }
    if (e.key !== 'Tab') return;

    const items = getFocusable(dialog);
    if (items.length === 0) {
      e.preventDefault(); // некуда идти — держим фокус на диалоге
      return;
    }

    const first = items[0];
    const last = items[items.length - 1];
    const active = document.activeElement;

    // 3. Замыкаем цикл в обе стороны
    if (e.shiftKey && (active === first || active === title)) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && active === last) {
      e.preventDefault();
      first.focus();
    }
  }

  function close() {
    document.removeEventListener('keydown', onKeydown, true);
    document.getElementById('app-root').inert = false;
    overlay.hidden = true;
    // 4. Возвращаем фокус туда, откуда пришли
    if (previouslyFocused && previouslyFocused.isConnected) {
      previouslyFocused.focus();
    }
  }

  document.addEventListener('keydown', onKeydown, true);
  return close;
}

Четыре пронумерованных шага в коде — это и есть весь контракт: отключить фон, увести фокус внутрь, замкнуть цикл Tab, вернуть фокус при закрытии. Обработчик вешаем на document в фазе перехвата (true) — иначе вложенные компоненты могут проглотить событие раньше.

inert против aria-hidden

Здесь ошибаются чаще всего, и ошибка коварная: она делает хуже, чем ничего.

inertaria-hidden="true"
Убирает из порядка TabДа, всё поддеревоНет
Блокирует кликиДаНет
Прячет от скринридераДаДа
Отключает выделение текстаДаНет

Если закрыть фон только атрибутом aria-hidden="true", вы получите худший из миров: Tab по-прежнему уходит на фон, но всё, что там есть, для скринридера стало невидимым. Пользователь жмёт Tab — и оказывается на элементе, о котором скринридер не может сказать ничего. Просто тишина. Это не «частично доступно», это хуже исходного состояния.

Правильный инструмент — inert: один атрибут выключает целое поддерево из интерфейса. Ставить его нужно на контейнер приложения (не на <body> — иначе выключите и саму модалку, если она лежит в body). Нативный <dialog>.showModal() делает это за вас.

<body>
  <div id="app-root" inert>
    <header>…</header>
    <main>…</main>
  </div>

  <div class="overlay">
    <div role="dialog" aria-modal="true" aria-labelledby="t">…</div>
  </div>
</body>

Как это работает

Браузер строит единую последовательность фокуса на весь документ. Модальность — это не встроенное свойство «окна»: у страницы нет понятия «слой, из которого нельзя выйти». Есть только последовательность, и она сквозная. Поэтому «модальность» приходится собирать из трёх независимых механизмов:

  • Порядок фокуса. Либо вы вырезаете фон из последовательности (inert), либо перехватываете Tab и разворачиваете фокус назад руками.
  • Дерево доступности. Скринридер ходит не только по Tab: у него есть свой курсор, стрелки, режим чтения, навигация по заголовкам. aria-modal и inert говорят ему, что читать сейчас нужно только диалог.
  • Слои отрисовки. Нативный <dialog> с showModal() уходит в top layer — отдельный слой поверх всего документа. Он не зависит от z-index, overflow: hidden и transform у родителей. Самописная модалка внутри карточки с overflow: hidden будет обрезана — отсюда привычка выносить оверлеи в конец <body> порталом.

Отдельный вопрос — куда вернуть фокус, если кнопки-инициатора больше нет. Такое бывает постоянно: диалог «Удалить строку?» открыт кнопкой в самой строке, строку удалили — кнопки нет. Фокус улетает на <body>, и скринридер начинает читать страницу с начала. Правило: возвращайте фокус на ближайший осмысленный элемент, который пережил операцию — заголовок таблицы, соседнюю строку, кнопку «Добавить», — и сообщите результат через live region («Строка удалена»). Проверка previouslyFocused.isConnected из примера выше — минимальная страховка от такого сценария.

Частые ошибки

  • Ловушка есть, Escape нет. Пользователь заперт по-настоящему. Escape закрывает диалог всегда.
  • Фокус не уводят внутрь. Диалог открылся, фокус остался на кнопке под подложкой — все остальные усилия бессмысленны.
  • focus() на контейнере без tabindex="-1". Вызов молча ничего не делает, элемент не фокусируемый. Ошибки в консоли не будет — это самое неприятное.
  • Только aria-hidden на фоне. Tab уходит наружу, а там теперь тишина для скринридера. Нужен inert.
  • Обработчик keydown не снимается при закрытии. После пары открытий Tab на всей странице начинает вести себя загадочно: несколько живых ловушек перехватывают его наперегонки.
  • autofocus на деструктивной кнопке. Enter, привычно нажатый после открытия окна, удаляет проект. Фокус — на «Отмена» или на заголовок.
  • Фокус возвращается на удалённый элемент. Улетает на <body>, скринридер теряет место. Возвращайте на живой соседний элемент.
  • Список фокусируемых элементов посчитан один раз при открытии. В диалоге появилась ошибка валидации, кнопка стала активной — а ловушка про них не знает. Пересчитывайте на каждый Tab.
  • Toast-уведомление поверх модалки. Оно за пределами диалога — с клавиатуры до него не добраться. Такие сообщения делайте через live region внутри диалога.

Итоги

  • Модальность — не свойство картинки, а поведение: фокус внутрь, цикл Tab, Escape, возврат фокуса, отключённый фон.
  • Нативный <dialog> + showModal() закрывает почти весь контракт бесплатно. Начинайте с него.
  • aria-modal="true" — подсказка для скринридера, а не механизм. Сам он фокус не удержит.
  • Фон выключается атрибутом inert. aria-hidden в одиночку делает только хуже.
  • Escape обязателен: ловушка без выхода — это баг, а не фича.
  • Запомните элемент-инициатор до открытия и верните фокус на него (или на живого соседа) после закрытия.
  • Тест на 20 секунд: открыть диалог с клавиатуры, прожать Tab по кругу, нажать Escape и проверить, куда встал фокус.
Проверьте себя
1. Модалка открыта, на фоновый контейнер поставили aria-hidden="true". Что произойдёт при нажатии Tab на последнем элементе диалога?
AФокус вернётся на первый элемент диалога — aria-hidden замыкает цикл
BФокус уйдёт на элементы фона, но скринридер о них ничего не скажет — станет только хуже
CФокус останется на месте, потому что фон недоступен
DБраузер выбросит ошибку в консоль
2. Пользователь открыл диалог «Удалить строку?» кнопкой внутри самой строки, подтвердил удаление. Куда должен уйти фокус после закрытия?
AНа кнопку-инициатор — она была сохранена перед открытием
BНа document.body — пусть пользователь сориентируется сам
CНа ближайший осмысленный живой элемент (соседнюю строку, заголовок таблицы), а результат объявить через live region
DНикуда: после удаления фокус не нужен