Вспомни, как часто в современных веб-сервисах ты встречаешь длинные выпадающие списки действий: выбор страны, переключение воркспейсов, палитра быстрых команд или контекстное меню файла в облачном диске. Когда пунктов становится больше десяти, пользователи закономерно требуют строку быстрого поиска прямо в шапке всплывающего окна.
Казалось бы, тривиальная задача: бросаем в разметку стандартный тег <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>
);
}
Давай разберем, как эта цепочка работает под капотом:
Menu.FilterProvider: перехватывает текстовый ввод, анализирует текстовое содержимое всех вложенных компонентовMenu.Itemи поддерживает актуальный список видимых индексов.Menu.Input: при открытии выпадающего окна автоматически получает фокус. Как только ты нажимаешь клавишуArrowDown, фокус плавно соскакивает на первый видимый пункт меню. При нажатииArrowUpна первом элементе фокус возвращается обратно в строку поиска.Menu.Empty: автоматически рендерится только тогда, когда введенная фраза не нашла совпадений ни в одном из элементов списка.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 без необходимости изобретать собственный менеджер клавиатурного фокуса.
