Дайджесты новостей
Трехмерная композиция интерфейса выпадающего диалогового меню Base UI со строкой поиска, списком команд и парящими клавишами навигации.

Анатомия доступного меню с фильтром: инженерные решения команды Base UI

Вспомни, как часто в современных интерфейсах тебе встречается выпадающее меню со встроенной строкой поиска: командная панель в Notion, поиск веток в GitHub, палитра действий в Figma или селектор тегов в таск-трекере. На первый взгляд задача кажется элементарной для любого фронтенд-разработчика: берем обычный выпадающий список, вставляем внутрь текстовый тег <input>, вешаем обработчик фильтрации массива — и готово.

Но попробуй закрыть глаза, включить скринридер и пройтись по такому компоненту исключительно с помощью клавиатуры. В девяти случаях из десяти тебя ждет катастрофа: фокус ввода внезапно теряется, скринридер замолкает либо начинает зачитывать бессвязный набор символов, стрелки клавиатуры перестают перемещать курсор по тексту, а нажатие клавиши Escape закрывает не то окно или вовсе сбрасывает введенную форму.

Инженеры библиотеки Base UI Дженна Смит (Jenna Smith) и Джеймс Нельсон (James Nelson) опубликовали детальное архитектурное исследование проектирования доступного фильтруемого меню (Filterable Menu). Я внимательно изучил их логику и хочу подробно разобрать, почему популярные готовые решения нарушают стандарты доступности и как построить идеальный компонент, сочетающий безупречную клавиатурную навигацию и честную семантику WAI-ARIA.

Принцип W3C: семантическая роль — это обещание

Главная ошибка, которую совершают разработчики при попытке создать меню с поиском, — механический выбор роли role="combobox". Логика кажется очевидной: у нас есть поле ввода и выпадающий список под ним, а значит, это комбобокс.

Однако в авторских практиках доступности W3C WAI-ARIA APG закреплен фундаментальный принцип: «Семантическая роль — это обещание пользователю» (a role is a promise). Когда скринридер сообщает незрячему человеку, что перед ним находится комбобокс, пользователь ожидает совершенно конкретного поведения: он вводит буквы, стрелками выбирает один из предложенных вариантов, нажимает Enter — и выбранное значение подставляется в это же самое поле ввода (как при выборе города или страны в форме заказа).

Но меню действий (Action Menu) устроено концептуально иначе:

  • Элементы меню не являются текстовыми значениями для заполнения строки. Это команды: «Переименовать файл», «Создать дубликат», «Переместить в корзину».
  • Нажатие на пункт меню выполняет действие в приложении и немедленно закрывает меню.
  • Текстовое поле служит исключительно временным ситом для отсева команд, а не самостоятельным полем данных.

Если навесить на такое меню роль комбобокса, скринридер пообещает пользователю выбор значения поля ввода, но при клике выполнит прикладную команду. Это грубое нарушение ожиданий доступности, полностью дезориентирующее людей с ограничениями зрения.

Почему атрибут aria-autocomplete здесь не работает

Вторая распространенная ловушка — попытка использовать атрибут aria-autocomplete="list". В спецификации WAI-ARIA этот атрибут жестко завязан на управление всплывающим окном предложений через связку атрибутов aria-controls и aria-haspopup.

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

Кроме того, возникает неразрешимый конфликт с клавишей Escape:

  • В комбобоксе первое нажатие Escape закрывает всплывающий список подсказок, но оставляет фокус внутри поля ввода для продолжения редактирования.
  • В меню действий нажатие Escape обязано полностью закрыть все всплывающее меню целиком и вернуть физический фокус на кнопку-триггер, вызвавшую меню.

Попытка скрестить эти противоположные модели поведения в рамках одного компонента неизбежно приводит к хрупким костылям и непредсказуемым багам навигации.

Архитектурный выбор Base UI: модальный диалог

Команда Base UI предложила элегантное и строгое решение: всплывающее меню с фильтром проектируется как доступный диалог (role="dialog"), внутри которого изолированно сосуществуют поле поиска и классическое меню команд.

В дереве доступности (Accessibility Tree) кнопка открытия меню объявляет aria-haspopup="dialog", а всплывающий контейнер получает роль role="dialog" с понятным aria-label. При открытии скринридеры (VoiceOver, NVDA, JAWS) четко объявляют вход в диалоговое окно, внутри которого изолированно живут поле поиска и пункты меню.

Благодаря этому пользователь сразу понимает контекст: он находится во временном интерактивном окне, откуда можно в любой момент выйти по Escape или Tab.

Виртуальный фокус: удержание DOM-фокуса в инпуте

Следующий критический инженерный барьер — управление фокусом клавиатуры. Когда пользователь открывает меню и начинает печатать, физический фокус браузера находится внутри тега <input>. Но как только пользователь нажимает клавишу «Стрелка вниз», чтобы выбрать нужную команду, возникает развилка:

  • Если физически перенести фокус DOM на элемент меню <button role="menuitem">, фокус покинет строку ввода. Пользователь больше не сможет продолжить набор текста или нажать Backspace для правки запроса без обратного клика в инпут.
  • Если оставить фокус в инпуте и не передать информацию скринридеру, незрячий человек не услышит, какой пункт сейчас подсвечен на экране.

Инфографическая схема виртуального фокуса в DOM: фокус ввода удерживается в поле поиска, пока aria-activedescendant связывает навигацию с пунктом меню.

Для разрешения этого противоречия инженеры Base UI применили механизм виртуального фокуса с помощью атрибута aria-activedescendant.

Физический фокус ввода всегда остается строго внутри текстового поля <input>. Когда пользователь нажимает стрелки вверх или вниз, фокус DOM никуда не перемещается: поле ввода продолжает принимать клавиатурные символы. Однако скрипт меняет значение атрибута aria-activedescendant у инпута, указывая идентификатор текущего выбранного пункта меню (например, aria-activedescendant="action-rename").

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

Тонкости клавиатурной навигации: Home, End и Tab

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

Поведение клавиш Home и End:

  • Если в поле фильтрации уже напечатан текст, клавиши Home и End выполняют свое естественное назначение: перемещают каретку в самое начало или самый конец набранной строки.
  • Если же поле ввода пустое, нажатие Home мгновенно выделяет первый пункт в меню, а End — переносит подсветку на последний доступный пункт.

Поведение клавиши Tab: нажатие Tab штатно закрывает меню и переводит фокус на следующий элемент страницы без блокировки (focus trap). Инпут объявляется как type="text" с явной ролью role="searchbox": это исключает появление платформенных кнопок очистки (крестиков), ломающих стилизацию в WebKit и Chromium.

import { Menu } from '@base-ui/react/menu';

export function ActionMenu() {
  return (
    <Menu.FilterProvider>
      <Menu.Root>
        <Menu.Trigger aria-haspopup="dialog">Действия</Menu.Trigger>
        <Menu.Portal>
          <Menu.Positioner>
            <Menu.Popup role="dialog" aria-label="Меню действий">
              <Menu.Input
                type="text"
                role="searchbox"
                aria-label="Фильтровать действия"
                placeholder="Поиск команды..."
              />
              <Menu.Clear aria-label="Очистить поиск" />
              <Menu.Empty>Команды не найдены</Menu.Empty>
              <Menu.List>
                <Menu.Item id="action-rename">Переименовать</Menu.Item>
                <Menu.Item id="action-duplicate">Дублировать</Menu.Item>
                <Menu.Item id="action-delete">Удалить</Menu.Item>
              </Menu.List>
            </Menu.Popup>
          </Menu.Positioner>
        </Menu.Portal>
      </Menu.Root>
    </Menu.FilterProvider>
  );
}

Давай посмотрим, как выглядит внутренняя логика разделения клавиш в обработчике клавиатурных событий инпута:

function handleInputKeyDown(
  event: React.KeyboardEvent<HTMLInputElement>,
  activeItemId: string | null,
  menuItems: string[]
) {
  const input = event.currentTarget;
  const isTextEmpty = input.value.length === 0;

  switch (event.key) {
    case 'Home':
      if (isTextEmpty && menuItems.length > 0) {
        event.preventDefault();
        setVirtualActiveDescendant(menuItems[0]);
      }
      break;

    case 'End':
      if (isTextEmpty && menuItems.length > 0) {
        event.preventDefault();
        setVirtualActiveDescendant(menuItems[menuItems.length - 1]);
      }
      break;

    case 'ArrowDown':
      event.preventDefault();
      moveToNextMenuItem(activeItemId, menuItems);
      break;

    case 'ArrowUp':
      event.preventDefault();
      moveToPreviousMenuItem(activeItemId, menuItems);
      break;
  }
}
Изоляция контекста во вложенных подменю

Если твое меню содержит многоуровневые вложенные подменю (каскадные списки), каждое подменю обязано оборачиваться в собственный независимый провайдер Menu.FilterProvider. В противном случае идентификаторы виртуального фокуса верхнего и нижнего уровней вступят в конфликт, и скринридер начнет озвучивать скрытые пункты соседней ветки.

Инженерные выводы для дизайн-систем

Исследование команды Base UI наглядно демонстрирует, что доступность — это не формальная галочка в аудите Lighthouse, а глубокая инженерная дисциплина, требующая точного понимания архитектуры браузерных событий и стандартов W3C.

При проектировании компонентов собственной дизайн-системы стоит придерживаться трех базовых правил:

  1. Не путай меню команд с выбором значений: если клик по элементу выполняет действие, используй семантику диалога и меню, а не комбобокса.
  2. Не отбирай фокус у пользователя: виртуальная адресация через aria-activedescendant позволяет сохранить непрерывность текстового ввода при параллельной навигации по списку.
  3. Уважай системные комбинации клавиш: предсказуемая работа клавиш Home, End, Tab и Escape отличает зрелый и надежный интерфейс от сырой поделки.