Вложенные подменю

Библиотека ally.js предназначена для управления доступностью веб-компонентов, особенно интерактивных элементов, таких как меню и подменю. Она позволяет эффективно контролировать фокус, обработку клавиатуры и атрибуты ARIA, что критично для вложенных структур.

Инициализация и базовые принципы

Для работы с вложенными подменю важно правильно структурировать HTML и подключить ally.js:

<ul role="menu" id="main-menu">
  <li role="menuitem" tabindex="0" aria-haspopup="true" aria-expanded="false">
    Файл
    <ul role="menu" class="submenu">
      <li role="menuitem" tabindex="-1">Создать</li>
      <li role="menuitem" tabindex="-1">Открыть</li>
    </ul>
  </li>
  <li role="menuitem" tabindex="0">Правка</li>
</ul>
  • role="menuitem" и role="menu" определяют семантику элементов для экранных читалок.
  • aria-haspopup="true" указывает на наличие подменю.
  • aria-expanded управляет состоянием раскрытия подменю.
  • tabindex контролирует порядок навигации клавишей Tab.

Управление фокусом с ally.js

Вложенные подменю требуют динамического управления фокусом. Ally.js предоставляет функцию focusable для получения всех элементов, доступных для фокуса:

const menuItems = ally.query.focusable({ context: document.getElementById('main-menu') });

menuItems.forEach(item => {
  item.addEventListener('keydown', event => {
    if (event.key === 'ArrowRight') {
      const submenu = item.querySelector('.submenu');
      if (submenu) {
        item.setAttribute('aria-expanded', 'true');
        ally.focus.first({ context: submenu });
      }
    }
    if (event.key === 'ArrowLeft') {
      const parentMenu = item.closest('.submenu');
      if (parentMenu) {
        parentMenu.parentElement.setAttribute('aria-expanded', 'false');
        parentMenu.parentElement.focus();
      }
    }
  });
});
  • ally.query.focusable возвращает элементы, которые можно сфокусировать, включая скрытые и динамически добавленные.
  • События keydown обрабатывают навигацию стрелками, обеспечивая правильное раскрытие и скрытие подменю.

Управление видимостью подменю

Скрытие и показ подменю требует синхронизации с ARIA-атрибутами:

function toggleSubmenu(item, open) {
  const submenu = item.querySelector('.submenu');
  if (!submenu) return;
  submenu.style.display = open ? 'block' : 'none';
  item.setAttribute('aria-expanded', open ? 'true' : 'false');
}

document.querySelectorAll('[role="menuitem"][aria-haspopup="true"]').forEach(item => {
  item.addEventListener('mouseover', () => toggleSubmenu(item, true));
  item.addEventListener('mouseout', () => toggleSubmenu(item, false));
});
  • Подменю отображается только при наведении мыши или фокусе клавиатурой.
  • aria-expanded обновляется синхронно с видимостью.

Вложенность и рекурсивная обработка

Вложенные подменю могут иметь несколько уровней. Ally.js упрощает рекурсивный обход элементов для фокуса:

function handleNestedMenu(item) {
  const submenu = item.querySelector('.submenu');
  if (!submenu) return;

  ally.query.focusable({ context: submenu }).forEach(subItem => {
    subItem.addEventListener('keydown', event => {
      if (event.key === 'ArrowRight') handleNestedMenu(subItem);
      if (event.key === 'ArrowLeft') {
        const parent = subItem.closest('.submenu');
        if (parent) {
          parent.style.display = 'none';
          parent.parentElement.focus();
        }
      }
    });
  });
}

document.querySelectorAll('[role="menuitem"][aria-haspopup="true"]').forEach(handleNestedMenu);
  • Рекурсивный обход обеспечивает одинаковое поведение для любого уровня вложенности.
  • Фокус автоматически возвращается на родительский элемент при закрытии подменю.

Обработка клавиш навигации

Для полного соответствия стандартам доступности важно обрабатывать клавиши:

  • ArrowDown — переход к следующему элементу меню.
  • ArrowUp — переход к предыдущему элементу.
  • ArrowRight — раскрытие подменю и фокус на первом элементе.
  • ArrowLeft — возврат к родительскому меню.
  • Escape — закрытие текущего подменю и возврат фокуса на родительский элемент.

Пример обработки Escape:

document.addEventListener('keydown', event => {
  if (event.key === 'Escape') {
    const openSubmenus = document.querySelectorAll('[aria-expanded="true"]');
    openSubmenus.forEach(menu => {
      menu.setAttribute('aria-expanded', 'false');
      const submenu = menu.querySelector('.submenu');
      if (submenu) submenu.style.display = 'none';
      menu.focus();
    });
  }
});

Интеграция с динамическим контентом

Ally.js позволяет работать с динамически добавляемыми подменю, используя mutation observer:

const observer = new MutationObserver(mutations => {
  mutations.forEach(mutation => {
    if (mutation.addedNodes.length) {
      mutation.addedNodes.forEach(node => {
        if (node.nodeType === 1 && node.matches('[role="menuitem"]')) {
          handleNestedMenu(node);
        }
      });
    }
  });
});

observer.observe(document.getElementById('main-menu'), { childList: true, subtree: true });
  • Все новые элементы автоматически получают обработчики фокуса и клавиш.
  • Позволяет создавать интерактивные меню без повторной инициализации всей структуры.

Практические рекомендации

  • Каждый menuitem с подменю должен иметь aria-haspopup="true" и управлять aria-expanded.
  • Подменю должно быть скрыто по умолчанию (display: none), открываться через ally.js при фокусе или навигации клавишами.
  • Использовать ally.query.focusable для обхода всех интерактивных элементов, включая динамически созданные.
  • Рекурсивная обработка клавиш обеспечивает одинаковую логику для любого уровня вложенности.
  • Escape и стрелки должны работать на всех уровнях, чтобы соблюсти требования доступности.

Ally.js позволяет создавать вложенные подменю, полностью соответствующие стандартам доступности WAI-ARIA, с гибким управлением фокусом и клавиатурной навигацией, упрощая поддержку сложных интерфейсов.