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/(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/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/context-menu/index.mdx b/apps/www/src/content/docs/components/context-menu/index.mdx index 0b451fa2e..13183e057 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 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). -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 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. @@ -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. 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 `value` prop can be used for autocomplete matching. +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. @@ -217,10 +217,13 @@ 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. 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 - `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..3a5d62c71 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. */ + /** 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; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ 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. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ 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 */ + /** Extra text to match in a searchable parent menu, along with 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/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts index 4602da5b5..6ba119f57 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... @@ -274,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": @@ -335,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 373f4442a..81b501689 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 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). -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 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. @@ -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. 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 `value` prop can be used for autocomplete matching. +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. @@ -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 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 - `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..af89a7475 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. */ + /** 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; - /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ 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. */ + /** Extra text to match in a searchable menu, along with the `children` text. */ 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 */ + /** Extra text to match in a searchable parent menu, along with 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; 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... diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index fa5ec7f72..caac1a818 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -148,6 +148,41 @@ 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 (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. See the +[upgrade guide](https://apsara.raystack.io/docs/upgrading). + +#### Breaking 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`. 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 + open on hover. +- `eventDetails.cancel()` in `onOpenChange` keeps an uncontrolled Menu or + ContextMenu open. + ## 0.49.0 ### Calendar / DatePicker / RangePicker improvements (PR #819) 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/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..bdb22987d 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,8 @@ import { RadioGroupBase, RadioItemBase } from '../menu/menu-radio'; +import { useMenuContext } from '../menu/menu-root'; +import { getItemLabel } from '../menu/utils'; export interface ContextMenuItemProps extends ContextMenuPrimitive.Item.Props, @@ -32,33 +31,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..e5cc98a6f 100644 --- a/packages/react/components/context-menu/context-menu-trigger.tsx +++ b/packages/react/components/context-menu/context-menu-trigger.tsx @@ -1,13 +1,10 @@ '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'; +import { getItemLabel } from '../menu/utils'; export interface ContextMenuTriggerProps extends ContextMenuPrimitive.Trigger.Props {} @@ -37,38 +34,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 ? getItemLabel(value, children) : undefined} {...props} - role={parent?.autocomplete ? 'option' : 'menuitem'} data-slot='menu-subtrigger' > {children} diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index a22260336..43f85aa3b 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,138 @@ 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 both the item value and its text', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Store away + + + ); + search('store'); + 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 () => { + 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('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( {TRIGGER_TEXT} @@ -419,10 +535,128 @@ 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('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...'); + 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 empties = document.querySelectorAll( + '[data-slot="menu-empty-state"]' + ); + expect(empties).toHaveLength(1); + expect(empties[0]).toHaveTextContent('No results'); + expect( + 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 3271f6350..b6f172812 100644 --- a/packages/react/components/menu/menu-checkbox-item.tsx +++ b/packages/react/components/menu/menu-checkbox-item.tsx @@ -1,19 +1,18 @@ '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'; +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; } @@ -30,8 +29,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 +38,6 @@ export function CheckboxItemBase({ state: 'checked' }); - if (hidden) return null; - const itemProps = { ...props, checked, @@ -70,25 +67,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..ad7a98db2 100644 --- a/packages/react/components/menu/menu-item.tsx +++ b/packages/react/components/menu/menu-item.tsx @@ -1,29 +1,9 @@ '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 }; -} +import { getItemLabel } from './utils'; /** * Keeps focus handling out of Base UI for items in a plain menu. @@ -51,33 +31,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..e1dd5aba8 100644 --- a/packages/react/components/menu/menu-link-item.tsx +++ b/packages/react/components/menu/menu-link-item.tsx @@ -1,17 +1,16 @@ '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'; +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; @@ -27,9 +26,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..6d3deafe3 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?.trim().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..66106b5cb 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,7 @@ 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'; +import { getItemLabel } from './utils'; export interface MenuTriggerProps extends MenuPrimitive.Trigger.Props { stopPropagation?: boolean; @@ -61,38 +60,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 ? getItemLabel(value, children) : undefined} {...props} - role={parent?.autocomplete ? 'option' : 'menuitem'} data-slot='menu-subtrigger' > {children} 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; } diff --git a/packages/react/components/menu/utils.ts b/packages/react/components/menu/utils.ts index ffdf50ee3..91ac468db 100644 --- a/packages/react/components/menu/utils.ts +++ b/packages/react/components/menu/utils.ts @@ -28,32 +28,40 @@ export const getChildrenValue = (children?: ReactNode) => { return value.trim(); }; -export const KEYCODES: Record = { - ARROW_RIGHT: ['ArrowRight', 39], - ESCAPE: ['Escape', 27] -}; +/** + * 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; -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 - }) - ); -}; +/** + * 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[] = []; -export const isElementSubMenuTrigger = (element: Element) => { - return element.getAttribute('data-slot') === 'menu-subtrigger'; -}; + 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]; };