Модальные интерфейсные элементы часто используются в современных веб-приложениях. Диалог подтверждения, окно настройки, вложенные формы, всплывающие панели — все эти элементы временно блокируют взаимодействие с остальной частью страницы. Когда модальные окна могут открываться одно поверх другого, задача управления клавиатурным фокусом значительно усложняется.
Библиотека Focus-trap предоставляет механизм ограничения фокуса внутри определённой области DOM. При наличии нескольких модальных окон необходимо грамотно организовать работу нескольких ловушек фокуса (focus traps), чтобы:
Рассмотрим типичную последовательность интерфейсных действий:
Без управления фокусом возникают следующие проблемы:
Такие проблемы критичны для доступности (accessibility), особенно при использовании клавиатурной навигации или экранных считывателей.
Библиотека Focus-trap решает эту задачу с помощью нескольких независимых ловушек фокуса, которые могут активироваться и деактивироваться по мере необходимости.
Каждое модальное окно получает собственную ловушку фокуса:
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()
})
После активации ловушки:
При открытии вложенного окна необходимо:
const openSecond = document.getElementById('open-second')
openSecond.addEventListener('click', () => {
trapOne.pause()
modalTwo.hidden = false
trapTwo.activate()
})
Теперь:
При закрытии вложенного окна необходимо восстановить предыдущую ловушку:
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, чтобы управление происходило только через интерфейс.
createFocusTrap(modalTwo, {
escapeDeactivates: false
})
Закрытие в таком случае реализуется вручную.
Focus-trap использует список tabbable-элементов — элементов, доступных для фокусировки:
tabindexЕсли внутри модального окна отсутствуют фокусируемые элементы,
библиотека выдаст ошибку. Решение — добавить контейнер с
tabindex="-1".
<div class="modal-content" tabindex="-1">
Это гарантирует наличие хотя бы одной точки фокусировки.
Вложенные окна могут загружать элементы асинхронно (например, формы или списки). 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, который:
Пример упрощённой структуры:
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()
}
}
}
Такая архитектура обеспечивает корректную работу интерфейса даже при глубокой вложенности модальных окон.
При использовании нескольких модальных окон необходимо учитывать следующие требования:
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 это обеспечивает корректную работу экранных считывателей и клавиатурной навигации.
Корректная реализация обеспечивает следующие правила:
Такая модель полностью соответствует рекомендациям WAI-ARIA Authoring Practices для диалоговых интерфейсов.
Последовательность событий при трёх уровнях вложенности:
Активируется ловушка первого окна.
При открытии второго:
pauseactivateПри открытии третьего:
pauseactivateПри закрытии третьего:
deactivateunpauseПри закрытии второго:
deactivateunpauseТакой механизм формирует строгий стек фокуса, предотвращающий любые утечки навигации.