Дайджесты новостей
Макрокомпозиция доступного выпадающего меню Base UI со строкой поиска и клавиатурной навигацией стрелками.

Поиск по пунктам меню в Base UI: анатомия доступного интерфейса

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

Казалось бы, тривиальная задача: бросаем в разметку стандартный тег <input>, вешаем локальный хук useState на событие ввода, фильтруем массив строк через обычный .filter() и рендерим отфильтрованный список. Но если ты откроешь такой самодельный компонент с включенным скринридером VoiceOver или попытаешься пройтись по нему только с клавиатуры, интерфейс превратится в минное поле.

Фокус ввода застревает в инпуте, нажатие стрелок ArrowDown скроллит всю страницу вместо переключения пунктов, экранный диктор молчит о количестве оставшихся результатов, а при случайном нажатии клавиши Escape закрывается не всплывающее меню, а модальное окно этажом выше.

Команда MUI (создатели легендарного Material UI) активно развивает проект Base UI — открытую библиотеку нестилизованных, безголовых (headless) компонентов для React. В свежих версиях компонента Base UI Menu инженеры представили эталонную архитектуру фильтруемых выпадающих меню, разобрав проблему доступности буквально по винтикам.

Почему Combobox и Menu — это два принципиально разных зверя

Корень проблемы большинства фронтенд-разработчиков кроется в смешении фундаментальных спецификаций доступности консорциума W3C:

  • Паттерн Combobox: предназначен для ввода данных в форму. Пользователь набирает текст, видит подсказки автодополнения и выбирает одно или несколько значений, которые станут полем отправляемой формы.
  • Паттерн Menu: предназначен исключительно для совершения действий или вызова команд. Это аналог системного контекстного меню операционной системы («Копировать», «Удалить», «Переименовать»).

Когда разработчик пытается превратить обычный Menu в Combobox, чтобы получить строку поиска, он ломает семантические ожидания пользователя. В спецификации WAI-ARIA меню должно иметь четкую роль menu, его пункты — роль menuitem, а поисковая строка — роль searchbox. У них кардинально разная клавиатурная карта: в меню клавиши-стрелки обязаны циклически переключать пункты, а в комбобоксе стрелки вправо и влево перемещают текстовый курсор внутри строки ввода.

Композиционная модель: FilterProvider, Input и List

Вместо монолитного «черного ящика» с десятком пропсов Base UI предлагает изящную композицию декларативных примитивов. Вся магия фильтрации строится вокруг контекстного провайдера Menu.FilterProvider:

import * as React from 'react';
import { Menu } from '@base-ui/react/menu';
import styles from './actions-menu.module.css';

export function ProjectActionsMenu() {
  return (
    <Menu.FilterProvider>
      <Menu.Root>
        <Menu.Trigger className={styles.buttonTrigger}>
          Действия проекта
        </Menu.Trigger>
        <Menu.Portal>
          <Menu.Positioner sideOffset={6}>
            <Menu.Popup className={styles.dropdownPopup}>
              <div className={styles.filterContainer}>
                <Menu.Input 
                  placeholder="Быстрый поиск команд..." 
                  className={styles.filterInput}
                  aria-label="Фильтровать список действий"
                />
                <Menu.Clear className={styles.clearButton}>
                  Очистить
                </Menu.Clear>
              </div>

              <Menu.Empty className={styles.emptyPlaceholder}>
                Ни одной подходящей команды не найдено
              </Menu.Empty>

              <Menu.List className={styles.itemsScrollContainer}>
                <Menu.Item className={styles.menuItem} onSelect={() => console.log('Копировать')}>
                  Копировать ссылку
                </Menu.Item>
                <Menu.Item className={styles.menuItem} onSelect={() => console.log('Экспорт')}>
                  Экспорт в JSON
                </Menu.Item>
                <Menu.Item className={styles.menuItem} onSelect={() => console.log('Права')}>
                  Управление доступом
                </Menu.Item>
                <Menu.Separator className={styles.divider} />
                <Menu.Item className={styles.dangerItem} onSelect={() => console.log('Удалить')}>
                  Удалить проект
                </Menu.Item>
              </Menu.List>
            </Menu.Popup>
          </Menu.Positioner>
        </Menu.Portal>
      </Menu.Root>
    </Menu.FilterProvider>
  );
}

Давай разберем, как эта цепочка работает под капотом:

  1. Menu.FilterProvider: перехватывает текстовый ввод, анализирует текстовое содержимое всех вложенных компонентов Menu.Item и поддерживает актуальный список видимых индексов.
  2. Menu.Input: при открытии выпадающего окна автоматически получает фокус. Как только ты нажимаешь клавишу ArrowDown, фокус плавно соскакивает на первый видимый пункт меню. При нажатии ArrowUp на первом элементе фокус возвращается обратно в строку поиска.
  3. Menu.Empty: автоматически рендерится только тогда, когда введенная фраза не нашла совпадений ни в одном из элементов списка.
  4. Menu.Clear: функциональная кнопка очистки, которая сама исчезает из DOM, когда поисковая строка пуста.

Изоляция вложенных подменю (Submenus)

Один из самых сложных сценариев в интерфейсах — когда внутри выпадающего меню есть вложенные списки (например, пункт «Поделиться», открывающий подменю с выбором контактов).

В наивных реализациях поиск в корневом меню начинает фильтровать и скрывать пункты внутри скрытых подменю, вызывая хаос. В Base UI вложенное меню оборачивается в собственный примитив <Menu.SubmenuRoot>, который может содержать собственный независимый <Menu.FilterProvider>:

// Изолированная фильтрация внутри подменю без влияния на родителя
<Menu.SubmenuRoot>
  <Menu.SubmenuTrigger className={styles.menuItem}>
    Поделиться с пользователем →
  </Menu.SubmenuTrigger>
  <Menu.Portal>
    <Menu.Positioner sideOffset={4}>
      <Menu.Popup className={styles.submenuPopup}>
        <Menu.FilterProvider>
          <Menu.Input placeholder="Поиск пользователя..." className={styles.filterInput} />
          <Menu.List>
            <Menu.Item className={styles.menuItem}>Алексей Смирнов</Menu.Item>
            <Menu.Item className={styles.menuItem}>Екатерина Волкова</Menu.Item>
          </Menu.List>
        </Menu.FilterProvider>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.SubmenuRoot>

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

Сравнение с Radix UI и практические выводы

В мире современного React-фронтенда библиотека Radix UI (лежащая в основе популярного набора компонентов shadcn/ui) долгое время удерживала пальму первенства. Однако в Radix до сих пор нет штатного примитива для фильтрации меню: разработчикам приходится подключать стороннюю библиотеку cmdk или городить сложные костыли на базе комбобоксов.

Подход Base UI подкупает именно своей чистотой: они не пытаются переписать стандарт веба, а аккуратно оборачивают его в доступные кирпичики. Если твой проект перегружен сложными выпадающими списками с десятками параметров, переход на headless-примитивы Base UI позволяет закрыть 100% требований стандартов доступности W3C без необходимости изобретать собственный менеджер клавиатурного фокуса.