From 419f6e9fd4e158ebbd7195b75f766b1102a005df Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 15:46:56 +0530 Subject: [PATCH 1/9] refactor: [menu, context-menu] use base ui native menu filtering Search mode wraps the root in Menu.FilterProvider and renders Menu.Input, Menu.Empty and Menu.List in place of an Autocomplete inside the popup. This removes the synthetic pointer and keyboard events, the custom matcher and the per-item filtering. Props, slots and exported types are unchanged. Items use their value as the Base UI label only in a searchable menu. A radio item's value is not used for matching. Separators stay hidden while the query is not empty. --- .../context-menu/context-menu-content.tsx | 138 +--------- .../context-menu/context-menu-item.tsx | 28 +- .../context-menu/context-menu-misc.tsx | 17 +- .../context-menu/context-menu-root.tsx | 154 +++-------- .../context-menu/context-menu-trigger.tsx | 37 +-- .../components/menu/menu-checkbox-item.tsx | 30 +-- .../react/components/menu/menu-content.tsx | 173 ++++--------- packages/react/components/menu/menu-item.tsx | 43 +--- .../react/components/menu/menu-link-item.tsx | 27 +- packages/react/components/menu/menu-misc.tsx | 14 +- packages/react/components/menu/menu-radio.tsx | 29 +-- packages/react/components/menu/menu-root.tsx | 239 ++++++++---------- .../react/components/menu/menu-trigger.tsx | 33 +-- packages/react/components/menu/utils.ts | 55 ++-- 14 files changed, 254 insertions(+), 763 deletions(-) diff --git a/packages/react/components/context-menu/context-menu-content.tsx b/packages/react/components/context-menu/context-menu-content.tsx index d1fc9fc1b..24bb9763f 100644 --- a/packages/react/components/context-menu/context-menu-content.tsx +++ b/packages/react/components/context-menu/context-menu-content.tsx @@ -1,21 +1,13 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - ContextMenu as ContextMenuPrimitive -} from '@base-ui/react'; +import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { KeyboardEvent, useCallback, useRef } from 'react'; import { type Radius, radiusStyle } from '../../shared/radius'; import styles from '../menu/menu.module.css'; +import { MenuSearch } from '../menu/menu-content'; import { useMenuContext } from '../menu/menu-root'; -import { - dispatchKeyboardEvent, - isElementSubMenuOpen, - isElementSubMenuTrigger, - KEYCODES -} from '../menu/utils'; import { useThemeInjection } from '../theme/portal'; +import { ContextMenuEmptyState } from './context-menu-misc'; export interface ContextMenuContentProps extends Omit< @@ -42,66 +34,7 @@ export const ContextMenuContent = ({ radius, ...positionerProps }: ContextMenuContentProps) => { - const { - autocomplete, - inputValue, - onInputValueChange, - inputRef, - isInitialRender, - parent - } = useMenuContext(); - - const focusInput = useCallback(() => { - if (document?.activeElement !== inputRef?.current) - inputRef?.current?.focus(); - }, [inputRef]); - const highlightedItem = useRef< - [index: number, reason: 'keyboard' | 'pointer' | 'none'] - >([-1, 'none']); - const containerRef = useRef(null); - - const highlightFirstItem = useCallback(() => { - if (!isInitialRender?.current) return; - isInitialRender.current = false; - const item = containerRef.current?.querySelector('[role="option"]'); - if (!item) return; - item.dispatchEvent(new PointerEvent('mousemove', { bubbles: true })); - }, [isInitialRender]); - - const checkAndOpenSubMenu = useCallback(() => { - if (highlightedItem.current[0] === -1) return; - const items = containerRef.current?.querySelectorAll('[role="option"]'); - const item = items?.[highlightedItem.current[0]]; - if (!item || !isElementSubMenuTrigger(item)) return; - dispatchKeyboardEvent(item, KEYCODES.ARROW_RIGHT); - }, []); - - const checkAndCloseSubMenu = useCallback( - (e: KeyboardEvent) => { - if (highlightedItem.current[0] === -1) return; - const items = containerRef.current?.querySelectorAll('[role="option"]'); - const item = items?.[highlightedItem.current[0]]; - if ( - !item || - !isElementSubMenuTrigger(item) || - !isElementSubMenuOpen(item) - ) - return; - dispatchKeyboardEvent(item, KEYCODES.ESCAPE); - e.stopPropagation(); - }, - [] - ); - - const blurStaleMenuItem = useCallback((index: number) => { - const items = containerRef.current?.querySelectorAll('[role="option"]'); - const item = items?.[index]; - if (!item || !isElementSubMenuTrigger(item) || !isElementSubMenuOpen(item)) - return; - dispatchKeyboardEvent(item, KEYCODES.ESCAPE); - item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); - }, []); - + const { autocomplete } = useMenuContext(); const theme = useThemeInjection(); return ( @@ -127,65 +60,16 @@ export const ContextMenuContent = ({ style={style} render={render} finalFocus={finalFocus} - role={autocomplete ? 'dialog' : 'menu'} - onFocus={ - autocomplete || parent?.autocomplete - ? e => { - focusInput(); - e.stopPropagation(); - highlightFirstItem(); - onFocus?.(e); - } - : undefined - } + onFocus={onFocus} > {autocomplete ? ( - onInputValueChange?.(value)} - autoHighlight={!!inputValue?.length} - mode='none' - loopFocus={false} - onItemHighlighted={(value, eventDetails) => { - if ( - highlightedItem.current[1] === 'pointer' && - eventDetails.reason === 'keyboard' - ) { - blurStaleMenuItem(highlightedItem.current[0]); - } - highlightedItem.current = [ - eventDetails.index, - eventDetails.reason - ]; - }} + - { - focusInput(); - }} - onKeyDown={e => { - if (e.key === 'ArrowLeft') return; - if (e.key === 'Escape') return checkAndCloseSubMenu(e); - if (e.key === 'ArrowRight' || e.key === 'Enter') - checkAndOpenSubMenu(); - e.stopPropagation(); - }} - tabIndex={-1} - /> - - {children} - - + {children} + ) : ( children )} diff --git a/packages/react/components/context-menu/context-menu-item.tsx b/packages/react/components/context-menu/context-menu-item.tsx index 2a3fff9ad..6a37ac688 100644 --- a/packages/react/components/context-menu/context-menu-item.tsx +++ b/packages/react/components/context-menu/context-menu-item.tsx @@ -1,15 +1,12 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - ContextMenu as ContextMenuPrimitive -} from '@base-ui/react'; +import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { Cell, CellBaseProps } from '../menu/cell'; import { CheckboxItemBase, MenuCheckboxItemProps } from '../menu/menu-checkbox-item'; -import { stopItemFocus, useMenuItemFilter } from '../menu/menu-item'; +import { stopItemFocus } from '../menu/menu-item'; import { LinkItemBase, MenuLinkItemProps } from '../menu/menu-link-item'; import { MenuRadioGroupProps, @@ -17,6 +14,7 @@ import { RadioGroupBase, RadioItemBase } from '../menu/menu-radio'; +import { useMenuContext } from '../menu/menu-root'; export interface ContextMenuItemProps extends ContextMenuPrimitive.Item.Props, @@ -32,33 +30,19 @@ export const ContextMenuItem = ({ render, ...props }: ContextMenuItemProps) => { - const { autocomplete, hidden } = useMenuItemFilter(value, children); - - if (hidden) return null; + const { autocomplete } = useMenuContext(); const cell = render ?? ( ); - if (autocomplete) { - return ( - } - {...props} - > - {children} - - ); - } - return ( {children} diff --git a/packages/react/components/context-menu/context-menu-misc.tsx b/packages/react/components/context-menu/context-menu-misc.tsx index 926b47440..21b76c544 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -1,8 +1,8 @@ 'use client'; import { - Autocomplete as AutocompletePrimitive, - ContextMenu as ContextMenuPrimitive + ContextMenu as ContextMenuPrimitive, + Menu as MenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ComponentProps } from 'react'; @@ -15,7 +15,6 @@ export const ContextMenuGroup = ({ children, ...props }: ContextMenuGroupProps) => { - // Stays rendered while filtering. Removing it would remount the items and reset their state. return ( { - const { shouldFilter } = useMenuContext(); - - if (shouldFilter) { - return null; - } - return ( ) => { const { autocomplete } = useMenuContext(); - const Component = autocomplete ? AutocompletePrimitive.Empty : 'div'; + const Component = autocomplete ? MenuPrimitive.Empty : 'div'; return ( diff --git a/packages/react/components/context-menu/context-menu-root.tsx b/packages/react/components/context-menu/context-menu-root.tsx index d3e38aac5..88981dc12 100644 --- a/packages/react/components/context-menu/context-menu-root.tsx +++ b/packages/react/components/context-menu/context-menu-root.tsx @@ -1,9 +1,11 @@ 'use client'; import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; -import { useControlled } from '@base-ui/utils/useControlled'; -import { useCallback, useRef } from 'react'; -import { MenuContext, useMenuContext } from '../menu/menu-root'; +import { + MenuFilter, + useMenuContext, + useMenuInputValue +} from '../menu/menu-root'; export interface NormalContextMenuRootProps extends ContextMenuPrimitive.Root.Props { @@ -33,71 +35,24 @@ export const ContextMenuRoot = ({ inputValue: providedInputValue, onInputValueChange, defaultInputValue = '', - open: providedOpen, - onOpenChange, - defaultOpen = false, ...props }: ContextMenuRootProps) => { - const [inputValue, setInputValueUnwrapped] = useControlled({ - controlled: providedInputValue, - default: defaultInputValue, - name: 'ContextMenu', - state: 'inputValue' + const [inputValue, setInputValue] = useMenuInputValue({ + inputValue: providedInputValue, + defaultInputValue, + onInputValueChange, + name: 'ContextMenu' }); - const [open, setOpenUnwrapped] = useControlled({ - controlled: providedOpen, - default: defaultOpen, - name: 'ContextMenu', - state: 'open' - }); - const inputRef = useRef(null); - const contentRef = useRef(null); - const isInitialRender = useRef(true); - - const setValue = useCallback( - (value: string) => { - setInputValueUnwrapped(value); - onInputValueChange?.(value); - }, - [onInputValueChange, setInputValueUnwrapped] - ); - - const handleOpenChange: ContextMenuPrimitive.Root.Props['onOpenChange'] = - useCallback( - ( - value: boolean, - eventDetails: ContextMenuPrimitive.Root.ChangeEventDetails - ) => { - if (!value && autocomplete) { - setValue(''); - isInitialRender.current = true; - } - setOpenUnwrapped(value); - onOpenChange?.(value, eventDetails); - }, - [onOpenChange, setValue, autocomplete, setOpenUnwrapped] - ); return ( - - - + + ); }; ContextMenuRoot.displayName = 'ContextMenu'; @@ -130,73 +85,26 @@ export const ContextMenuSubMenu = ({ inputValue: providedInputValue, onInputValueChange, defaultInputValue = '', - open: providedOpen, - onOpenChange, - defaultOpen = false, ...props }: ContextMenuSubMenuProps) => { - const [inputValue, setInputValueUnwrapped] = useControlled({ - controlled: providedInputValue, - default: defaultInputValue, - name: 'ContextMenu.SubMenu', - state: 'inputValue' + const [inputValue, setInputValue] = useMenuInputValue({ + inputValue: providedInputValue, + defaultInputValue, + onInputValueChange, + name: 'ContextMenu.SubMenu' }); - const [open, setOpenUnwrapped] = useControlled({ - controlled: providedOpen, - default: defaultOpen, - name: 'ContextMenu.SubMenu', - state: 'open' - }); - const parentContext = useMenuContext(); - const inputRef = useRef(null); - const isInitialRender = useRef(true); - const contentRef = useRef(null); - - const setValue = useCallback( - (value: string) => { - setInputValueUnwrapped(value); - onInputValueChange?.(value); - }, - [onInputValueChange, setInputValueUnwrapped] - ); - - const handleOpenChange: ContextMenuPrimitive.SubmenuRoot.Props['onOpenChange'] = - useCallback( - ( - value: boolean, - eventDetails: ContextMenuPrimitive.SubmenuRoot.ChangeEventDetails - ) => { - if (!value && autocomplete) { - setValue(''); - isInitialRender.current = true; - } - setOpenUnwrapped(value); - onOpenChange?.(value, eventDetails); - }, - [onOpenChange, setValue, autocomplete, setOpenUnwrapped] - ); + const parent = useMenuContext(); return ( - - - + + ); }; ContextMenuSubMenu.displayName = 'ContextMenu.SubMenu'; diff --git a/packages/react/components/context-menu/context-menu-trigger.tsx b/packages/react/components/context-menu/context-menu-trigger.tsx index 9822a36f5..c49fa3e4c 100644 --- a/packages/react/components/context-menu/context-menu-trigger.tsx +++ b/packages/react/components/context-menu/context-menu-trigger.tsx @@ -1,13 +1,9 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - ContextMenu as ContextMenuPrimitive -} from '@base-ui/react'; +import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { ChevronRightIcon } from '~/icons'; import { Cell, CellBaseProps } from '../menu/cell'; import { useMenuContext } from '../menu/menu-root'; -import { getMatch } from '../menu/utils'; export interface ContextMenuTriggerProps extends ContextMenuPrimitive.Trigger.Props {} @@ -37,38 +33,13 @@ export const ContextMenuSubTrigger = ({ leadingIcon, ...props }: ContextMenuSubTriggerProps) => { - const { parent, inputRef } = useMenuContext(); + const { parent } = useMenuContext(); - if (parent?.shouldFilter && !getMatch(value, children, parent?.inputValue)) { - return null; - } - - const cell = ; return ( { - if (document?.activeElement !== parent?.inputRef?.current) - parent?.inputRef?.current?.focus(); - props?.onPointerEnter?.(e); - }} - onKeyDown={e => { - requestAnimationFrame(() => { - inputRef?.current?.focus(); - }); - props?.onKeyDown?.(e); - }} - /> - ) : ( - cell - ) - } + render={} + label={parent?.autocomplete ? value : undefined} {...props} - role={parent?.autocomplete ? 'option' : 'menuitem'} data-slot='menu-subtrigger' > {children} diff --git a/packages/react/components/menu/menu-checkbox-item.tsx b/packages/react/components/menu/menu-checkbox-item.tsx index 3271f6350..781002e7e 100644 --- a/packages/react/components/menu/menu-checkbox-item.tsx +++ b/packages/react/components/menu/menu-checkbox-item.tsx @@ -1,14 +1,12 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react'; import { useControlled } from '@base-ui/utils/useControlled'; import { CheckIcon } from '~/icons'; import { Cell, CellBaseProps } from './cell'; import cellStyles from './cell.module.css'; -import { stopItemFocus, useMenuItemFilter } from './menu-item'; +import { stopItemFocus } from './menu-item'; +import { useMenuContext } from './menu-root'; export interface MenuCheckboxItemProps extends MenuPrimitive.CheckboxItem.Props, @@ -30,8 +28,8 @@ export function CheckboxItemBase({ onCheckedChange, ...props }: MenuCheckboxItemProps & { slotPrefix: string }) { - const { autocomplete, hidden } = useMenuItemFilter(value, children); - // The Base UI item unmounts while filtered out, so the state lives here. + const { autocomplete } = useMenuContext(); + // The Base UI item renders nothing while filtered out, so the state lives here. const [checked, setCheckedUnwrapped] = useControlled({ controlled: checkedProp, default: defaultChecked, @@ -39,8 +37,6 @@ export function CheckboxItemBase({ state: 'checked' }); - if (hidden) return null; - const itemProps = { ...props, checked, @@ -70,25 +66,13 @@ export function CheckboxItemBase({ /> ); - if (autocomplete) { - return ( - } - > - {children} - - ); - } - return ( {children} diff --git a/packages/react/components/menu/menu-content.tsx b/packages/react/components/menu/menu-content.tsx index 7729c69bf..c9258aba1 100644 --- a/packages/react/components/menu/menu-content.tsx +++ b/packages/react/components/menu/menu-content.tsx @@ -1,21 +1,13 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { KeyboardEvent, useCallback, useRef } from 'react'; import { type Radius, radiusStyle } from '../../shared/radius'; import { useThemeInjection } from '../theme/portal'; import styles from './menu.module.css'; +import { MenuEmptyState } from './menu-misc'; import { useMenuContext } from './menu-root'; -import { - dispatchKeyboardEvent, - isElementSubMenuOpen, - isElementSubMenuTrigger, - KEYCODES -} from './utils'; +import { splitChildren } from './utils'; export interface MenuContentProps extends Omit< @@ -42,66 +34,7 @@ export function MenuContent({ radius, ...positionerProps }: MenuContentProps) { - const { - autocomplete, - inputValue, - onInputValueChange, - inputRef, - isInitialRender, - parent - } = useMenuContext(); - - const focusInput = useCallback(() => { - if (document?.activeElement !== inputRef?.current) - inputRef?.current?.focus(); - }, [inputRef]); - const highlightedItem = useRef< - [index: number, reason: 'keyboard' | 'pointer' | 'none'] - >([-1, 'none']); - const containerRef = useRef(null); - - const highlightFirstItem = useCallback(() => { - if (!isInitialRender?.current) return; - isInitialRender.current = false; - const item = containerRef.current?.querySelector('[role="option"]'); - if (!item) return; - item.dispatchEvent(new PointerEvent('mousemove', { bubbles: true })); - }, [isInitialRender]); - - const checkAndOpenSubMenu = useCallback(() => { - if (highlightedItem.current[0] === -1) return; - const items = containerRef.current?.querySelectorAll('[role="option"]'); - const item = items?.[highlightedItem.current[0]]; - if (!item || !isElementSubMenuTrigger(item)) return; - dispatchKeyboardEvent(item, KEYCODES.ARROW_RIGHT); - }, []); - - const checkAndCloseSubMenu = useCallback( - (e: KeyboardEvent) => { - if (highlightedItem.current[0] === -1) return; - const items = containerRef.current?.querySelectorAll('[role="option"]'); - const item = items?.[highlightedItem.current[0]]; - if ( - !item || - !isElementSubMenuTrigger(item) || - !isElementSubMenuOpen(item) - ) - return; - dispatchKeyboardEvent(item, KEYCODES.ESCAPE); - e.stopPropagation(); - }, - [] - ); - - const blurStaleMenuItem = useCallback((index: number) => { - const items = containerRef.current?.querySelectorAll('[role="option"]'); - const item = items?.[index]; - if (!item || !isElementSubMenuTrigger(item) || !isElementSubMenuOpen(item)) - return; - dispatchKeyboardEvent(item, KEYCODES.ESCAPE); - item.dispatchEvent(new PointerEvent('pointerout', { bubbles: true })); - }, []); - + const { autocomplete } = useMenuContext(); const theme = useThemeInjection(); return ( @@ -127,66 +60,12 @@ export function MenuContent({ style={style} render={render} finalFocus={finalFocus} - role={autocomplete ? 'dialog' : 'menu'} - onFocus={ - autocomplete || parent?.autocomplete - ? e => { - focusInput(); - e.stopPropagation(); - highlightFirstItem(); - onFocus?.(e); - } - : undefined - } + onFocus={onFocus} > {autocomplete ? ( - onInputValueChange?.(value)} - autoHighlight={!!inputValue?.length} - mode='none' - loopFocus={false} - onItemHighlighted={(value, eventDetails) => { - if ( - highlightedItem.current[1] === 'pointer' && - eventDetails.reason === 'keyboard' - ) { - // focus moved using keyboard after using pointer - blurStaleMenuItem(highlightedItem.current[0]); - } - highlightedItem.current = [ - eventDetails.index, - eventDetails.reason - ]; - }} - > - { - focusInput(); - }} - onKeyDown={e => { - if (e.key === 'ArrowLeft') return; - if (e.key === 'Escape') return checkAndCloseSubMenu(e); - if (e.key === 'ArrowRight' || e.key === 'Enter') - checkAndOpenSubMenu(); - e.stopPropagation(); - }} - tabIndex={-1} - /> - - {children} - - + + {children} + ) : ( children )} @@ -197,6 +76,42 @@ export function MenuContent({ } MenuContent.displayName = 'Menu.Content'; +/** + * Renders the search input, the empty state, and the filtered list. + * @remarks Only for internal usage. + */ +export function MenuSearch({ + slotPrefix, + placeholder, + emptyStateType = MenuEmptyState, + children +}: { + slotPrefix: string; + placeholder: string; + emptyStateType?: unknown; + children: React.ReactNode; +}) { + const [emptyState, items] = splitChildren(children, emptyStateType); + + return ( + <> + + {emptyState} + + {items} + + + ); +} + export function MenuSubContent(props: MenuContentProps) { return ; } diff --git a/packages/react/components/menu/menu-item.tsx b/packages/react/components/menu/menu-item.tsx index bb49776b9..80ebc6413 100644 --- a/packages/react/components/menu/menu-item.tsx +++ b/packages/react/components/menu/menu-item.tsx @@ -1,29 +1,8 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; -import { ReactNode } from 'react'; +import { Menu as MenuPrimitive } from '@base-ui/react'; import { Cell, CellBaseProps } from './cell'; import { useMenuContext } from './menu-root'; -import { getMatch } from './utils'; - -/** - * Hides the item when it does not match the search value (in auto mode). - * @remarks Only for internal usage. - */ -export function useMenuItemFilter(value: unknown, children: ReactNode) { - const { autocomplete, inputValue, shouldFilter } = useMenuContext(); - const hidden = - shouldFilter && - !getMatch( - typeof value === 'string' ? value : undefined, - children, - inputValue - ); - return { autocomplete, hidden }; -} /** * Keeps focus handling out of Base UI for items in a plain menu. @@ -51,33 +30,19 @@ export function MenuItem({ render, ...props }: MenuItemProps) { - const { autocomplete, hidden } = useMenuItemFilter(value, children); - - if (hidden) return null; + const { autocomplete } = useMenuContext(); const cell = render ?? ( ); - if (autocomplete) { - return ( - } - {...props} - > - {children} - - ); - } - return ( {children} diff --git a/packages/react/components/menu/menu-link-item.tsx b/packages/react/components/menu/menu-link-item.tsx index 5573980ac..d96ad0041 100644 --- a/packages/react/components/menu/menu-link-item.tsx +++ b/packages/react/components/menu/menu-link-item.tsx @@ -1,12 +1,10 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react'; import { ReactElement } from 'react'; import { Cell, CellBaseProps } from './cell'; -import { stopItemFocus, useMenuItemFilter } from './menu-item'; +import { stopItemFocus } from './menu-item'; +import { useMenuContext } from './menu-root'; export interface MenuLinkItemProps extends Omit, @@ -27,9 +25,7 @@ export function LinkItemBase({ render = , ...props }: MenuLinkItemProps & { slotPrefix: string }) { - const { autocomplete, hidden } = useMenuItemFilter(value, children); - - if (hidden) return null; + const { autocomplete } = useMenuContext(); const cell = ( ); - if (autocomplete) { - return ( - } - > - {children} - - ); - } - return ( {children} diff --git a/packages/react/components/menu/menu-misc.tsx b/packages/react/components/menu/menu-misc.tsx index b040a9a90..8b14571a0 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Autocomplete as AutocompletePrimitive } from '@base-ui/react'; import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { cx } from 'class-variance-authority'; import { ComponentProps, ReactNode } from 'react'; @@ -12,7 +11,6 @@ export function MenuGroup({ children, ...props }: MenuPrimitive.Group.Props) { - // Stays rendered while filtering. Removing it would remount the items and reset their state. return ( diff --git a/packages/react/components/menu/menu-radio.tsx b/packages/react/components/menu/menu-radio.tsx index 315621e85..0c02b9059 100644 --- a/packages/react/components/menu/menu-radio.tsx +++ b/packages/react/components/menu/menu-radio.tsx @@ -1,12 +1,10 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react'; import { Cell, CellBaseProps } from './cell'; import cellStyles from './cell.module.css'; -import { stopItemFocus, useMenuItemFilter } from './menu-item'; +import { stopItemFocus } from './menu-item'; +import { useMenuContext } from './menu-root'; export type MenuRadioGroupProps = MenuPrimitive.RadioGroup.Props; @@ -42,9 +40,7 @@ export function RadioItemBase({ render, ...props }: MenuRadioItemProps & { slotPrefix: string }) { - const { autocomplete, hidden } = useMenuItemFilter(value, children); - - if (hidden) return null; + const { autocomplete } = useMenuContext(); const cell = render ?? ( ); - if (autocomplete) { - return ( - - } - > - {children} - - ); - } - return ( {children} diff --git a/packages/react/components/menu/menu-root.tsx b/packages/react/components/menu/menu-root.tsx index 849abc7a9..f3736d356 100644 --- a/packages/react/components/menu/menu-root.tsx +++ b/packages/react/components/menu/menu-root.tsx @@ -2,7 +2,7 @@ import { Menu as MenuPrimitive } from '@base-ui/react'; import { useControlled } from '@base-ui/utils/useControlled'; -import { createContext, useCallback, useContext, useRef } from 'react'; +import { createContext, ReactNode, useCallback, useContext } from 'react'; interface CommonProps { autocomplete?: boolean; @@ -14,16 +14,15 @@ interface CommonProps { open?: boolean; } -interface MenuContextValue extends CommonProps { - parent?: CommonProps; - onInputValueChange?: (value: string) => void; +interface MenuContextValue { + autocomplete?: boolean; + autocompleteMode?: 'auto' | 'manual'; + inputValue?: string; + parent?: MenuContextValue; } interface UseMenuContextReturn extends MenuContextValue { shouldFilter?: boolean; - parent?: CommonProps & { - shouldFilter?: boolean; - }; } /** @@ -39,26 +38,79 @@ export const useMenuContext = (): UseMenuContextReturn => { if (!context) return {}; const shouldFilter = !!( - context?.autocomplete && - context?.autocompleteMode === 'auto' && - context?.inputValue?.length + context.autocomplete && + context.autocompleteMode === 'auto' && + context.inputValue?.length ); - const shouldFilterParent = !!( - context?.parent?.autocomplete && - context?.parent?.autocompleteMode === 'auto' && - context?.parent?.inputValue?.length + return { ...context, shouldFilter }; +}; + +/** + * Tracks the search value of a menu or submenu. + * @remarks Only for internal usage. + */ +export function useMenuInputValue({ + inputValue, + defaultInputValue, + onInputValueChange, + name +}: { + inputValue?: string; + defaultInputValue: string; + onInputValueChange?: (value: string) => void; + name: string; +}) { + const [value, setValueUnwrapped] = useControlled({ + controlled: inputValue, + default: defaultInputValue, + name, + state: 'inputValue' + }); + + const setValue = useCallback( + (next: string) => { + setValueUnwrapped(next); + onInputValueChange?.(next); + }, + [onInputValueChange, setValueUnwrapped] ); - return { - ...context, - shouldFilter, - parent: context?.parent && { - ...context.parent, - shouldFilter: shouldFilterParent - } - }; -}; + return [value, setValue] as const; +} + +/** + * Provides the menu context and, in autocomplete mode, wraps the root in Base UI's filter provider. + * @remarks Only for internal usage. + */ +export function MenuFilter({ + autocomplete, + autocompleteMode, + inputValue, + onInputValueChange, + parent, + children +}: MenuContextValue & { + onInputValueChange: (value: string) => void; + children: ReactNode; +}) { + return ( + + {autocomplete ? ( + onInputValueChange(value)} + filter={autocompleteMode === 'manual' ? null : undefined} + autoHighlight='always' + > + {children} + + ) : ( + children + )} + + ); +} export interface MenuRootBaseProps { open?: boolean; @@ -92,69 +144,24 @@ export const MenuRoot = ({ inputValue: providedInputValue, onInputValueChange, defaultInputValue = '', - open: providedOpen, - onOpenChange, - defaultOpen = false, ...props }: MenuRootProps) => { - const [inputValue, setInputValueUnwrapped] = useControlled({ - controlled: providedInputValue, - default: defaultInputValue, - name: 'Menu', - state: 'inputValue' - }); - const [open, setOpenUnwrapped] = useControlled({ - controlled: providedOpen, - default: defaultOpen, - name: 'Menu', - state: 'open' + const [inputValue, setInputValue] = useMenuInputValue({ + inputValue: providedInputValue, + defaultInputValue, + onInputValueChange, + name: 'Menu' }); - const inputRef = useRef(null); - const contentRef = useRef(null); - const isInitialRender = useRef(true); - - const setValue = useCallback( - (value: string) => { - setInputValueUnwrapped(value); - onInputValueChange?.(value); - }, - [onInputValueChange, setInputValueUnwrapped] - ); - - const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = - useCallback( - (value: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { - if (!value && autocomplete) { - setValue(''); - isInitialRender.current = true; - } - setOpenUnwrapped(value); - onOpenChange?.(value, eventDetails); - }, - [onOpenChange, setValue, autocomplete, setOpenUnwrapped] - ); return ( - - - + + ); }; MenuRoot.displayName = 'Menu'; @@ -187,70 +194,26 @@ export const MenuSubMenu = ({ inputValue: providedInputValue, onInputValueChange, defaultInputValue = '', - open: providedOpen, - onOpenChange, - defaultOpen = false, ...props }: MenuSubMenuProps) => { - const [inputValue, setInputValueUnwrapped] = useControlled({ - controlled: providedInputValue, - default: defaultInputValue, - name: 'Menu.Submenu', - state: 'inputValue' - }); - const [open, setOpenUnwrapped] = useControlled({ - controlled: providedOpen, - default: defaultOpen, - name: 'Menu.Submenu', - state: 'open' + const [inputValue, setInputValue] = useMenuInputValue({ + inputValue: providedInputValue, + defaultInputValue, + onInputValueChange, + name: 'Menu.Submenu' }); - const parentContext = useMenuContext(); - const inputRef = useRef(null); - const isInitialRender = useRef(true); - const contentRef = useRef(null); - - const setValue = useCallback( - (value: string) => { - setInputValueUnwrapped(value); - onInputValueChange?.(value); - }, - [onInputValueChange, setInputValueUnwrapped] - ); - - const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = - useCallback( - (value: boolean, eventDetails: MenuPrimitive.Root.ChangeEventDetails) => { - if (!value && autocomplete) { - setValue(''); - isInitialRender.current = true; - } - setOpenUnwrapped(value); - onOpenChange?.(value, eventDetails); - }, - [onOpenChange, setValue, autocomplete, setOpenUnwrapped] - ); + const parent = useMenuContext(); return ( - - - + + ); }; MenuSubMenu.displayName = 'Menu.Submenu'; diff --git a/packages/react/components/menu/menu-trigger.tsx b/packages/react/components/menu/menu-trigger.tsx index 85a2ce36c..3a460635c 100644 --- a/packages/react/components/menu/menu-trigger.tsx +++ b/packages/react/components/menu/menu-trigger.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Autocomplete as AutocompletePrimitive } from '@base-ui/react'; import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { cx } from 'class-variance-authority'; import { ChevronRightIcon } from '~/icons'; @@ -9,7 +8,6 @@ import { useMenubarContext } from '../menubar/menubar'; import { Cell, CellBaseProps } from './cell'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; -import { getMatch } from './utils'; export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { stopPropagation?: boolean; @@ -61,38 +59,13 @@ export function MenuSubTrigger({ leadingIcon, ...props }: MenuSubTriggerProps) { - const { parent, inputRef } = useMenuContext(); + const { parent } = useMenuContext(); - if (parent?.shouldFilter && !getMatch(value, children, parent?.inputValue)) { - return null; - } - - const cell = ; return ( { - if (document?.activeElement !== parent?.inputRef?.current) - parent?.inputRef?.current?.focus(); - props?.onPointerEnter?.(e); - }} - onKeyDown={e => { - requestAnimationFrame(() => { - inputRef?.current?.focus(); - }); - props?.onKeyDown?.(e); - }} - /> - ) : ( - cell - ) - } + render={} + label={parent?.autocomplete ? value : undefined} {...props} - role={parent?.autocomplete ? 'option' : 'menuitem'} data-slot='menu-subtrigger' > {children} diff --git a/packages/react/components/menu/utils.ts b/packages/react/components/menu/utils.ts index ffdf50ee3..bee3ec034 100644 --- a/packages/react/components/menu/utils.ts +++ b/packages/react/components/menu/utils.ts @@ -28,32 +28,33 @@ export const getChildrenValue = (children?: ReactNode) => { return value.trim(); }; -export const KEYCODES: Record = { - ARROW_RIGHT: ['ArrowRight', 39], - ESCAPE: ['Escape', 27] -}; - -export const dispatchKeyboardEvent = ( - element: Element | EventTarget, - key: (typeof KEYCODES)[keyof typeof KEYCODES], - bubbles: boolean = true -) => { - const [keyName, keyCode] = key; - return element.dispatchEvent( - new KeyboardEvent('keydown', { - key: keyName, - code: keyName, - keyCode: keyCode, - which: keyCode, - bubbles - }) - ); -}; - -export const isElementSubMenuTrigger = (element: Element) => { - return element.getAttribute('data-slot') === 'menu-subtrigger'; -}; +/** + * Separates the elements of the given component type from the other children. + * Looks inside fragments, which keep their other children. + */ +export const splitChildren = ( + children: ReactNode, + type: unknown +): [matched: ReactNode[], rest: ReactNode[]] => { + const matched: ReactNode[] = []; + const rest: ReactNode[] = []; + + React.Children.toArray(children).forEach(child => { + if (!React.isValidElement<{ children?: ReactNode }>(child)) { + rest.push(child); + } else if (child.type === type) { + matched.push(child); + } else if (child.type === React.Fragment) { + const [innerMatched, innerRest] = splitChildren( + child.props.children, + type + ); + matched.push(...innerMatched); + rest.push(React.cloneElement(child, undefined, ...innerRest)); + } else { + rest.push(child); + } + }); -export const isElementSubMenuOpen = (element: Element) => { - return element.hasAttribute('data-popup-open'); + return [matched, rest]; }; From 7d206283930cf5824a6395feb36bfbaa096a0af9 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 15:46:59 +0530 Subject: [PATCH 2/9] refactor: [menu] skip hidden groups in search list styles A group with no matching items stays in the list with the hidden attribute. The input keeps its bottom border while the empty state shows, since the empty state renders outside the list. --- packages/react/components/menu/menu.module.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react/components/menu/menu.module.css b/packages/react/components/menu/menu.module.css index 1ba6d5b23..c9f897a0b 100644 --- a/packages/react/components/menu/menu.module.css +++ b/packages/react/components/menu/menu.module.css @@ -53,8 +53,8 @@ padding: var(--rs-space-2); } -/* Groups stay rendered while filtering, so a group with no matches is an empty child. */ -.comboboxContent:not(:has(> :not(:empty))) { +/* A group with no matches stays rendered with `hidden`. */ +.comboboxContent:not(:has(> :not(:empty, [hidden]))) { padding: 0; } @@ -70,7 +70,8 @@ border-bottom: 0.5px solid var(--rs-color-border-base-primary); } -.comboboxContainer:not(:has(.comboboxContent > :not(:empty))) .comboboxInput { +.comboboxContainer:not(:has(.comboboxContent > :not(:empty, [hidden]), .empty)) + .comboboxInput { border-bottom: none; } From 0eee9253362ec6039639877c28726c4d6e54baeb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 15:47:16 +0530 Subject: [PATCH 3/9] test: [menu, context-menu] cover native menu filtering Query search mode by its menu roles, and cover accent-insensitive matching, value as label, group labels, the empty state live region, manual mode and submenu keyboard handoff. --- .../__tests__/context-menu.test.tsx | 7 +- .../components/menu/__tests__/menu.test.tsx | 255 ++++++++++++++++-- 2 files changed, 232 insertions(+), 30 deletions(-) diff --git a/packages/react/components/context-menu/__tests__/context-menu.test.tsx b/packages/react/components/context-menu/__tests__/context-menu.test.tsx index 8189d164a..3ba3795a8 100644 --- a/packages/react/components/context-menu/__tests__/context-menu.test.tsx +++ b/packages/react/components/context-menu/__tests__/context-menu.test.tsx @@ -224,12 +224,13 @@ describe('ContextMenu', () => { ); - const input = screen.getByRole('combobox'); + const input = screen.getByRole('searchbox'); fireEvent.change(input, { target: { value: 'grid' } }); - expect(screen.getAllByRole('option')).toHaveLength(1); + expect(screen.queryAllByRole('menuitem')).toHaveLength(0); + expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(1); fireEvent.change(input, { target: { value: 'zzz' } }); - expect(screen.queryAllByRole('option')).toHaveLength(0); + expect(screen.queryAllByRole('menuitemcheckbox')).toHaveLength(0); }); }); }); diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index a22260336..653e6fdcb 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -336,40 +336,46 @@ describe('Menu', () => { ); const search = (value: string) => { - fireEvent.change(screen.getByRole('combobox'), { target: { value } }); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value } + }); }; + const items = () => + Array.from( + document.querySelectorAll('[data-slot="menu-search-list"] [role]') + ).filter(el => !['group', 'separator', 'status'].includes(el.role!)); it('filters items as the user types', async () => { await renderAndOpenDropdown(); - expect(screen.getAllByRole('option')).toHaveLength(9); + expect(items()).toHaveLength(9); search('set'); - const options = screen.getAllByRole('option'); - expect(options).toHaveLength(1); - expect(options[0]).toHaveTextContent('Settings'); + expect(items()).toHaveLength(1); + expect(items()[0]).toHaveTextContent('Settings'); search('view'); - expect(screen.getAllByRole('option')).toHaveLength(2); + expect(items()).toHaveLength(2); }); it('toggles checkbox and radio items and keeps the search', async () => { await renderAndOpenDropdown(); search('grid'); - const checkbox = screen.getByRole('option', { name: 'Show grid' }); + const checkbox = screen.getByRole('menuitemcheckbox', { + name: 'Show grid' + }); expect(checkbox).toHaveAttribute('aria-checked', 'false'); fireEvent.click(checkbox); expect(checkbox).toHaveAttribute('aria-checked', 'true'); - expect(screen.getByRole('combobox')).toHaveValue('grid'); + expect(screen.getByRole('searchbox')).toHaveValue('grid'); search('view'); - const board = screen.getByRole('option', { name: 'Board view' }); + const board = screen.getByRole('menuitemradio', { name: 'Board view' }); fireEvent.click(board); expect(board).toHaveAttribute('aria-checked', 'true'); - expect(screen.getByRole('option', { name: 'List view' })).toHaveAttribute( - 'aria-checked', - 'false' - ); + expect( + screen.getByRole('menuitemradio', { name: 'List view' }) + ).toHaveAttribute('aria-checked', 'false'); }); it('keeps uncontrolled checkbox and radio state while filtering', async () => { @@ -388,28 +394,120 @@ describe('Menu', () => { ); - const option = (name: string) => screen.getByRole('option', { name }); - fireEvent.click(option('Show grid')); - fireEvent.click(option('Show rulers')); - fireEvent.click(option('Board view')); + const checkbox = (name: string) => + screen.getByRole('menuitemcheckbox', { name }); + const radio = (name: string) => + screen.getByRole('menuitemradio', { name }); + fireEvent.click(checkbox('Show grid')); + fireEvent.click(checkbox('Show rulers')); + fireEvent.click(radio('Board view')); search('view'); - expect(option('Board view')).toHaveAttribute('aria-checked', 'true'); + expect(radio('Board view')).toHaveAttribute('aria-checked', 'true'); search(''); - expect(option('Show grid')).toHaveAttribute('aria-checked', 'false'); - expect(option('Show rulers')).toHaveAttribute('aria-checked', 'false'); - expect(option('Board view')).toHaveAttribute('aria-checked', 'true'); + expect(checkbox('Show grid')).toHaveAttribute('aria-checked', 'false'); + expect(checkbox('Show rulers')).toHaveAttribute('aria-checked', 'false'); + expect(radio('Board view')).toHaveAttribute('aria-checked', 'true'); }); it('renders link items as anchors', async () => { await renderAndOpenDropdown(); - const link = screen.getByRole('option', { name: 'Docs' }); + const link = screen.getByRole('menuitem', { name: 'Docs' }); expect(link.tagName).toBe('A'); expect(link).toHaveAttribute('href', '/docs'); }); - it('renders the empty state without live region attributes', async () => { + it('labels the search input with the placeholder', async () => { + await renderAndOpenDropdown(); + expect(screen.getByRole('searchbox')).toHaveAttribute( + 'aria-label', + 'Search...' + ); + }); + + it('sets aria-haspopup="dialog" on the trigger', () => { + render(); + expect(screen.getByText(TRIGGER_TEXT)).toHaveAttribute( + 'aria-haspopup', + 'dialog' + ); + }); + + it('ignores accents when matching', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Café + Tea + + + ); + search('cafe'); + expect(items()).toHaveLength(1); + expect(items()[0]).toHaveTextContent('Café'); + }); + + it('matches the item value in place of its text', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Store away + + + ); + search('store'); + expect(items()).toHaveLength(0); + + search('arch'); + expect(items()).toHaveLength(1); + }); + + it('keeps the label of a matching group and hides other groups', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + + Fruits + Apple + + + Vegetables + Carrot + + + + ); + search('apple'); + expect( + screen.getByText('Fruits').closest('[data-slot="menu-group"]') + ).not.toHaveAttribute('hidden'); + expect(screen.queryByText('Carrot')).not.toBeInTheDocument(); + expect( + screen.getByText('Vegetables').closest('[data-slot="menu-group"]') + ).toHaveAttribute('hidden'); + }); + + it('hides separators while the search is not empty', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Apple + + Banana + + + ); + expect(screen.getByRole('separator')).toBeInTheDocument(); + search('a'); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); + }); + + it('shows the empty state only when no item matches', async () => { await renderAndOpenDropdown( {TRIGGER_TEXT} @@ -419,10 +517,113 @@ describe('Menu', () => { ); - const empty = document.querySelector('[data-slot="menu-empty-state"]'); - expect(empty).not.toHaveAttribute('role'); - expect(empty).not.toHaveAttribute('aria-live'); - expect(empty).not.toHaveAttribute('aria-atomic'); + const empty = () => + document.querySelector('[data-slot="menu-empty-state"]'); + expect(empty()).toBeNull(); + + search('zzz'); + expect(empty()).toHaveTextContent('No results'); + expect(empty()).toHaveAttribute('role', 'status'); + expect(empty()).toHaveAttribute('aria-live', 'polite'); + }); + + it('keeps every item in manual mode', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Apple + Banana + + + ); + search('zzz'); + expect(items()).toHaveLength(2); + }); + + describe('Submenus', () => { + const NestedMenu = ({ + searchableSubmenu + }: { + searchableSubmenu?: boolean; + }) => ( + + {TRIGGER_TEXT} + + Edit + + Share + + Copy link + Email + + + + + ); + + it('filters submenu triggers by their text and not their items', async () => { + await renderAndOpenDropdown(); + search('shar'); + expect(screen.getByText('Share')).toBeInTheDocument(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + + search('copy'); + expect(screen.queryByText('Share')).not.toBeInTheDocument(); + }); + + it('filters a searchable submenu by its own input', async () => { + await renderAndOpenDropdown(); + const root = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(root).toHaveFocus()); + search('shar'); + fireEvent.keyDown(root, { key: 'ArrowDown' }); + fireEvent.keyDown(root, { key: 'ArrowRight' }); + + const sub = await screen.findByPlaceholderText('Search share'); + await waitFor(() => expect(sub).toHaveFocus()); + fireEvent.change(sub, { target: { value: 'mail' } }); + expect(screen.queryByText('Copy link')).not.toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + }); + + it('moves focus into a plain submenu and back to the root input', async () => { + await renderAndOpenDropdown(); + const root = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(root).toHaveFocus()); + search('shar'); + fireEvent.keyDown(root, { key: 'ArrowDown' }); + fireEvent.keyDown(root, { key: 'ArrowRight' }); + + const copy = await screen.findByText('Copy link'); + await waitFor(() => expect(copy).toHaveFocus()); + + fireEvent.keyDown(copy, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByText('Copy link')).not.toBeInTheDocument() + ); + await waitFor(() => expect(root).toHaveFocus()); + }); + + it('keeps an empty state inside a fragment beside the list', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + <> + Profile + No results + + + + ); + search('zzz'); + const empty = document.querySelector('[data-slot="menu-empty-state"]'); + expect(empty).toHaveTextContent('No results'); + expect( + empty?.closest('[data-slot="menu-search-list"]') + ).not.toBeInTheDocument(); + }); }); }); }); From 39946933e23d5e55d770a79e00e1a064b3228bf5 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 15:47:18 +0530 Subject: [PATCH 4/9] chore: [docs] describe native menu filtering --- .../docs/components/context-menu/index.mdx | 24 +++++++------- .../docs/components/context-menu/props.ts | 14 ++++---- .../content/docs/components/menu/index.mdx | 32 ++++++++++--------- .../src/content/docs/components/menu/props.ts | 14 ++++---- 4 files changed, 44 insertions(+), 40 deletions(-) diff --git a/apps/www/src/content/docs/components/context-menu/index.mdx b/apps/www/src/content/docs/components/context-menu/index.mdx index 0b451fa2e..8c7222014 100644 --- a/apps/www/src/content/docs/components/context-menu/index.mdx +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -70,7 +70,7 @@ Use `ContextMenu.Submenu`, `ContextMenu.SubmenuTrigger`, and `ContextMenu.Submen Pass `autocomplete` to the ContextMenu root. Each menu manages its own autocomplete state. -- **`autocompleteMode="auto"`** is the default. Items filter as the user types, matched against each item's `value` prop or its children text. +- **`autocompleteMode="auto"`** is the default. Items filter as the user types. Each item matches by its `value` prop, or by its children text when it has no `value`. Matching ignores case and accents. - **Submenus** opt in separately: pass `autocomplete` to `ContextMenu.Submenu`. @@ -107,7 +107,7 @@ The container that holds the menu items. When autocomplete is enabled, renders a Individual clickable options within the menu. Built on top of [Base UI ContextMenu.Item](https://base-ui.com/react/components/context-menu). -Renders as a `role='option'` when used in an autocomplete menu. By default, the item's `children` text content is used for matching, which can be overridden by passing a `value` prop. +In a searchable menu, the item's `children` text is used for matching. Pass a `value` prop to match a different text. @@ -125,7 +125,7 @@ Groups related radio items and holds the selected value. ### RadioItem -A menu item that works like a radio button in a `ContextMenu.RadioGroup`. Renders a dot before the label when checked. The `value` prop is also used for search matching when it is a string. +A menu item that works like a radio button in a `ContextMenu.RadioGroup`. Renders a dot before the label when checked. @@ -137,13 +137,13 @@ A menu item that renders an `` element. Pass a router link to `render` to use ### Group -A way to group related menu items together. When filtering is active, the group label is hidden. +A way to group related menu items together. When filtering is active, a group with no matching items is hidden. ### Label -Renders a label in a menu group. This component should be wrapped with ContextMenu.Group so the `aria-labelledby` is correctly set on the group element. Hidden when filtering is active. +Renders a label in a menu group. This component should be wrapped with ContextMenu.Group so the `aria-labelledby` is correctly set on the group element. @@ -155,7 +155,7 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Placeholder content when there are no menu items to display. +Placeholder content when there are no menu items to display. In a searchable menu, it shows only when no item matches the search, and screen readers announce it. @@ -171,7 +171,7 @@ Supports its own `autocomplete` prop to enable search functionality within the s The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props. -When inside a searchable parent menu, the `value` prop can be used for autocomplete matching. +When inside a searchable parent menu, the trigger matches by its `value` prop, or by its children text when it has no `value`. The root search does not search inside submenus. @@ -217,10 +217,12 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test - Arrow keys move through items, Enter activates the highlighted item, and ArrowRight/ArrowLeft open and close submenus. - With `autocomplete`, the popup becomes a `role="dialog"` containing a search - input, items render as `role="option"`, and focus stays in the input while - arrow keys move the highlight, and Escape first closes an open submenu. + input (`role="searchbox"`, labelled by `searchPlaceholder`) and a + `role="menu"` list. Items keep their menu roles. Focus stays in the input + while arrow keys move the highlight, and Escape first closes an open submenu. - `ContextMenu.Label` must be wrapped in `ContextMenu.Group` so the group is labelled via `aria-labelledby`. - Checkbox items use `role="menuitemcheckbox"` and radio items use - `role="menuitemradio"`, both with `aria-checked`. In a searchable menu they - render as `role="option"` and keep `aria-checked`. + `role="menuitemradio"`, both with `aria-checked`. +- In a searchable menu, `ContextMenu.EmptyState` is a `role="status"` live + region. diff --git a/apps/www/src/content/docs/components/context-menu/props.ts b/apps/www/src/content/docs/components/context-menu/props.ts index 36d04c9ce..65e392fab 100644 --- a/apps/www/src/content/docs/components/context-menu/props.ts +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -53,7 +53,7 @@ export interface ContextMenuTriggerProps { } export interface ContextMenuContentProps { - /** Placeholder text for the autocomplete search input + /** Placeholder text and accessible name for the autocomplete search input * @default "Search..." */ searchPlaceholder?: string; @@ -102,7 +102,7 @@ export interface ContextMenuItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ value?: string; /** Additional CSS class names */ @@ -153,7 +153,7 @@ export interface ContextMenuCheckboxItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ value?: string; /** Additional CSS class names */ @@ -183,7 +183,7 @@ export interface ContextMenuRadioGroupProps { } export interface ContextMenuRadioItemProps { - /** Value of the radio item. Also used for autocomplete matching when it is a string. */ + /** Value of the radio item. */ value: unknown; /** Whether to close the menu when the item is clicked @@ -222,7 +222,7 @@ export interface ContextMenuLinkItemProps { /** Icon element to display after item text */ trailingIcon?: ReactNode; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ value?: string; /** Additional CSS class names */ @@ -283,12 +283,12 @@ export interface ContextMenuSubTriggerProps { /** Icon element to display after trigger text. Defaults to a chevron right icon. */ trailingIcon?: ReactNode; - /** Value used for autocomplete matching when inside a searchable parent menu */ + /** Text used to match the search of a searchable parent menu, in place of the `children` text. */ value?: string; } export interface ContextMenuSubContentProps { - /** Placeholder text for the autocomplete search input + /** Placeholder text and accessible name for the autocomplete search input * @default "Search..." */ searchPlaceholder?: string; diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index 373f4442a..038e05369 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -91,7 +91,7 @@ To use a router link, pass it to `render`. The item keeps its layout and icons. Pass `autocomplete` to the Menu root. Each menu manages its own autocomplete state. -- **`autocompleteMode="auto"`** is the default. Items filter as the user types, matched against each item's `value` prop or its children text. +- **`autocompleteMode="auto"`** is the default. Items filter as the user types. Each item matches by its `value` prop, or by its children text when it has no `value`. Matching ignores case and accents. - **`autocompleteMode="manual"`** leaves filtering to you. Do it in `onInputValueChange`. - **Submenus** opt in separately: pass `autocomplete` to `Menu.Submenu`. @@ -141,7 +141,7 @@ The container that holds the menu items. When autocomplete is enabled, renders a Individual clickable options within the menu. Built on top of [Base UI Menu.Item](https://base-ui.com/react/components/menu). -Renders as a `role='option'` when used in an autocomplete menu. By default, the item's `children` text content is used for matching, which can be overridden by passing a `value` prop. +In a searchable menu, the item's `children` text is used for matching. Pass a `value` prop to match a different text. @@ -159,7 +159,7 @@ Groups related radio items and holds the selected value. ### RadioItem -A menu item that works like a radio button in a `Menu.RadioGroup`. Renders a dot before the label when checked. The `value` prop is also used for search matching when it is a string. +A menu item that works like a radio button in a `Menu.RadioGroup`. Renders a dot before the label when checked. @@ -171,13 +171,13 @@ A menu item that renders an `` element. Built on [Base UI Menu.LinkItem](http ### Group -A way to group related menu items together. When filtering is active, the group label is hidden. +A way to group related menu items together. When filtering is active, a group with no matching items is hidden. ### Label -Renders a label in a menu group. This component should be wrapped with Menu.Group so the `aria-labelledby` is correctly set on the group element. Hidden when filtering is active. +Renders a label in a menu group. This component should be wrapped with Menu.Group so the `aria-labelledby` is correctly set on the group element. @@ -189,7 +189,7 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Placeholder content when there are no menu items to display. +Placeholder content when there are no menu items to display. In a searchable menu, it shows only when no item matches the search, and screen readers announce it. @@ -205,7 +205,7 @@ Supports its own `autocomplete` prop to enable search functionality within the s The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props. -When inside a searchable parent menu, the `value` prop can be used for autocomplete matching. +When inside a searchable parent menu, the trigger matches by its `value` prop, or by its children text when it has no `value`. The root search does not search inside submenus. @@ -250,14 +250,16 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test - Arrow keys move through items, Enter activates the highlighted item, Escape closes the menu and returns focus to the trigger, and ArrowRight/ArrowLeft open and close submenus. -- With `autocomplete`, the popup becomes a `role="dialog"` containing a search - input, items render as `role="option"`, and focus stays in the input while - arrow keys move the highlight, and ArrowRight or Enter opens a highlighted - submenu, and Escape first closes an open submenu. +- With `autocomplete`, the trigger has `aria-haspopup="dialog"` and the popup + becomes a `role="dialog"` containing a search input (`role="searchbox"`, + labelled by `searchPlaceholder`) and a `role="menu"` list. Items keep their + menu roles. Focus stays in the input while arrow keys move the highlight. + ArrowRight opens a highlighted submenu and moves focus to its first item, or + to its own search input when it is searchable. Escape closes the submenu and + returns focus to the parent input. - `Menu.Label` must be wrapped in `Menu.Group` so the group is labelled via `aria-labelledby`. - `Menu.CheckboxItem` uses `role="menuitemcheckbox"` and `Menu.RadioItem` uses - `role="menuitemradio"`, both with `aria-checked`. In a searchable menu they - render as `role="option"` and keep `aria-checked`. -- `Menu.LinkItem` renders an `` with `role="menuitem"`, or `role="option"` - in a searchable menu. + `role="menuitemradio"`, both with `aria-checked`. +- `Menu.LinkItem` renders an `` with `role="menuitem"`. +- In a searchable menu, `Menu.EmptyState` is a `role="status"` live region. diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts index 9049c3299..0bbba907b 100644 --- a/apps/www/src/content/docs/components/menu/props.ts +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -55,7 +55,7 @@ export interface MenuTriggerProps { } export interface MenuContentProps { - /** Placeholder text for the autocomplete search input + /** Placeholder text and accessible name for the autocomplete search input * @default "Search..." */ searchPlaceholder?: string; @@ -104,7 +104,7 @@ export interface MenuItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ value?: string; /** Additional CSS class names */ @@ -140,7 +140,7 @@ export interface MenuCheckboxItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ value?: string; /** Additional CSS class names */ @@ -170,7 +170,7 @@ export interface MenuRadioGroupProps { } export interface MenuRadioItemProps { - /** Value of the radio item. Also used for autocomplete matching when it is a string. */ + /** Value of the radio item. */ value: unknown; /** Whether to close the menu when the item is clicked @@ -209,7 +209,7 @@ export interface MenuLinkItemProps { /** Icon element to display after item text */ trailingIcon?: ReactNode; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ value?: string; /** Additional CSS class names */ @@ -285,12 +285,12 @@ export interface MenuSubTriggerProps { /** Icon element to display after trigger text. Defaults to a chevron right icon. */ trailingIcon?: ReactNode; - /** Value used for autocomplete matching when inside a searchable parent menu */ + /** Text used to match the search of a searchable parent menu, in place of the `children` text. */ value?: string; } export interface MenuSubContentProps { - /** Placeholder text for the autocomplete search input + /** Placeholder text and accessible name for the autocomplete search input * @default "Search..." */ searchPlaceholder?: string; From a718c2705e305349c61e7509974a7e3a7c0c3131 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 29 Oct 2026 15:47:20 +0530 Subject: [PATCH 5/9] chore: [docs] add empty state to menu search demos --- apps/www/src/content/docs/components/context-menu/demo.ts | 1 + apps/www/src/content/docs/components/menu/demo.ts | 1 + apps/www/src/content/docs/components/menubar/demo.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/www/src/content/docs/components/context-menu/demo.ts b/apps/www/src/content/docs/components/context-menu/demo.ts index 9e6a976e8..0b2216f89 100644 --- a/apps/www/src/content/docs/components/context-menu/demo.ts +++ b/apps/www/src/content/docs/components/context-menu/demo.ts @@ -145,6 +145,7 @@ export const autocompleteDemo = { Right click here + No results found Heading Assign member... diff --git a/apps/www/src/content/docs/components/menu/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts index 4602da5b5..27fecbc2a 100644 --- a/apps/www/src/content/docs/components/menu/demo.ts +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -162,6 +162,7 @@ export const autocompleteDemo = { Default Autocomplete + No results found Heading Assign member... diff --git a/apps/www/src/content/docs/components/menubar/demo.ts b/apps/www/src/content/docs/components/menubar/demo.ts index d32483435..b099d0e0b 100644 --- a/apps/www/src/content/docs/components/menubar/demo.ts +++ b/apps/www/src/content/docs/components/menubar/demo.ts @@ -80,6 +80,7 @@ export const autocompleteDemo = { Actions + No results found General Assign member... From fcb2fbd8defdf282601ceffaff65c66ff06f6018 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 2 Nov 2026 10:45:41 +0530 Subject: [PATCH 6/9] fix: [menu, context-menu] match item value and text in search An item with a value matches on both its value and its children text. A search of only spaces keeps separators visible. An EmptyState inside a fragment renders once. --- .../context-menu/context-menu-item.tsx | 3 +- .../context-menu/context-menu-trigger.tsx | 3 +- .../components/menu/__tests__/menu.test.tsx | 31 ++++++++++++++++--- .../components/menu/menu-checkbox-item.tsx | 5 +-- packages/react/components/menu/menu-item.tsx | 3 +- .../react/components/menu/menu-link-item.tsx | 5 +-- packages/react/components/menu/menu-root.tsx | 2 +- .../react/components/menu/menu-trigger.tsx | 3 +- packages/react/components/menu/utils.ts | 9 +++++- 9 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/react/components/context-menu/context-menu-item.tsx b/packages/react/components/context-menu/context-menu-item.tsx index 6a37ac688..bdb22987d 100644 --- a/packages/react/components/context-menu/context-menu-item.tsx +++ b/packages/react/components/context-menu/context-menu-item.tsx @@ -15,6 +15,7 @@ import { RadioItemBase } from '../menu/menu-radio'; import { useMenuContext } from '../menu/menu-root'; +import { getItemLabel } from '../menu/utils'; export interface ContextMenuItemProps extends ContextMenuPrimitive.Item.Props, @@ -40,7 +41,7 @@ export const ContextMenuItem = ({ diff --git a/packages/react/components/context-menu/context-menu-trigger.tsx b/packages/react/components/context-menu/context-menu-trigger.tsx index c49fa3e4c..e5cc98a6f 100644 --- a/packages/react/components/context-menu/context-menu-trigger.tsx +++ b/packages/react/components/context-menu/context-menu-trigger.tsx @@ -4,6 +4,7 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { ChevronRightIcon } from '~/icons'; import { Cell, CellBaseProps } from '../menu/cell'; import { useMenuContext } from '../menu/menu-root'; +import { getItemLabel } from '../menu/utils'; export interface ContextMenuTriggerProps extends ContextMenuPrimitive.Trigger.Props {} @@ -38,7 +39,7 @@ export const ContextMenuSubTrigger = ({ return ( } - label={parent?.autocomplete ? value : undefined} + label={parent?.autocomplete ? getItemLabel(value, children) : undefined} {...props} data-slot='menu-subtrigger' > diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index 653e6fdcb..8f0612415 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -449,7 +449,7 @@ describe('Menu', () => { expect(items()[0]).toHaveTextContent('Café'); }); - it('matches the item value in place of its text', async () => { + it('matches both the item value and its text', async () => { await renderAndOpenDropdown( {TRIGGER_TEXT} @@ -459,10 +459,13 @@ describe('Menu', () => { ); search('store'); - expect(items()).toHaveLength(0); + expect(items()).toHaveLength(1); search('arch'); expect(items()).toHaveLength(1); + + search('zzz'); + expect(items()).toHaveLength(0); }); it('keeps the label of a matching group and hides other groups', async () => { @@ -507,6 +510,21 @@ describe('Menu', () => { expect(screen.queryByRole('separator')).not.toBeInTheDocument(); }); + it('keeps separators for a search of only spaces', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Apple + + Banana + + + ); + search(' '); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + it('shows the empty state only when no item matches', async () => { await renderAndOpenDropdown( @@ -618,10 +636,13 @@ describe('Menu', () => { ); search('zzz'); - const empty = document.querySelector('[data-slot="menu-empty-state"]'); - expect(empty).toHaveTextContent('No results'); + const empties = document.querySelectorAll( + '[data-slot="menu-empty-state"]' + ); + expect(empties).toHaveLength(1); + expect(empties[0]).toHaveTextContent('No results'); expect( - empty?.closest('[data-slot="menu-search-list"]') + empties[0].closest('[data-slot="menu-search-list"]') ).not.toBeInTheDocument(); }); }); diff --git a/packages/react/components/menu/menu-checkbox-item.tsx b/packages/react/components/menu/menu-checkbox-item.tsx index 781002e7e..b6f172812 100644 --- a/packages/react/components/menu/menu-checkbox-item.tsx +++ b/packages/react/components/menu/menu-checkbox-item.tsx @@ -7,11 +7,12 @@ import { Cell, CellBaseProps } from './cell'; import cellStyles from './cell.module.css'; import { stopItemFocus } from './menu-item'; import { useMenuContext } from './menu-root'; +import { getItemLabel } from './utils'; export interface MenuCheckboxItemProps extends MenuPrimitive.CheckboxItem.Props, CellBaseProps { - /** Text used to match the search value. Defaults to the children text. */ + /** Extra text to match in a searchable menu, along with the children text. */ value?: string; } @@ -70,7 +71,7 @@ export function CheckboxItemBase({ diff --git a/packages/react/components/menu/menu-item.tsx b/packages/react/components/menu/menu-item.tsx index 80ebc6413..ad7a98db2 100644 --- a/packages/react/components/menu/menu-item.tsx +++ b/packages/react/components/menu/menu-item.tsx @@ -3,6 +3,7 @@ import { Menu as MenuPrimitive } from '@base-ui/react'; import { Cell, CellBaseProps } from './cell'; import { useMenuContext } from './menu-root'; +import { getItemLabel } from './utils'; /** * Keeps focus handling out of Base UI for items in a plain menu. @@ -40,7 +41,7 @@ export function MenuItem({ diff --git a/packages/react/components/menu/menu-link-item.tsx b/packages/react/components/menu/menu-link-item.tsx index d96ad0041..e1dd5aba8 100644 --- a/packages/react/components/menu/menu-link-item.tsx +++ b/packages/react/components/menu/menu-link-item.tsx @@ -5,11 +5,12 @@ import { ReactElement } from 'react'; import { Cell, CellBaseProps } from './cell'; import { stopItemFocus } from './menu-item'; import { useMenuContext } from './menu-root'; +import { getItemLabel } from './utils'; export interface MenuLinkItemProps extends Omit, CellBaseProps { - /** Text used to match the search value. Defaults to the children text. */ + /** Extra text to match in a searchable menu, along with the children text. */ value?: string; /** The link element to render, for example a router link. */ render?: ReactElement; @@ -39,7 +40,7 @@ export function LinkItemBase({ diff --git a/packages/react/components/menu/menu-root.tsx b/packages/react/components/menu/menu-root.tsx index f3736d356..6d3deafe3 100644 --- a/packages/react/components/menu/menu-root.tsx +++ b/packages/react/components/menu/menu-root.tsx @@ -40,7 +40,7 @@ export const useMenuContext = (): UseMenuContextReturn => { const shouldFilter = !!( context.autocomplete && context.autocompleteMode === 'auto' && - context.inputValue?.length + context.inputValue?.trim().length ); return { ...context, shouldFilter }; diff --git a/packages/react/components/menu/menu-trigger.tsx b/packages/react/components/menu/menu-trigger.tsx index 3a460635c..66106b5cb 100644 --- a/packages/react/components/menu/menu-trigger.tsx +++ b/packages/react/components/menu/menu-trigger.tsx @@ -8,6 +8,7 @@ import { useMenubarContext } from '../menubar/menubar'; import { Cell, CellBaseProps } from './cell'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; +import { getItemLabel } from './utils'; export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { stopPropagation?: boolean; @@ -64,7 +65,7 @@ export function MenuSubTrigger({ return ( } - label={parent?.autocomplete ? value : undefined} + label={parent?.autocomplete ? getItemLabel(value, children) : undefined} {...props} data-slot='menu-subtrigger' > diff --git a/packages/react/components/menu/utils.ts b/packages/react/components/menu/utils.ts index bee3ec034..91ac468db 100644 --- a/packages/react/components/menu/utils.ts +++ b/packages/react/components/menu/utils.ts @@ -28,6 +28,13 @@ export const getChildrenValue = (children?: ReactNode) => { return value.trim(); }; +/** + * Text that a searchable item matches on: its `value` and its children text. + * Returns `undefined` without a `value`, so Base UI reads the rendered text. + */ +export const getItemLabel = (value?: string, children?: ReactNode) => + value ? `${value} ${getChildrenValue(children)}`.trim() : undefined; + /** * Separates the elements of the given component type from the other children. * Looks inside fragments, which keep their other children. @@ -50,7 +57,7 @@ export const splitChildren = ( type ); matched.push(...innerMatched); - rest.push(React.cloneElement(child, undefined, ...innerRest)); + rest.push(React.cloneElement(child, undefined, innerRest)); } else { rest.push(child); } From a81df291acff216bbcbce868ccd75dff7abaae6b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 2 Nov 2026 10:45:43 +0530 Subject: [PATCH 7/9] chore: [docs] correct menu search matching and add changelog entry --- apps/www/src/components/linear-menu-demo.tsx | 9 +----- .../docs/components/context-menu/index.mdx | 11 +++---- .../content/docs/components/menu/index.mdx | 14 ++++----- packages/react/CHANGELOG.md | 29 +++++++++++++++++++ 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/apps/www/src/components/linear-menu-demo.tsx b/apps/www/src/components/linear-menu-demo.tsx index 31eeb2ba6..c167cd762 100644 --- a/apps/www/src/components/linear-menu-demo.tsx +++ b/apps/www/src/components/linear-menu-demo.tsx @@ -207,14 +207,6 @@ export default function LinearMenuDemo() { const renderMenu = (items: MenuItem[], query: string) => { const filteredItems = filterMenuItems(items, query); - if (searchQuery && filteredItems.length === 0) { - return ( -
- No results -
- ); - } - return filteredItems.map((item, index) => { switch (item.type) { case 'group': @@ -274,6 +266,7 @@ export default function LinearMenuDemo() { }>Actions {renderMenu(menuData, searchQuery)} + No results
diff --git a/apps/www/src/content/docs/components/context-menu/index.mdx b/apps/www/src/content/docs/components/context-menu/index.mdx index 8c7222014..6c586b4b1 100644 --- a/apps/www/src/content/docs/components/context-menu/index.mdx +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -70,7 +70,7 @@ Use `ContextMenu.Submenu`, `ContextMenu.SubmenuTrigger`, and `ContextMenu.Submen Pass `autocomplete` to the ContextMenu root. Each menu manages its own autocomplete state. -- **`autocompleteMode="auto"`** is the default. Items filter as the user types. Each item matches by its `value` prop, or by its children text when it has no `value`. Matching ignores case and accents. +- **`autocompleteMode="auto"`** is the default. Items filter as the user types. Each item matches by its text and, when set, its `value` prop. A `ContextMenu.RadioItem` matches by its text only, because its `value` is the selected value. Matching ignores case and accents. - **Submenus** opt in separately: pass `autocomplete` to `ContextMenu.Submenu`. @@ -107,7 +107,7 @@ The container that holds the menu items. When autocomplete is enabled, renders a Individual clickable options within the menu. Built on top of [Base UI ContextMenu.Item](https://base-ui.com/react/components/context-menu). -In a searchable menu, the item's `children` text is used for matching. Pass a `value` prop to match a different text. +In a searchable menu, the item's text is used for matching, including text in `leadingIcon` and `trailingIcon`. Pass a `value` prop to also match other text, such as a keyword. @@ -155,7 +155,7 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Placeholder content when there are no menu items to display. In a searchable menu, it shows only when no item matches the search, and screen readers announce it. +Placeholder content when there are no menu items to display. In a searchable menu, it shows only when no item matches the search, and screen readers announce it. Render it as a direct child of `ContextMenu.Content`. @@ -171,7 +171,7 @@ Supports its own `autocomplete` prop to enable search functionality within the s The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props. -When inside a searchable parent menu, the trigger matches by its `value` prop, or by its children text when it has no `value`. The root search does not search inside submenus. +When inside a searchable parent menu, the trigger matches by its text and, when set, its `value` prop. The root search does not search inside submenus. @@ -219,7 +219,8 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test - With `autocomplete`, the popup becomes a `role="dialog"` containing a search input (`role="searchbox"`, labelled by `searchPlaceholder`) and a `role="menu"` list. Items keep their menu roles. Focus stays in the input - while arrow keys move the highlight, and Escape first closes an open submenu. + while arrow keys move the highlight. Escape in a submenu closes it and + returns focus to the parent input. - `ContextMenu.Label` must be wrapped in `ContextMenu.Group` so the group is labelled via `aria-labelledby`. - Checkbox items use `role="menuitemcheckbox"` and radio items use diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index 038e05369..8db220891 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -91,7 +91,7 @@ To use a router link, pass it to `render`. The item keeps its layout and icons. Pass `autocomplete` to the Menu root. Each menu manages its own autocomplete state. -- **`autocompleteMode="auto"`** is the default. Items filter as the user types. Each item matches by its `value` prop, or by its children text when it has no `value`. Matching ignores case and accents. +- **`autocompleteMode="auto"`** is the default. Items filter as the user types. Each item matches by its text and, when set, its `value` prop. A `Menu.RadioItem` matches by its text only, because its `value` is the selected value. Matching ignores case and accents. - **`autocompleteMode="manual"`** leaves filtering to you. Do it in `onInputValueChange`. - **Submenus** opt in separately: pass `autocomplete` to `Menu.Submenu`. @@ -141,7 +141,7 @@ The container that holds the menu items. When autocomplete is enabled, renders a Individual clickable options within the menu. Built on top of [Base UI Menu.Item](https://base-ui.com/react/components/menu). -In a searchable menu, the item's `children` text is used for matching. Pass a `value` prop to match a different text. +In a searchable menu, the item's text is used for matching, including text in `leadingIcon` and `trailingIcon`. Pass a `value` prop to also match other text, such as a keyword. @@ -189,7 +189,7 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Placeholder content when there are no menu items to display. In a searchable menu, it shows only when no item matches the search, and screen readers announce it. +Placeholder content when there are no menu items to display. In a searchable menu, it shows only when no item matches the search, and screen readers announce it. Render it as a direct child of `Menu.Content`. @@ -205,7 +205,7 @@ Supports its own `autocomplete` prop to enable search functionality within the s The trigger item for a submenu. Renders with a trailing chevron icon by default. Accepts `leadingIcon` and `trailingIcon` props. -When inside a searchable parent menu, the trigger matches by its `value` prop, or by its children text when it has no `value`. The root search does not search inside submenus. +When inside a searchable parent menu, the trigger matches by its text and, when set, its `value` prop. The root search does not search inside submenus. @@ -254,9 +254,9 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test becomes a `role="dialog"` containing a search input (`role="searchbox"`, labelled by `searchPlaceholder`) and a `role="menu"` list. Items keep their menu roles. Focus stays in the input while arrow keys move the highlight. - ArrowRight opens a highlighted submenu and moves focus to its first item, or - to its own search input when it is searchable. Escape closes the submenu and - returns focus to the parent input. + ArrowRight or Enter opens a highlighted submenu and moves focus to its first + item, or to its own search input when it is searchable. Escape closes the + submenu and returns focus to the parent input. - `Menu.Label` must be wrapped in `Menu.Group` so the group is labelled via `aria-labelledby`. - `Menu.CheckboxItem` uses `role="menuitemcheckbox"` and `Menu.RadioItem` uses diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index fa5ec7f72..8bfd524a7 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -148,6 +148,35 @@ Apsara renders `Calendar`, `DatePicker` or `RangePicker` now. - **A date filter can be cleared.** Clicking the selected day or emptying the input clears it, and `onValueChange` receives `''`. +### Menu and ContextMenu: search uses Base UI menu filtering + +Menu, ContextMenu and Menubar search use Base UI's `Menu.FilterProvider`, +`Menu.Input`, `Menu.List` and `Menu.Empty`. Base UI marks this API as +preview. Props, slots and exported types are unchanged. + +#### Behavior changes + +- In search mode the trigger has `aria-haspopup="dialog"`, the popup is a + `dialog`, the input is a `searchbox` labelled by `searchPlaceholder`, + and the list is a `menu`. Items keep their menu roles (`menuitem`, + `menuitemcheckbox`, `menuitemradio`) instead of `option`. App tests + that query `role="option"` or expect `aria-haspopup="menu"` need + updating. +- An item matches by its rendered text, including text in `leadingIcon` + and `trailingIcon`, and by its `value` when set. A radio item's `value` + does not match. Matching ignores case, accents and punctuation. +- Typing highlights the first match. +- Group labels stay visible while their group has matches. +- In search mode, `EmptyState` shows only when no item matches, as a + `role="status"` live region. It renders next to the list, not inside + it, when it is a direct child of `Content`. +- ArrowLeft in a submenu's search input moves the text cursor instead of + closing the submenu. +- Escape in the root input closes the whole menu, even when a submenu is + open on hover. +- `eventDetails.cancel()` in `onOpenChange` keeps an uncontrolled Menu or + ContextMenu open. + ## 0.49.0 ### Calendar / DatePicker / RangePicker improvements (PR #819) From f1e35c8fc6e73cf1ae88430f818281f4eb0dc9a3 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 2 Nov 2026 11:53:30 +0530 Subject: [PATCH 8/9] test: [menu] cover enter on submenu trigger and fragment empty state --- .../react/components/menu/__tests__/menu.test.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index 8f0612415..43f85aa3b 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -605,6 +605,18 @@ describe('Menu', () => { expect(screen.getByText('Email')).toBeInTheDocument(); }); + it('opens a searchable submenu with Enter and focuses its input', async () => { + await renderAndOpenDropdown(); + const root = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(root).toHaveFocus()); + search('shar'); + fireEvent.keyDown(root, { key: 'ArrowDown' }); + fireEvent.keyDown(root, { key: 'Enter' }); + + const sub = await screen.findByPlaceholderText('Search share'); + await waitFor(() => expect(sub).toHaveFocus()); + }); + it('moves focus into a plain submenu and back to the root input', async () => { await renderAndOpenDropdown(); const root = screen.getByPlaceholderText('Search...'); @@ -628,8 +640,8 @@ describe('Menu', () => { {TRIGGER_TEXT} + Profile <> - Profile No results From 9fcd5a4374f9364b818e236e8897bdc6e73222f8 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 2 Nov 2026 11:53:32 +0530 Subject: [PATCH 9/9] chore: [docs] mark menu search changes as breaking and fix value docs --- .../src/content/docs/(overview)/upgrading.mdx | 24 +++++++++++++++++++ .../docs/components/context-menu/index.mdx | 2 +- .../docs/components/context-menu/props.ts | 8 +++---- .../src/content/docs/components/menu/demo.ts | 5 +--- .../content/docs/components/menu/index.mdx | 2 +- .../src/content/docs/components/menu/props.ts | 8 +++---- packages/react/CHANGELOG.md | 20 ++++++++++------ 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/apps/www/src/content/docs/(overview)/upgrading.mdx b/apps/www/src/content/docs/(overview)/upgrading.mdx index 82f756aad..39fc75f1f 100644 --- a/apps/www/src/content/docs/(overview)/upgrading.mdx +++ b/apps/www/src/content/docs/(overview)/upgrading.mdx @@ -7,6 +7,30 @@ One section per release, newest first, with only the changes that need action from you. The full record of every release, features and fixes included, is on [GitHub releases](https://github.com/raystack/apsara/releases). +## Unreleased: menu search uses Base UI filtering + +Search in `Menu`, `ContextMenu` and `Menubar` uses Base UI's menu filtering. +Props, slots and exported types do not change. + +### 1. Update tests that query roles + +In a searchable menu, items keep their menu roles: `menuitem`, +`menuitemcheckbox` and `menuitemradio`. They used to be `option`. The trigger +has `aria-haspopup="dialog"`, the popup is a `dialog`, and the input is a +`searchbox`. Update queries such as `getAllByRole('option')` to +`getAllByRole('menuitem')`. + +### 2. Check items that have a `value` + +An item with a `value` matches its `value` and its `children` text, not the +text in its icons. A `RadioItem` matches by its text, not its `value`. + +### 3. Render `EmptyState` as a direct child of `Content` + +In a searchable menu, `EmptyState` shows only when no item matches. Render it +as a direct child of `Content`, or inside a fragment there, so it sits next to +the list instead of inside it. + ## Unreleased: date filters compare whole days `DataView`, `DataTable` and `FilterChip` read and compare dates as calendar diff --git a/apps/www/src/content/docs/components/context-menu/index.mdx b/apps/www/src/content/docs/components/context-menu/index.mdx index 6c586b4b1..13183e057 100644 --- a/apps/www/src/content/docs/components/context-menu/index.mdx +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -107,7 +107,7 @@ The container that holds the menu items. When autocomplete is enabled, renders a Individual clickable options within the menu. Built on top of [Base UI ContextMenu.Item](https://base-ui.com/react/components/context-menu). -In a searchable menu, the item's text is used for matching, including text in `leadingIcon` and `trailingIcon`. Pass a `value` prop to also match other text, such as a keyword. +In a searchable menu, the item's text is used for matching, including text in `leadingIcon` and `trailingIcon`. Pass a `value` prop to also match other text, such as a keyword. With a `value`, text in the icons no longer matches. diff --git a/apps/www/src/content/docs/components/context-menu/props.ts b/apps/www/src/content/docs/components/context-menu/props.ts index 65e392fab..3a5d62c71 100644 --- a/apps/www/src/content/docs/components/context-menu/props.ts +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -102,7 +102,7 @@ export interface ContextMenuItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ value?: string; /** Additional CSS class names */ @@ -153,7 +153,7 @@ export interface ContextMenuCheckboxItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ value?: string; /** Additional CSS class names */ @@ -222,7 +222,7 @@ export interface ContextMenuLinkItemProps { /** Icon element to display after item text */ trailingIcon?: ReactNode; - /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ value?: string; /** Additional CSS class names */ @@ -283,7 +283,7 @@ export interface ContextMenuSubTriggerProps { /** Icon element to display after trigger text. Defaults to a chevron right icon. */ trailingIcon?: ReactNode; - /** Text used to match the search of a searchable parent menu, in place of the `children` text. */ + /** Extra text to match in a searchable parent menu, along with the `children` text. */ value?: string; } diff --git a/apps/www/src/content/docs/components/menu/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts index 27fecbc2a..6ba119f57 100644 --- a/apps/www/src/content/docs/components/menu/demo.ts +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -275,10 +275,6 @@ export const linearDemo = { const renderMenu = (items: MenuItem[], query: string) => { const filteredItems = filterMenuItems(items, query); - if (searchQuery && filteredItems.length === 0) { - return
No results
; - } - return filteredItems.map((item, index) => { switch (item.type) { case "group": @@ -336,6 +332,7 @@ export const linearDemo = { {renderMenu(menuData, searchQuery)} + No results
); diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index 8db220891..81b501689 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -141,7 +141,7 @@ The container that holds the menu items. When autocomplete is enabled, renders a Individual clickable options within the menu. Built on top of [Base UI Menu.Item](https://base-ui.com/react/components/menu). -In a searchable menu, the item's text is used for matching, including text in `leadingIcon` and `trailingIcon`. Pass a `value` prop to also match other text, such as a keyword. +In a searchable menu, the item's text is used for matching, including text in `leadingIcon` and `trailingIcon`. Pass a `value` prop to also match other text, such as a keyword. With a `value`, text in the icons no longer matches. diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts index 0bbba907b..af89a7475 100644 --- a/apps/www/src/content/docs/components/menu/props.ts +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -104,7 +104,7 @@ export interface MenuItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ value?: string; /** Additional CSS class names */ @@ -140,7 +140,7 @@ export interface MenuCheckboxItemProps { /** Whether the item is disabled */ disabled?: boolean; - /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ value?: string; /** Additional CSS class names */ @@ -209,7 +209,7 @@ export interface MenuLinkItemProps { /** Icon element to display after item text */ trailingIcon?: ReactNode; - /** Text used to match the search, in place of the `children` text. Only used in a searchable menu. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ value?: string; /** Additional CSS class names */ @@ -285,7 +285,7 @@ export interface MenuSubTriggerProps { /** Icon element to display after trigger text. Defaults to a chevron right icon. */ trailingIcon?: ReactNode; - /** Text used to match the search of a searchable parent menu, in place of the `children` text. */ + /** Extra text to match in a searchable parent menu, along with the `children` text. */ value?: string; } diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8bfd524a7..caac1a818 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -148,13 +148,14 @@ Apsara renders `Calendar`, `DatePicker` or `RangePicker` now. - **A date filter can be cleared.** Clicking the selected day or emptying the input clears it, and `onValueChange` receives `''`. -### Menu and ContextMenu: search uses Base UI menu filtering +### Menu and ContextMenu: search uses Base UI menu filtering (BREAKING) Menu, ContextMenu and Menubar search use Base UI's `Menu.FilterProvider`, `Menu.Input`, `Menu.List` and `Menu.Empty`. Base UI marks this API as -preview. Props, slots and exported types are unchanged. +preview. Props, slots and exported types are unchanged. See the +[upgrade guide](https://apsara.raystack.io/docs/upgrading). -#### Behavior changes +#### Breaking changes - In search mode the trigger has `aria-haspopup="dialog"`, the popup is a `dialog`, the input is a `searchbox` labelled by `searchPlaceholder`, @@ -163,13 +164,18 @@ preview. Props, slots and exported types are unchanged. that query `role="option"` or expect `aria-haspopup="menu"` need updating. - An item matches by its rendered text, including text in `leadingIcon` - and `trailingIcon`, and by its `value` when set. A radio item's `value` - does not match. Matching ignores case, accents and punctuation. -- Typing highlights the first match. -- Group labels stay visible while their group has matches. + and `trailingIcon`. With a `value`, it matches the `value` and its + `children` text, and text in the icons no longer matches. A radio + item's `value` does not match. Matching ignores case, accents and + punctuation. - In search mode, `EmptyState` shows only when no item matches, as a `role="status"` live region. It renders next to the list, not inside it, when it is a direct child of `Content`. + +#### Other changes + +- Typing highlights the first match. +- Group labels stay visible while their group has matches. - ArrowLeft in a submenu's search input moves the text cursor instead of closing the submenu. - Escape in the root input closes the whole menu, even when a submenu is