Unit-тестирование ловушек фокуса

Библиотека Focus-trap предназначена для управления фокусом клавиатуры внутри определённого контейнера. Это особенно важно для модальных окон, всплывающих подсказок и сложных виджетов, где пользователь не должен покидать область интерфейса, пока элемент активен. Основная идея заключается в «замыкании» фокуса: при активации trap, клавиатурный фокус циклически проходит только через элементы внутри контейнера.

Ключевые моменты работы:

  • Инициализация: createFocusTrap(container, options) создаёт объект trap для указанного DOM-контейнера.
  • Активация и деактивация: методы activate() и deactivate() включают и выключают управление фокусом.
  • Фокус по умолчанию: через опцию initialFocus можно задать конкретный элемент для установки фокуса при активации trap.
  • Восстановление фокуса: опция returnFocusOnDeactivate управляет возвращением фокуса на элемент, который был активен до активации trap.

Настройка ловушки фокуса

Focus-trap позволяет тонко настраивать поведение фокуса:

  • escapeDeactivates — разрешает закрытие trap по клавише Esc.
  • clickOutsideDeactivates — разрешает деактивацию при клике вне контейнера.
  • allowOutsideClick — позволяет определённые клики за пределами trap без его деактивации.
  • onActivate / onDeactivate — колбэки для выполнения дополнительной логики при включении и выключении trap.

Эти параметры критически важны для корректного UX и предотвращения «залипания» фокуса вне видимой области интерфейса.

Структура unit-тестов для Focus-trap

Unit-тестирование trap строится на симуляции поведения пользователя с клавиатурой и проверки состояния DOM:

  1. Инициализация trap: проверка того, что объект создаётся и методы activate/deactivate доступны.
  2. Фокус по умолчанию: активация trap должна устанавливать фокус на правильный элемент (initialFocus или первый фокусируемый элемент контейнера).
  3. Цикличность фокуса: тесты должны подтверждать, что нажатие Tab и Shift+Tab ограничено рамками контейнера.
  4. Деактивация и возврат фокуса: проверка, что после deactivate() фокус возвращается на элемент, который был активен до активации trap.
  5. Обработка внешних событий: эмуляция нажатий Esc, кликов за пределами контейнера, чтобы проверить правильное срабатывание опций escapeDeactivates и clickOutsideDeactivates.
  6. Асинхронные колбэки: тестирование onActivate и onDeactivate, включая возможные промисы и задержки.

Примеры тестов с использованием Jest

import { createFocusTrap } from 'focus-trap';

let trapContainer;
let trap;
let buttonOutside;

beforeEach(() => {
  trapContainer = document.createElement('div');
  const button1 = document.createElement('button');
  const button2 = document.createElement('button');
  buttonOutside = document.createElement('button');

  trapContainer.appendChild(button1);
  trapContainer.appendChild(button2);
  document.body.appendChild(trapContainer);
  document.body.appendChild(buttonOutside);

  trap = createFocusTrap(trapContainer, {
    escapeDeactivates: true,
    clickOutsideDeactivates: true
  });
});

afterEach(() => {
  trap.deactivate();
  document.body.removeChild(trapContainer);
  document.body.removeChild(buttonOutside);
});

test('Фокус устанавливается на первый элемент внутри trap', () => {
  trap.activate();
  expect(document.activeElement).toBe(trapContainer.querySelector('button'));
});

test('Фокус не выходит за пределы trap при Tab', () => {
  trap.activate();
  const buttons = trapContainer.querySelectorAll('button');
  buttons[0].focus();
  const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
  document.activeElement.dispatchEvent(tabEvent);
  expect(document.activeElement).toBe(buttons[1]);
});

test('Escape деактивирует trap', () => {
  trap.activate();
  const escEvent = new KeyboardEvent('keydown', { key: 'Escape' });
  document.dispatchEvent(escEvent);
  expect(document.activeElement).toBe(buttonOutside); // если returnFocusOnDeactivate = true
});

Особенности тестирования асинхронных сценариев

Focus-trap поддерживает асинхронные колбэки и возможность динамического изменения DOM после активации. Для unit-тестов важно учитывать:

  • Использование setTimeout или await Promise.resolve() для ожидания завершения колбэков.
  • Проверку корректного восстановления фокуса при асинхронной деактивации.
  • Эмуляцию динамических добавлений/удалений фокусируемых элементов и проверку цикличности фокуса.
test('Асинхронная деактивация с колбэком', async () => {
  let deactivated = false;
  trap = createFocusTrap(trapContainer, {
    onDeactivate: () => new Promise(resolve => {
      setTimeout(() => {
        deactivated = true;
        resolve();
      }, 50);
    })
  });

  trap.activate();
  await trap.deactivate();
  expect(deactivated).toBe(true);
});

Лучшие практики для unit-тестирования

  • Изолированность тестов: каждый тест должен создавать свой контейнер и объект trap, чтобы избежать влияния предыдущих тестов.
  • Тестирование реального DOM: использование document.body позволяет корректно проверять цикличность фокуса и обработку событий клавиатуры.
  • Покрытие всех опций: каждый параметр trap должен иметь хотя бы один тест, подтверждающий его корректное поведение.
  • Эмуляция пользовательских действий: Tab, Shift+Tab, Escape и клики вне контейнера необходимо прогонять через реальные события DOM.

Unit-тестирование Focus-trap обеспечивает стабильность интерфейсов с модальными окнами и виджетами, предотвращая ошибки навигации клавиатурой и повышая доступность.