Множественные модальные окна

Модальные интерфейсные элементы часто используются в современных веб-приложениях. Диалог подтверждения, окно настройки, вложенные формы, всплывающие панели — все эти элементы временно блокируют взаимодействие с остальной частью страницы. Когда модальные окна могут открываться одно поверх другого, задача управления клавиатурным фокусом значительно усложняется.

Библиотека Focus-trap предоставляет механизм ограничения фокуса внутри определённой области DOM. При наличии нескольких модальных окон необходимо грамотно организовать работу нескольких ловушек фокуса (focus traps), чтобы:

  • фокус оставался только внутри активного окна;
  • предыдущие ловушки фокуса временно приостанавливались;
  • при закрытии верхнего окна управление корректно возвращалось к предыдущему.

Проблемы фокуса при вложенных модальных окнах

Рассмотрим типичную последовательность интерфейсных действий:

  1. Открывается первое модальное окно.
  2. Внутри него появляется кнопка, вызывающая второе модальное окно.
  3. Второе окно открывается поверх первого.

Без управления фокусом возникают следующие проблемы:

  • Tab перемещает фокус за пределы верхнего окна.
  • фокус может попасть на элементы первого модального окна.
  • возможен переход на элементы основной страницы.
  • после закрытия второго окна фокус теряется.

Такие проблемы критичны для доступности (accessibility), особенно при использовании клавиатурной навигации или экранных считывателей.

Библиотека Focus-trap решает эту задачу с помощью нескольких независимых ловушек фокуса, которые могут активироваться и деактивироваться по мере необходимости.

Принцип работы нескольких focus-trap

Каждое модальное окно получает собственную ловушку фокуса:

  • одна ловушка соответствует одному DOM-контейнеру;
  • в момент открытия ловушка активируется;
  • при открытии нового окна предыдущая ловушка ставится на паузу.

Focus-trap предоставляет методы:

  • activate()
  • deactivate()
  • pause()
  • unpause()

Именно pause и unpause используются при работе со стеком модальных окон.

Базовая структура модальных окон

Пример HTML-структуры двух окон:

<button id="open-first">Открыть первое окно</button>

<div id="modal-one" class="modal" hidden>
  <button id="open-second">Открыть второе окно</button>
  <button id="close-first">Закрыть</button>
</div>

<div id="modal-two" class="modal" hidden>
  <p>Второе модальное окно</p>
  <button id="close-second">Закрыть</button>
</div>

Каждое окно содержит интерактивные элементы, которые должны участвовать в цикле фокусировки.

Создание нескольких ловушек фокуса

import { createFocusTrap } from 'focus-trap'

const modalOne = document.getElementById('modal-one')
const modalTwo = document.getElementById('modal-two')

const trapOne = createFocusTrap(modalOne, {
  escapeDeactivates: false
})

const trapTwo = createFocusTrap(modalTwo, {
  escapeDeactivates: false
})

Созданы две независимые ловушки фокуса, каждая из которых отвечает за своё окно.

Открытие первого модального окна

const openFirst = document.getElementById('open-first')

openFirst.addEventListener('click', () => {
  modalOne.hidden = false
  trapOne.activate()
})

После активации ловушки:

  • фокус перемещается на первый доступный элемент;
  • клавиша Tab циклически перемещает фокус внутри контейнера;
  • элементы страницы становятся недоступными для фокусировки.

Открытие второго модального окна

При открытии вложенного окна необходимо:

  1. приостановить предыдущую ловушку;
  2. активировать новую.
const openSecond = document.getElementById('open-second')

openSecond.addEventListener('click', () => {
  trapOne.pause()

  modalTwo.hidden = false
  trapTwo.activate()
})

Теперь:

  • фокус ограничен только вторым окном;
  • элементы первого окна временно исключаются из цикла фокуса;
  • клавиша Tab перемещается только по элементам второго окна.

Закрытие верхнего модального окна

При закрытии вложенного окна необходимо восстановить предыдущую ловушку:

const closeSecond = document.getElementById('close-second')

closeSecond.addEventListener('click', () => {
  trapTwo.deactivate()
  modalTwo.hidden = true

  trapOne.unpause()
})

После выполнения:

  • фокус снова ограничен первым модальным окном;
  • клавиатурная навигация продолжается с прежней позиции.

Закрытие первого модального окна

const closeFirst = document.getElementById('close-first')

closeFirst.addEventListener('click', () => {
  trapOne.deactivate()
  modalOne.hidden = true
})

После деактивации ловушки фокус возвращается на элемент, который был активен перед открытием окна.

Использование стека модальных окон

В сложных интерфейсах может существовать более двух вложенных окон. В этом случае удобно использовать стек ловушек фокуса.

Пример простой реализации:

const trapStack = []

function openModal(modalElement) {
  const trap = createFocusTrap(modalElement)

  if (trapStack.length > 0) {
    trapStack[trapStack.length - 1].pause()
  }

  trapStack.push(trap)
  modalElement.hidden = false
  trap.activate()
}

function closeModal(modalElement) {
  const trap = trapStack.pop()

  trap.deactivate()
  modalElement.hidden = true

  if (trapStack.length > 0) {
    trapStack[trapStack.length - 1].unpause()
  }
}

Такой подход автоматически управляет фокусом независимо от глубины вложенности окон.

Управление начальным фокусом

Вложенные модальные окна часто требуют точного указания элемента, на который должен перейти фокус после открытия.

Focus-trap предоставляет параметр initialFocus.

const trapTwo = createFocusTrap(modalTwo, {
  initialFocus: '#close-second'
})

После открытия второго окна фокус сразу устанавливается на кнопку закрытия.

Возврат фокуса после закрытия

По умолчанию библиотека возвращает фокус на элемент, который был активен перед активацией ловушки. Однако при сложных сценариях возможно использование параметра setReturnFocus.

const trapTwo = createFocusTrap(modalTwo, {
  setReturnFocus: '#open-second'
})

После закрытия второго окна фокус перемещается на кнопку, вызвавшую это окно.

Блокировка клавиши Escape

Иногда необходимо запретить автоматическое закрытие верхнего окна клавишей Escape, чтобы управление происходило только через интерфейс.

createFocusTrap(modalTwo, {
  escapeDeactivates: false
})

Закрытие в таком случае реализуется вручную.

Предотвращение утечки фокуса

Focus-trap использует список tabbable-элементов — элементов, доступных для фокусировки:

  • кнопки
  • ссылки
  • поля ввода
  • элементы с tabindex

Если внутри модального окна отсутствуют фокусируемые элементы, библиотека выдаст ошибку. Решение — добавить контейнер с tabindex="-1".

<div class="modal-content" tabindex="-1">

Это гарантирует наличие хотя бы одной точки фокусировки.

Работа с динамически изменяемым DOM

Вложенные окна могут загружать элементы асинхронно (например, формы или списки). Focus-trap автоматически пересчитывает список фокусируемых элементов при каждом нажатии Tab, поэтому динамическое добавление элементов не требует дополнительной настройки.

Однако при значительных изменениях DOM может понадобиться повторная активация ловушки.

trap.updateContainerElements(modalElement)

Использование в интерфейсных фреймворках

При работе с React, Vue или Angular принцип остаётся тем же:

  • каждое модальное окно имеет собственную ловушку;
  • при открытии нового окна предыдущая ловушка ставится на паузу;
  • при закрытии — возобновляется.

В React обычно используется библиотека focus-trap-react, которая автоматически управляет жизненным циклом ловушки.

Типичные ошибки при работе с несколькими окнами

1. Одновременная активация нескольких ловушек

Если две ловушки активны одновременно, возможны непредсказуемые переходы фокуса.

2. Отсутствие pause/unpause

Без временной приостановки предыдущей ловушки пользователь может попасть на элементы нижнего окна.

3. Потеря элемента возврата фокуса

Если элемент, на который должен вернуться фокус, удаляется из DOM, возврат фокуса невозможен.

4. Скрытие модального окна без деактивации ловушки

Нужно всегда сначала вызывать deactivate(), а затем скрывать DOM-элемент.

Архитектура менеджера модальных окон

В крупных приложениях создаётся централизованный Modal Manager, который:

  • хранит стек открытых окон;
  • управляет ловушками фокуса;
  • обрабатывает клавишу Escape;
  • предотвращает прокрутку страницы.

Пример упрощённой структуры:

class ModalManager {
  constructor() {
    this.stack = []
  }

  open(modal, trap) {
    if (this.stack.length) {
      this.stack[this.stack.length - 1].trap.pause()
    }

    modal.hidden = false
    trap.activate()

    this.stack.push({ modal, trap })
  }

  close() {
    const item = this.stack.pop()

    item.trap.deactivate()
    item.modal.hidden = true

    if (this.stack.length) {
      this.stack[this.stack.length - 1].trap.unpause()
    }
  }
}

Такая архитектура обеспечивает корректную работу интерфейса даже при глубокой вложенности модальных окон.

Особенности доступности (Accessibility)

При использовании нескольких модальных окон необходимо учитывать следующие требования:

  • использование role="dialog" или role="alertdialog";
  • наличие aria-modal="true";
  • указание aria-labelledby и aria-describedby;
  • скрытие фонового контента через aria-hidden.

Пример:

<div
  id="modal-two"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-two-title"
>

В сочетании с Focus-trap это обеспечивает корректную работу экранных считывателей и клавиатурной навигации.

Поведение клавиатуры при нескольких окнах

Корректная реализация обеспечивает следующие правила:

  • Tab и Shift+Tab работают только внутри активного окна.
  • Escape закрывает только верхнее окно.
  • фокус никогда не выходит за пределы текущего модального уровня.

Такая модель полностью соответствует рекомендациям WAI-ARIA Authoring Practices для диалоговых интерфейсов.

Практическая схема взаимодействия

Последовательность событий при трёх уровнях вложенности:

  1. Активируется ловушка первого окна.

  2. При открытии второго:

    • первая ловушка pause
    • вторая activate
  3. При открытии третьего:

    • вторая pause
    • третья activate
  4. При закрытии третьего:

    • третья deactivate
    • вторая unpause
  5. При закрытии второго:

    • вторая deactivate
    • первая unpause

Такой механизм формирует строгий стек фокуса, предотвращающий любые утечки навигации.