From 1346196330e6a5756a5f530bd7b48a217f2e2903 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:46:08 -0500 Subject: [PATCH 1/7] feat: [menu, context-menu] add checkbox, radio, and link items --- .../docs/components/context-menu/demo.ts | 47 ++++ .../docs/components/context-menu/index.mdx | 57 ++++- .../docs/components/context-menu/props.ts | 121 ++++++++- .../src/content/docs/components/menu/demo.ts | 77 ++++++ .../content/docs/components/menu/index.mdx | 82 ++++++- .../src/content/docs/components/menu/props.ts | 121 ++++++++- .../__tests__/context-menu.test.tsx | 77 ++++++ .../__tests__/data-slots.test.tsx | 17 +- .../context-menu/context-menu-content.tsx | 9 +- .../context-menu/context-menu-item.tsx | 53 +++- .../context-menu/context-menu-misc.tsx | 27 ++- .../context-menu/context-menu-trigger.tsx | 9 +- .../components/context-menu/context-menu.tsx | 16 +- .../menu/__tests__/data-slots.test.tsx | 17 +- .../components/menu/__tests__/menu.test.tsx | 229 +++++++++++++++++- .../react/components/menu/cell.module.css | 26 ++ packages/react/components/menu/cell.tsx | 60 +++-- .../components/menu/menu-checkbox-item.tsx | 77 ++++++ .../react/components/menu/menu-content.tsx | 9 +- packages/react/components/menu/menu-item.tsx | 49 +++- .../react/components/menu/menu-link-item.tsx | 69 ++++++ packages/react/components/menu/menu-misc.tsx | 87 ++++++- packages/react/components/menu/menu-radio.tsx | 96 ++++++++ .../react/components/menu/menu-trigger.tsx | 9 +- .../react/components/menu/menu.module.css | 16 ++ packages/react/components/menu/menu.tsx | 11 +- packages/react/shared/item-count/index.ts | 1 + .../react/shared/item-count/item-count.tsx | 51 ++++ 28 files changed, 1425 insertions(+), 95 deletions(-) create mode 100644 packages/react/components/menu/menu-checkbox-item.tsx create mode 100644 packages/react/components/menu/menu-link-item.tsx create mode 100644 packages/react/components/menu/menu-radio.tsx create mode 100644 packages/react/shared/item-count/index.ts create mode 100644 packages/react/shared/item-count/item-count.tsx 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 622a32210..7d11d1155 100644 --- a/apps/www/src/content/docs/components/context-menu/demo.ts +++ b/apps/www/src/content/docs/components/context-menu/demo.ts @@ -239,3 +239,50 @@ function ControlledContextMenu() { ); }` }; + +export const selectionDemo = { + type: 'code', + code: ` +function SelectionContextMenu() { + const [showGrid, setShowGrid] = React.useState(true); + const [sort, setSort] = React.useState("name"); + + return ( + + + Right click here + + + + Show grid + + + + Sort by + Name + Date + + + Open docs + + + ); +}` +}; + +export const emptyDemo = { + type: 'code', + code: ` + + + Right click here + + + Copy + Rename + Delete + No actions found + + + ` +}; 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 2556e3ca8..46bf6dd6e 100644 --- a/apps/www/src/content/docs/components/context-menu/index.mdx +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -12,6 +12,8 @@ import { submenuDemo, autocompleteDemo, controlledDemo, + selectionDemo, + emptyDemo, } from "./demo.ts"; @@ -59,6 +61,12 @@ Use `ContextMenu.Submenu`, `ContextMenu.SubmenuTrigger`, and `ContextMenu.Submen +### Checkbox, radio, and link items + +`ContextMenu.CheckboxItem`, `ContextMenu.RadioGroup`, `ContextMenu.RadioItem`, and `ContextMenu.LinkItem` work the same way as in [Menu](/docs/components/menu#checkbox-items). Checkbox and radio items keep the menu open on click. Link items render an `` element. + + + ### Autocomplete Pass `autocomplete` to the ContextMenu root. Each menu manages its own autocomplete state. @@ -68,6 +76,12 @@ Pass `autocomplete` to the ContextMenu root. Each menu manages its own autocompl +### Empty state and status + +`ContextMenu.EmptyState` shows its children only when the menu has no items, for example when nothing matches the search. `ContextMenu.Status` announces the number of matching items to screen readers while the search input has text. It is not visible. + + + ### Controlled Pass `open` with `onOpenChange` to own the state. Do this when the menu has to close on something other than a selection, or when only one menu may be open at a time. @@ -104,6 +118,30 @@ Renders as a `role='option'` when used in an autocomplete menu. By default, the +### CheckboxItem + +A menu item that toggles a setting on or off. Renders a check mark before the label when checked. + + + +### RadioGroup + +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. + + + +### LinkItem + +A menu item that renders an `` element. Pass a router link to `render` to use client-side routing. + + + ### Group A way to group related menu items together. When filtering is active, the group wrapper is removed and items are rendered flat. @@ -124,10 +162,16 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Placeholder content when there are no menu items to display. +Content to show when the menu has no items. With `autocomplete`, it shows when nothing matches the search. The element stays in the DOM as a live region (`role="status"`), so screen readers announce it when it appears. +### Status + +A live region that announces the number of matching items while the search input has text. It is visually hidden. It renders only in a searchable menu (`autocomplete`), and renders nothing otherwise. + + + ### Submenu Wraps a submenu root. Use with `ContextMenu.SubmenuTrigger` and `ContextMenu.SubmenuContent` to create nested menus. @@ -165,7 +209,14 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `context-menu-group` | `ContextMenu.Group` wrapper | | `context-menu-label` | `ContextMenu.Label` | | `context-menu-separator` | `ContextMenu.Separator` | +| `context-menu-checkbox-item` | Each `ContextMenu.CheckboxItem` | +| `context-menu-checkbox-item-indicator` | Check mark inside a checkbox item | +| `context-menu-radio-group` | `ContextMenu.RadioGroup` wrapper | +| `context-menu-radio-item` | Each `ContextMenu.RadioItem` | +| `context-menu-radio-item-indicator` | Dot inside a radio item | +| `context-menu-link-item` | Each `ContextMenu.LinkItem` | | `context-menu-empty-state` | `ContextMenu.EmptyState` | +| `context-menu-status` | `ContextMenu.Status` (when `autocomplete`) | | `menu-subtrigger` | `ContextMenu.SubmenuTrigger` (shared with `Menu`'s internals) | | `menu-cell-leading-icon` | Leading icon inside an item or submenu trigger (when provided; shared with `Menu`'s internals) | | `menu-cell-trailing-icon` | Trailing icon inside an item or submenu trigger (when provided; shared with `Menu`'s internals) | @@ -184,3 +235,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test 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`. +- `ContextMenu.EmptyState` and `ContextMenu.Status` are polite live regions. 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 e5ac87ed7..9f2f7e0da 100644 --- a/apps/www/src/content/docs/components/context-menu/props.ts +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -127,9 +127,126 @@ export interface ContextMenuSeparatorProps { className?: string; } +export interface ContextMenuCheckboxItemProps { + /** Whether the item is checked (controlled) */ + checked?: boolean; + + /** Whether the item is checked by default (uncontrolled) + * @default false + */ + defaultChecked?: boolean; + + /** Callback fired when the checked state changes */ + onCheckedChange?: (checked: boolean) => void; + + /** Whether to close the menu when the item is clicked + * @default false + */ + closeOnClick?: boolean; + + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** Icon element to display after item text */ + trailingIcon?: ReactNode; + + /** Whether the item is disabled */ + disabled?: boolean; + + /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + value?: string; + + /** Additional CSS class names */ + className?: string; + + /** Render a custom element using Base UI's render prop pattern. Replaces the default layout and check mark. */ + render?: ReactElement; +} + +export interface ContextMenuRadioGroupProps { + /** The value of the checked radio item (controlled) */ + value?: unknown; + + /** The value of the radio item checked by default (uncontrolled) */ + defaultValue?: unknown; + + /** Callback fired when the checked radio item changes */ + onValueChange?: (value: unknown) => void; + + /** Whether all radio items in the group are disabled + * @default false + */ + disabled?: boolean; + + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuRadioItemProps { + /** Value of the radio item. Also used for autocomplete matching when it is a string. */ + value: unknown; + + /** Whether to close the menu when the item is clicked + * @default false + */ + closeOnClick?: boolean; + + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** Icon element to display after item text */ + trailingIcon?: ReactNode; + + /** Whether the item is disabled */ + disabled?: boolean; + + /** Additional CSS class names */ + className?: string; + + /** Render a custom element using Base UI's render prop pattern. Replaces the default layout and dot. */ + render?: ReactElement; +} + +export interface ContextMenuLinkItemProps { + /** The URL the link points to */ + href?: string; + + /** Whether to close the menu when the link is clicked + * @default false + */ + closeOnClick?: boolean; + + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** 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. */ + value?: string; + + /** Additional CSS class names */ + className?: string; + + /** The link element to render, for example a router link. The item keeps its layout and icons. + * @default + */ + render?: ReactElement; +} + export interface ContextMenuEmptyStateProps { - /** React nodes to render in empty state */ - children?: ReactNode; + /** Content to show when the menu has no items or nothing matches the search */ + children: ReactNode; + + /** Additional CSS class names */ + className?: string; +} + +export interface ContextMenuStatusProps { + /** Text to announce. A function receives the number of matching items. + * @default count => `${count} results` + */ + children?: ReactNode | ((count: number) => ReactNode); /** Additional CSS class names */ className?: string; diff --git a/apps/www/src/content/docs/components/menu/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts index ad710a03b..42c616f4d 100644 --- a/apps/www/src/content/docs/components/menu/demo.ts +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -520,3 +520,80 @@ function ControlledMenu() { ); }` }; + +export const checkboxDemo = { + type: 'code', + code: ` +function CheckboxMenu() { + const [showGrid, setShowGrid] = React.useState(true); + + return ( + + }>View + + + Show grid + + Show rulers + Show guides + + + ); +}` +}; + +export const radioDemo = { + type: 'code', + code: ` +function RadioMenu() { + const [layout, setLayout] = React.useState("list"); + + return ( + + }>Layout + + + Layout + List + Board + Timeline + + + + ); +}` +}; + +export const linkDemo = { + type: 'code', + code: ` + + }>Go to + + Projects + Settings + + GitHub + + + ` +}; + +export const emptyDemo = { + type: 'code', + code: ` + + }>Assign + + Ada Lovelace + Grace Hopper + Alan Turing + No people found + + + ` +}; diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index cde35ba55..ec9932676 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -13,6 +13,10 @@ import { autocompleteDemo, linearDemo, controlledDemo, + checkboxDemo, + radioDemo, + linkDemo, + emptyDemo, } from "./demo.ts"; @@ -60,6 +64,30 @@ Use `Menu.Submenu`, `Menu.SubmenuTrigger`, and `Menu.SubmenuContent` to create n +### Checkbox items + +Use `Menu.CheckboxItem` for a setting that turns on or off. Clicking it toggles the check mark and keeps the menu open. Pass `checked` and `onCheckedChange` to control it. + + + +### Radio items + +Wrap `Menu.RadioItem` parts in a `Menu.RadioGroup`. Only one item in the group is checked at a time. Clicking an item keeps the menu open. + + + +### Link items + +Use `Menu.LinkItem` to navigate. It renders an `` element. The menu stays open on click by default. Pass `closeOnClick` to close it, for example with client-side routing. + +To use a router link, pass it to `render`. The item keeps its layout and icons. + +```tsx +}>Projects +``` + + + ### Autocomplete Pass `autocomplete` to the Menu root. Each menu manages its own autocomplete state. @@ -70,6 +98,12 @@ Pass `autocomplete` to the Menu root. Each menu manages its own autocomplete sta +### Empty state and status + +`Menu.EmptyState` shows its children only when the menu has no items, for example when nothing matches the search. `Menu.Status` announces the number of matching items to screen readers while the search input has text. It is not visible. + + + ### Linear inspired Menu This is a Linear-inspired menu component that supports custom filtering and displays nested options. Users can search through all nested items using a single input field. @@ -114,10 +148,34 @@ 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. +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. +### CheckboxItem + +A menu item that toggles a setting on or off. Renders a check mark before the label when checked. Built on [Base UI Menu.CheckboxItem](https://base-ui.com/react/components/menu). + + + +### RadioGroup + +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. + + + +### LinkItem + +A menu item that renders an `` element. Built on [Base UI Menu.LinkItem](https://base-ui.com/react/components/menu). + + + ### Group A way to group related menu items together. When filtering is active, the group wrapper is removed and items are rendered flat. @@ -138,10 +196,16 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Placeholder content when there are no menu items to display. +Content to show when the menu has no items. With `autocomplete`, it shows when nothing matches the search. The element stays in the DOM as a live region (`role="status"`), so screen readers announce it when it appears. Built on [Base UI Autocomplete.Empty](https://base-ui.com/react/components/autocomplete) in a searchable menu. +### Status + +A live region that announces the number of matching items while the search input has text. It is visually hidden. It renders only in a searchable menu (`autocomplete`), and renders nothing otherwise. Built on [Base UI Autocomplete.Status](https://base-ui.com/react/components/autocomplete). + + + ### Submenu Wraps a submenu root. Use with `Menu.SubmenuTrigger` and `Menu.SubmenuContent` to create nested menus. @@ -181,7 +245,14 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `menu-group` | `Menu.Group` wrapper | | `menu-label` | `Menu.Label` | | `menu-separator` | `Menu.Separator` | +| `menu-checkbox-item` | Each `Menu.CheckboxItem` | +| `menu-checkbox-item-indicator` | Check mark inside a checkbox item | +| `menu-radio-group` | `Menu.RadioGroup` wrapper | +| `menu-radio-item` | Each `Menu.RadioItem` | +| `menu-radio-item-indicator` | Dot inside a radio item | +| `menu-link-item` | Each `Menu.LinkItem` | | `menu-empty-state` | `Menu.EmptyState` | +| `menu-status` | `Menu.Status` (when `autocomplete`) | | `menu-subtrigger` | `Menu.SubmenuTrigger` | ## Accessibility @@ -199,3 +270,10 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test submenu, and Escape first closes an open submenu. - `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. +- `Menu.EmptyState` and `Menu.Status` are polite live regions, so screen readers + announce when nothing matches and how many items match. diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts index 632c75f8a..b8fdc0719 100644 --- a/apps/www/src/content/docs/components/menu/props.ts +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -114,6 +114,113 @@ export interface MenuItemProps { render?: ReactElement; } +export interface MenuCheckboxItemProps { + /** Whether the item is checked (controlled) */ + checked?: boolean; + + /** Whether the item is checked by default (uncontrolled) + * @default false + */ + defaultChecked?: boolean; + + /** Callback fired when the checked state changes */ + onCheckedChange?: (checked: boolean) => void; + + /** Whether to close the menu when the item is clicked + * @default false + */ + closeOnClick?: boolean; + + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** Icon element to display after item text */ + trailingIcon?: ReactNode; + + /** Whether the item is disabled */ + disabled?: boolean; + + /** Value of the item used for autocomplete matching. If not provided, `children` text content is used. */ + value?: string; + + /** Additional CSS class names */ + className?: string; + + /** Render a custom element using Base UI's render prop pattern. Replaces the default layout and check mark. */ + render?: ReactElement; +} + +export interface MenuRadioGroupProps { + /** The value of the checked radio item (controlled) */ + value?: unknown; + + /** The value of the radio item checked by default (uncontrolled) */ + defaultValue?: unknown; + + /** Callback fired when the checked radio item changes */ + onValueChange?: (value: unknown) => void; + + /** Whether all radio items in the group are disabled + * @default false + */ + disabled?: boolean; + + /** Additional CSS class names */ + className?: string; +} + +export interface MenuRadioItemProps { + /** Value of the radio item. Also used for autocomplete matching when it is a string. */ + value: unknown; + + /** Whether to close the menu when the item is clicked + * @default false + */ + closeOnClick?: boolean; + + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** Icon element to display after item text */ + trailingIcon?: ReactNode; + + /** Whether the item is disabled */ + disabled?: boolean; + + /** Additional CSS class names */ + className?: string; + + /** Render a custom element using Base UI's render prop pattern. Replaces the default layout and dot. */ + render?: ReactElement; +} + +export interface MenuLinkItemProps { + /** The URL the link points to */ + href?: string; + + /** Whether to close the menu when the link is clicked + * @default false + */ + closeOnClick?: boolean; + + /** Icon element to display before item text */ + leadingIcon?: ReactNode; + + /** 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. */ + value?: string; + + /** Additional CSS class names */ + className?: string; + + /** The link element to render, for example a router link. The item keeps its layout and icons. + * @default + */ + render?: ReactElement; +} + export interface MenuGroupProps { /** Additional CSS class names */ className?: string; @@ -130,8 +237,18 @@ export interface MenuSeparatorProps { } export interface MenuEmptyStateProps { - /** React nodes to render in empty state */ - children?: ReactNode; + /** Content to show when the menu has no items or nothing matches the search */ + children: ReactNode; + + /** Additional CSS class names */ + className?: string; +} + +export interface MenuStatusProps { + /** Text to announce. A function receives the number of matching items. + * @default count => `${count} results` + */ + children?: ReactNode | ((count: number) => ReactNode); /** Additional CSS class names */ className?: string; 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 bb7e2b360..df4d13e4e 100644 --- a/packages/react/components/context-menu/__tests__/context-menu.test.tsx +++ b/packages/react/components/context-menu/__tests__/context-menu.test.tsx @@ -144,4 +144,81 @@ describe('ContextMenu', () => { expect(onOpenChange).toHaveBeenCalled(); }); }); + + describe('Selection and Link Items', () => { + it('toggles checkbox items and keeps one radio item checked', async () => { + await renderAndOpenContextMenu( + + {TRIGGER_TEXT} + + Show grid + + List + Board + + + + ); + + const checkbox = screen.getByRole('menuitemcheckbox'); + fireEvent.click(checkbox); + expect(checkbox).toHaveAttribute('aria-checked', 'true'); + + const [list, board] = screen.getAllByRole('menuitemradio'); + fireEvent.click(board); + expect(list).toHaveAttribute('aria-checked', 'false'); + expect(board).toHaveAttribute('aria-checked', 'true'); + }); + + it('renders link items as anchors with role menuitem', async () => { + await renderAndOpenContextMenu( + + {TRIGGER_TEXT} + + Docs + + + ); + + const link = screen.getByRole('menuitem', { name: 'Docs' }); + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/docs'); + }); + }); + + describe('Autocomplete', () => { + it('filters items and shows the empty state only with no matches', async () => { + await renderAndOpenContextMenu( + + {TRIGGER_TEXT} + + {MENU_ITEMS.map(item => ( + {item.label} + ))} + Show grid + No results + + + + ); + + const input = screen.getByRole('combobox'); + const empty = document.querySelector( + '[data-slot="context-menu-empty-state"]' + ); + const status = document.querySelector( + '[data-slot="context-menu-status"]' + ); + expect(empty).toBeEmptyDOMElement(); + + fireEvent.change(input, { target: { value: 'grid' } }); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(status).toHaveTextContent('1 result'); + expect(empty).toBeEmptyDOMElement(); + + fireEvent.change(input, { target: { value: 'zzz' } }); + expect(screen.queryAllByRole('option')).toHaveLength(0); + expect(empty).toHaveTextContent('No results'); + }); + }); }); diff --git a/packages/react/components/context-menu/__tests__/data-slots.test.tsx b/packages/react/components/context-menu/__tests__/data-slots.test.tsx index f769628d8..8280cc37b 100644 --- a/packages/react/components/context-menu/__tests__/data-slots.test.tsx +++ b/packages/react/components/context-menu/__tests__/data-slots.test.tsx @@ -34,6 +34,13 @@ describe('ContextMenu data-slot contract', () => { Nested + + Checkbox + + + Radio + + Link Nothing here @@ -53,6 +60,12 @@ describe('ContextMenu data-slot contract', () => { 'context-menu-item', 'context-menu-separator', 'context-menu-empty-state', + 'context-menu-checkbox-item', + 'context-menu-checkbox-item-indicator', + 'context-menu-radio-group', + 'context-menu-radio-item', + 'context-menu-radio-item-indicator', + 'context-menu-link-item', 'menu-subtrigger', 'menu-cell-leading-icon', 'menu-cell-trailing-icon' @@ -92,6 +105,7 @@ describe('ContextMenu data-slot contract', () => { {TRIGGER_TEXT} Item + ); @@ -99,7 +113,8 @@ describe('ContextMenu data-slot contract', () => { expectSlots(document.body, [ 'context-menu-search-input', 'context-menu-search-list', - 'context-menu-item' + 'context-menu-item', + 'context-menu-status' ]); }); }); diff --git a/packages/react/components/context-menu/context-menu-content.tsx b/packages/react/components/context-menu/context-menu-content.tsx index c1d05a89e..8fa7e1619 100644 --- a/packages/react/components/context-menu/context-menu-content.tsx +++ b/packages/react/components/context-menu/context-menu-content.tsx @@ -6,6 +6,7 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { KeyboardEvent, useCallback, useRef } from 'react'; +import { ItemCountProvider } from '~/shared/item-count'; import { type Radius, radiusStyle } from '../../shared/radius'; import styles from '../menu/menu.module.css'; import { useMenuContext } from '../menu/menu-root'; @@ -166,9 +167,7 @@ export const ContextMenuContent = ({ placeholder={searchPlaceholder} className={styles.comboboxInput} ref={inputRef} - onPointerEnter={e => { - focusInput(); - }} + onPointerEnter={focusInput} onKeyDown={e => { if (e.key === 'ArrowLeft') return; if (e.key === 'Escape') return checkAndCloseSubMenu(e); @@ -183,11 +182,11 @@ export const ContextMenuContent = ({ className={styles.comboboxContent} ref={containerRef} > - {children} + {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 de6f01ed4..2a3fff9ad 100644 --- a/packages/react/components/context-menu/context-menu-item.tsx +++ b/packages/react/components/context-menu/context-menu-item.tsx @@ -5,8 +5,18 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { Cell, CellBaseProps } from '../menu/cell'; -import { useMenuContext } from '../menu/menu-root'; -import { getMatch } from '../menu/utils'; +import { + CheckboxItemBase, + MenuCheckboxItemProps +} from '../menu/menu-checkbox-item'; +import { stopItemFocus, useMenuItemFilter } from '../menu/menu-item'; +import { LinkItemBase, MenuLinkItemProps } from '../menu/menu-link-item'; +import { + MenuRadioGroupProps, + MenuRadioItemProps, + RadioGroupBase, + RadioItemBase +} from '../menu/menu-radio'; export interface ContextMenuItemProps extends ContextMenuPrimitive.Item.Props, @@ -22,17 +32,14 @@ export const ContextMenuItem = ({ render, ...props }: ContextMenuItemProps) => { - const { autocomplete, inputValue, shouldFilter } = useMenuContext(); + const { autocomplete, hidden } = useMenuItemFilter(value, children); + + if (hidden) return null; const cell = render ?? ( ); - // In auto mode, hide items that don't match the search value - if (shouldFilter && !getMatch(value, children, inputValue)) { - return null; - } - if (autocomplete) { return ( { - e.stopPropagation(); - e.preventDefault(); - e.preventBaseUIHandler(); - }} + onFocus={stopItemFocus} > {children} ); }; ContextMenuItem.displayName = 'ContextMenu.Item'; + +export type ContextMenuCheckboxItemProps = MenuCheckboxItemProps; +export const ContextMenuCheckboxItem = ( + props: ContextMenuCheckboxItemProps +) => ; +ContextMenuCheckboxItem.displayName = 'ContextMenu.CheckboxItem'; + +export type ContextMenuRadioGroupProps = MenuRadioGroupProps; +export const ContextMenuRadioGroup = (props: ContextMenuRadioGroupProps) => ( + +); +ContextMenuRadioGroup.displayName = 'ContextMenu.RadioGroup'; + +export type ContextMenuRadioItemProps = MenuRadioItemProps; +export const ContextMenuRadioItem = (props: ContextMenuRadioItemProps) => ( + +); +ContextMenuRadioItem.displayName = 'ContextMenu.RadioItem'; + +export type ContextMenuLinkItemProps = MenuLinkItemProps; +export const ContextMenuLinkItem = (props: ContextMenuLinkItemProps) => ( + +); +ContextMenuLinkItem.displayName = 'ContextMenu.LinkItem'; diff --git a/packages/react/components/context-menu/context-menu-misc.tsx b/packages/react/components/context-menu/context-menu-misc.tsx index bc1ce7826..abdd4bbbe 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -4,6 +4,12 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ComponentProps, Fragment } from 'react'; import styles from '../menu/menu.module.css'; +import { + EmptyStateBase, + MenuEmptyStateProps, + MenuStatusProps, + StatusBase +} from '../menu/menu-misc'; import { useMenuContext } from '../menu/menu-root'; export type ContextMenuGroupProps = ContextMenuPrimitive.Group.Props; @@ -72,17 +78,14 @@ export const ContextMenuSeparator = ({ }; ContextMenuSeparator.displayName = 'ContextMenu.Separator'; -export const ContextMenuEmptyState = ({ - className, - children, - ...props -}: ComponentProps<'div'>) => ( -
- {children} -
+export type ContextMenuEmptyStateProps = MenuEmptyStateProps; +export const ContextMenuEmptyState = (props: ContextMenuEmptyStateProps) => ( + ); ContextMenuEmptyState.displayName = 'ContextMenu.EmptyState'; + +export type ContextMenuStatusProps = MenuStatusProps; +export const ContextMenuStatus = (props: ContextMenuStatusProps) => ( + +); +ContextMenuStatus.displayName = 'ContextMenu.Status'; diff --git a/packages/react/components/context-menu/context-menu-trigger.tsx b/packages/react/components/context-menu/context-menu-trigger.tsx index 9822a36f5..031fca621 100644 --- a/packages/react/components/context-menu/context-menu-trigger.tsx +++ b/packages/react/components/context-menu/context-menu-trigger.tsx @@ -5,6 +5,7 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { ChevronRightIcon } from '~/icons'; +import { useRegisterItem } from '~/shared/item-count'; import { Cell, CellBaseProps } from '../menu/cell'; import { useMenuContext } from '../menu/menu-root'; import { getMatch } from '../menu/utils'; @@ -39,9 +40,11 @@ export const ContextMenuSubTrigger = ({ }: ContextMenuSubTriggerProps) => { const { parent, inputRef } = useMenuContext(); - if (parent?.shouldFilter && !getMatch(value, children, parent?.inputValue)) { - return null; - } + const hidden = + parent?.shouldFilter && !getMatch(value, children, parent?.inputValue); + useRegisterItem(!hidden); + + if (hidden) return null; const cell = ; return ( diff --git a/packages/react/components/context-menu/context-menu.tsx b/packages/react/components/context-menu/context-menu.tsx index 15ce97fce..539fad1f8 100644 --- a/packages/react/components/context-menu/context-menu.tsx +++ b/packages/react/components/context-menu/context-menu.tsx @@ -2,12 +2,19 @@ import { ContextMenuContent, ContextMenuSubContent } from './context-menu-content'; -import { ContextMenuItem } from './context-menu-item'; +import { + ContextMenuCheckboxItem, + ContextMenuItem, + ContextMenuLinkItem, + ContextMenuRadioGroup, + ContextMenuRadioItem +} from './context-menu-item'; import { ContextMenuEmptyState, ContextMenuGroup, ContextMenuLabel, - ContextMenuSeparator + ContextMenuSeparator, + ContextMenuStatus } from './context-menu-misc'; import { ContextMenuRoot, ContextMenuSubMenu } from './context-menu-root'; import { @@ -19,10 +26,15 @@ export const ContextMenu = Object.assign(ContextMenuRoot, { Trigger: ContextMenuTrigger, Content: ContextMenuContent, Item: ContextMenuItem, + CheckboxItem: ContextMenuCheckboxItem, + RadioGroup: ContextMenuRadioGroup, + RadioItem: ContextMenuRadioItem, + LinkItem: ContextMenuLinkItem, Group: ContextMenuGroup, Label: ContextMenuLabel, Separator: ContextMenuSeparator, EmptyState: ContextMenuEmptyState, + Status: ContextMenuStatus, Submenu: ContextMenuSubMenu, SubmenuTrigger: ContextMenuSubTrigger, SubmenuContent: ContextMenuSubContent diff --git a/packages/react/components/menu/__tests__/data-slots.test.tsx b/packages/react/components/menu/__tests__/data-slots.test.tsx index 51aedf5a2..e85a18657 100644 --- a/packages/react/components/menu/__tests__/data-slots.test.tsx +++ b/packages/react/components/menu/__tests__/data-slots.test.tsx @@ -34,6 +34,11 @@ describe('Menu data-slot contract', () => { Nested + Checkbox + + Radio + + Link Nothing here @@ -53,7 +58,13 @@ describe('Menu data-slot contract', () => { 'menu-cell-trailing-icon', 'menu-separator', 'menu-subtrigger', - 'menu-empty-state' + 'menu-empty-state', + 'menu-checkbox-item', + 'menu-checkbox-item-indicator', + 'menu-radio-group', + 'menu-radio-item', + 'menu-radio-item-indicator', + 'menu-link-item' ]); }); @@ -104,6 +115,7 @@ describe('Menu data-slot contract', () => { {TRIGGER_TEXT} Item + ); @@ -111,7 +123,8 @@ describe('Menu data-slot contract', () => { expectSlots(document.body, [ 'menu-search-input', 'menu-search-list', - 'menu-item' + 'menu-item', + 'menu-status' ]); }); }); diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index ceb2617c1..c6e8f193c 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -1,5 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../button/button'; import { Menu } from '../menu'; @@ -134,4 +133,230 @@ describe('Menu', () => { expect(onOpenChange).toHaveBeenCalled(); }); }); + + describe('Checkbox Items', () => { + it('toggles the checked state and aria-checked', async () => { + const onCheckedChange = vi.fn(); + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + + Show grid + + + + ); + + const item = screen.getByRole('menuitemcheckbox'); + expect(item).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(item); + expect(item).toHaveAttribute('aria-checked', 'true'); + expect(onCheckedChange).toHaveBeenCalledWith(true, expect.anything()); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + fireEvent.click(item); + expect(item).toHaveAttribute('aria-checked', 'false'); + }); + + it('respects the controlled checked prop', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Show grid + + + ); + + const item = screen.getByRole('menuitemcheckbox'); + fireEvent.click(item); + expect(item).toHaveAttribute('aria-checked', 'true'); + }); + }); + + describe('Radio Items', () => { + it('keeps a single item checked in a group', async () => { + const onValueChange = vi.fn(); + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + + List + Board + + + + ); + + const [list, board] = screen.getAllByRole('menuitemradio'); + expect(list).toHaveAttribute('aria-checked', 'true'); + expect(board).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(board); + expect(list).toHaveAttribute('aria-checked', 'false'); + expect(board).toHaveAttribute('aria-checked', 'true'); + expect(onValueChange).toHaveBeenCalledWith('board', expect.anything()); + }); + }); + + describe('Link Items', () => { + it('renders an anchor with role menuitem', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Projects + + + ); + + const link = screen.getByRole('menuitem', { name: 'Projects' }); + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/projects'); + }); + + it('renders a custom link element through render', async () => { + const RouterLink = (props: React.ComponentProps<'a'>) => ( +
+ ); + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + }> + Settings + + + + ); + + const link = screen.getByRole('menuitem', { name: 'Settings' }); + expect(link).toHaveAttribute('data-router'); + expect(link).toHaveAttribute('href', '/settings'); + }); + }); + + describe('Empty State', () => { + it('shows only when the menu has no items', async () => { + const { unmount } = render( + + {TRIGGER_TEXT} + + Profile + No actions + + + ); + fireEvent.click(screen.getByText(TRIGGER_TEXT)); + expect(screen.queryByText(/No actions/)).not.toBeInTheDocument(); + unmount(); + + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + No actions + + + ); + expect(screen.getByRole('status')).toHaveTextContent('No actions'); + }); + }); + + describe('Autocomplete', () => { + const SearchMenu = () => ( + + {TRIGGER_TEXT} + + {MENU_ITEMS.map(item => ( + {item.label} + ))} + Show grid + + List view + Board view + + Docs + No results + + + + ); + + const search = (value: string) => { + fireEvent.change(screen.getByRole('combobox'), { target: { value } }); + }; + + it('filters items as the user types', async () => { + await renderAndOpenDropdown(); + expect(screen.getAllByRole('option')).toHaveLength(9); + + search('set'); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Settings'); + + search('view'); + expect(screen.getAllByRole('option')).toHaveLength(2); + }); + + it('shows the empty state only when nothing matches', async () => { + await renderAndOpenDropdown(); + const empty = document.querySelector('[data-slot="menu-empty-state"]'); + expect(empty).toHaveAttribute('role', 'status'); + expect(empty).toBeEmptyDOMElement(); + + search('zzz'); + expect(empty).toHaveTextContent('No results'); + + search('team'); + expect(empty).toBeEmptyDOMElement(); + }); + + it('announces the number of matches', async () => { + await renderAndOpenDropdown(); + const status = document.querySelector('[data-slot="menu-status"]'); + expect(status).toHaveAttribute('role', 'status'); + expect(status).toBeEmptyDOMElement(); + + search('view'); + expect(status).toHaveTextContent('2 results'); + + search('docs'); + expect(status).toHaveTextContent('1 result'); + + search('zzz'); + expect(status).toHaveTextContent('0 results'); + }); + + it('toggles checkbox and radio items and keeps the search', async () => { + await renderAndOpenDropdown(); + + search('grid'); + const checkbox = screen.getByRole('option', { name: 'Show grid' }); + expect(checkbox).toHaveAttribute('aria-checked', 'false'); + fireEvent.click(checkbox); + expect(checkbox).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('combobox')).toHaveValue('grid'); + + search('view'); + const board = screen.getByRole('option', { name: 'Board view' }); + fireEvent.click(board); + expect(board).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('option', { name: 'List view' })).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('renders link items as anchors', async () => { + await renderAndOpenDropdown(); + const link = screen.getByRole('option', { name: 'Docs' }); + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/docs'); + }); + }); }); diff --git a/packages/react/components/menu/cell.module.css b/packages/react/components/menu/cell.module.css index 47a53547e..8dbedbecd 100644 --- a/packages/react/components/menu/cell.module.css +++ b/packages/react/components/menu/cell.module.css @@ -39,3 +39,29 @@ color: var(--rs-color-foreground-base-secondary); margin-left: auto; } + +.indicator { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: var(--rs-space-5); + height: var(--rs-space-5); + color: var(--rs-color-foreground-base-primary); +} + +.indicator[data-unchecked] > * { + visibility: hidden; +} + +.radioDot { + width: var(--rs-space-3); + height: var(--rs-space-3); + border-radius: var(--rs-radius-full); + background-color: currentColor; +} + +a.cell { + color: inherit; + text-decoration: none; +} diff --git a/packages/react/components/menu/cell.tsx b/packages/react/components/menu/cell.tsx index fd46fec77..2d013274e 100644 --- a/packages/react/components/menu/cell.tsx +++ b/packages/react/components/menu/cell.tsx @@ -1,14 +1,16 @@ +import { mergeProps, useRender } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ComponentProps, ReactNode } from 'react'; +import { ReactNode } from 'react'; import styles from './cell.module.css'; export type CellBaseProps = { leadingIcon?: ReactNode; trailingIcon?: ReactNode; }; -export type CellProps = ComponentProps<'div'> & +export type CellProps = useRender.ComponentProps<'div'> & CellBaseProps & { - type?: 'select' | 'item'; + /** Check or radio mark, rendered in a fixed-width column before the icon. */ + indicator?: ReactNode; }; export function Cell({ @@ -16,26 +18,40 @@ export function Cell({ children, leadingIcon, trailingIcon, - type = 'item', + indicator, + render, + ref, ...props }: CellProps) { - return ( -
- {leadingIcon && ( - - {leadingIcon} - - )} - {children} - {trailingIcon && ( - - {trailingIcon} - - )} -
- ); + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>(props, { + className: cx(styles.cell, className), + children: ( + <> + {indicator} + {leadingIcon && ( + + {leadingIcon} + + )} + {children} + {trailingIcon && ( + + {trailingIcon} + + )} + + ) + }) + }); } Cell.displayName = 'Menu.Cell'; diff --git a/packages/react/components/menu/menu-checkbox-item.tsx b/packages/react/components/menu/menu-checkbox-item.tsx new file mode 100644 index 000000000..2f0ade1fa --- /dev/null +++ b/packages/react/components/menu/menu-checkbox-item.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; +import { CheckIcon } from '~/icons'; +import { Cell, CellBaseProps } from './cell'; +import cellStyles from './cell.module.css'; +import { stopItemFocus, useMenuItemFilter } from './menu-item'; + +export interface MenuCheckboxItemProps + extends MenuPrimitive.CheckboxItem.Props, + CellBaseProps { + /** Text used to match the search value. Defaults to the children text. */ + value?: string; +} + +/** @remarks Only for internal usage. */ +export function CheckboxItemBase({ + slotPrefix, + children, + value, + leadingIcon, + trailingIcon, + render, + ...props +}: MenuCheckboxItemProps & { slotPrefix: string }) { + const { autocomplete, hidden } = useMenuItemFilter(value, children); + + if (hidden) return null; + + const cell = render ?? ( + + + + } + leadingIcon={leadingIcon} + trailingIcon={trailingIcon} + /> + ); + + if (autocomplete) { + return ( + } + > + {children} + + ); + } + + return ( + + {children} + + ); +} + +export function MenuCheckboxItem(props: MenuCheckboxItemProps) { + return ; +} +MenuCheckboxItem.displayName = 'Menu.CheckboxItem'; diff --git a/packages/react/components/menu/menu-content.tsx b/packages/react/components/menu/menu-content.tsx index bebefc19f..4cd32a828 100644 --- a/packages/react/components/menu/menu-content.tsx +++ b/packages/react/components/menu/menu-content.tsx @@ -6,6 +6,7 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { KeyboardEvent, useCallback, useRef } from 'react'; +import { ItemCountProvider } from '~/shared/item-count'; import { type Radius, radiusStyle } from '../../shared/radius'; import { useThemeInjection } from '../theme/portal'; import styles from './menu.module.css'; @@ -167,9 +168,7 @@ export function MenuContent({ placeholder={searchPlaceholder} className={styles.comboboxInput} ref={inputRef} - onPointerEnter={e => { - focusInput(); - }} + onPointerEnter={focusInput} onKeyDown={e => { if (e.key === 'ArrowLeft') return; if (e.key === 'Escape') return checkAndCloseSubMenu(e); @@ -184,11 +183,11 @@ export function MenuContent({ className={styles.comboboxContent} ref={containerRef} > - {children} + {children} ) : ( - children + {children} )} diff --git a/packages/react/components/menu/menu-item.tsx b/packages/react/components/menu/menu-item.tsx index d523f476a..c987de383 100644 --- a/packages/react/components/menu/menu-item.tsx +++ b/packages/react/components/menu/menu-item.tsx @@ -4,10 +4,44 @@ import { Autocomplete as AutocompletePrimitive, Menu as MenuPrimitive } from '@base-ui/react'; +import { ReactNode } from 'react'; +import { useRegisterItem } from '~/shared/item-count'; 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), + * and counts it for `Menu.EmptyState` and `Menu.Status` when it shows. + * @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 + ); + useRegisterItem(!hidden); + return { autocomplete, hidden }; +} + +/** + * Keeps focus handling out of Base UI for items in a plain menu. + * @remarks Only for internal usage. + */ +export const stopItemFocus = (e: { + stopPropagation: () => void; + preventDefault: () => void; + preventBaseUIHandler: () => void; +}) => { + e.stopPropagation(); + e.preventDefault(); + e.preventBaseUIHandler(); +}; + export interface MenuItemProps extends MenuPrimitive.Item.Props, CellBaseProps { value?: string; } @@ -20,17 +54,14 @@ export function MenuItem({ render, ...props }: MenuItemProps) { - const { autocomplete, inputValue, shouldFilter } = useMenuContext(); + const { autocomplete, hidden } = useMenuItemFilter(value, children); + + if (hidden) return null; const cell = render ?? ( ); - // In auto mode, hide items that don't match the search value - if (shouldFilter && !getMatch(value, children, inputValue)) { - return null; - } - if (autocomplete) { return ( { - e.stopPropagation(); - e.preventDefault(); - e.preventBaseUIHandler(); - }} + onFocus={stopItemFocus} > {children} diff --git a/packages/react/components/menu/menu-link-item.tsx b/packages/react/components/menu/menu-link-item.tsx new file mode 100644 index 000000000..5573980ac --- /dev/null +++ b/packages/react/components/menu/menu-link-item.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; +import { ReactElement } from 'react'; +import { Cell, CellBaseProps } from './cell'; +import { stopItemFocus, useMenuItemFilter } from './menu-item'; + +export interface MenuLinkItemProps + extends Omit, + CellBaseProps { + /** Text used to match the search value. Defaults to the children text. */ + value?: string; + /** The link element to render, for example a router link. */ + render?: ReactElement; +} + +/** @remarks Only for internal usage. */ +export function LinkItemBase({ + slotPrefix, + children, + value, + leadingIcon, + trailingIcon, + render =
, + ...props +}: MenuLinkItemProps & { slotPrefix: string }) { + const { autocomplete, hidden } = useMenuItemFilter(value, children); + + if (hidden) return null; + + const cell = ( + + ); + + if (autocomplete) { + return ( + } + > + {children} + + ); + } + + return ( + + {children} + + ); +} + +export function MenuLinkItem(props: MenuLinkItemProps) { + return ; +} +MenuLinkItem.displayName = 'Menu.LinkItem'; diff --git a/packages/react/components/menu/menu-misc.tsx b/packages/react/components/menu/menu-misc.tsx index 78c3c8ba3..209a8fe9d 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -1,8 +1,12 @@ 'use client'; -import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ComponentProps, Fragment, ReactNode } from 'react'; +import { useItemCount } from '~/shared/item-count'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; @@ -67,19 +71,90 @@ export function MenuSeparator({ className, ...props }: ComponentProps<'div'>) { } MenuSeparator.displayName = 'Menu.Separator'; -export function MenuEmptyState({ +export interface MenuEmptyStateProps extends ComponentProps<'div'> { + children: ReactNode; +} + +/** @remarks Only for internal usage. */ +export function EmptyStateBase({ + slotPrefix, className, children, ...props -}: ComponentProps<'div'> & { children: ReactNode }) { +}: MenuEmptyStateProps & { slotPrefix: string }) { + const { autocomplete } = useMenuContext(); + const count = useItemCount(); + const content = count ? null : children; + const sharedProps = { + 'data-slot': `${slotPrefix}-empty-state`, + className: cx(styles.empty, className) + }; + + if (autocomplete) { + return ( + + {content} + + ); + } + return (
- {children} + {content}
); } + +export function MenuEmptyState(props: MenuEmptyStateProps) { + return ; +} MenuEmptyState.displayName = 'Menu.EmptyState'; + +export interface MenuStatusProps + extends Omit, 'children'> { + /** Text to announce. Receives the number of matching items. */ + children?: ReactNode | ((count: number) => ReactNode); +} + +const defaultStatus = (count: number) => + `${count} ${count === 1 ? 'result' : 'results'}`; + +/** @remarks Only for internal usage. */ +export function StatusBase({ + slotPrefix, + className, + children = defaultStatus, + ...props +}: MenuStatusProps & { slotPrefix: string }) { + const { autocomplete, inputValue } = useMenuContext(); + const count = useItemCount() ?? 0; + + if (!autocomplete) return null; + + const content = inputValue + ? typeof children === 'function' + ? children(count) + : children + : null; + + return ( + + {content} + + ); +} + +export function MenuStatus(props: MenuStatusProps) { + return ; +} +MenuStatus.displayName = 'Menu.Status'; diff --git a/packages/react/components/menu/menu-radio.tsx b/packages/react/components/menu/menu-radio.tsx new file mode 100644 index 000000000..315621e85 --- /dev/null +++ b/packages/react/components/menu/menu-radio.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { + Autocomplete as AutocompletePrimitive, + Menu as MenuPrimitive +} from '@base-ui/react'; +import { Cell, CellBaseProps } from './cell'; +import cellStyles from './cell.module.css'; +import { stopItemFocus, useMenuItemFilter } from './menu-item'; + +export type MenuRadioGroupProps = MenuPrimitive.RadioGroup.Props; + +/** @remarks Only for internal usage. */ +export function RadioGroupBase({ + slotPrefix, + ...props +}: MenuRadioGroupProps & { slotPrefix: string }) { + return ( + + ); +} + +export function MenuRadioGroup(props: MenuRadioGroupProps) { + return ; +} +MenuRadioGroup.displayName = 'Menu.RadioGroup'; + +export interface MenuRadioItemProps + extends MenuPrimitive.RadioItem.Props, + CellBaseProps {} + +/** @remarks Only for internal usage. */ +export function RadioItemBase({ + slotPrefix, + children, + value, + leadingIcon, + trailingIcon, + render, + ...props +}: MenuRadioItemProps & { slotPrefix: string }) { + const { autocomplete, hidden } = useMenuItemFilter(value, children); + + if (hidden) return null; + + const cell = render ?? ( + + + + } + leadingIcon={leadingIcon} + trailingIcon={trailingIcon} + /> + ); + + if (autocomplete) { + return ( + + } + > + {children} + + ); + } + + return ( + + {children} + + ); +} + +export function MenuRadioItem(props: MenuRadioItemProps) { + return ; +} +MenuRadioItem.displayName = 'Menu.RadioItem'; diff --git a/packages/react/components/menu/menu-trigger.tsx b/packages/react/components/menu/menu-trigger.tsx index 85a2ce36c..e5ee7f545 100644 --- a/packages/react/components/menu/menu-trigger.tsx +++ b/packages/react/components/menu/menu-trigger.tsx @@ -4,6 +4,7 @@ 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'; +import { useRegisterItem } from '~/shared/item-count'; import { Button } from '../button'; import { useMenubarContext } from '../menubar/menubar'; import { Cell, CellBaseProps } from './cell'; @@ -63,9 +64,11 @@ export function MenuSubTrigger({ }: MenuSubTriggerProps) { const { parent, inputRef } = useMenuContext(); - if (parent?.shouldFilter && !getMatch(value, children, parent?.inputValue)) { - return null; - } + const hidden = + parent?.shouldFilter && !getMatch(value, children, parent?.inputValue); + useRegisterItem(!hidden); + + if (hidden) return null; const cell = ; return ( diff --git a/packages/react/components/menu/menu.module.css b/packages/react/components/menu/menu.module.css index e500bdb15..71d9d34df 100644 --- a/packages/react/components/menu/menu.module.css +++ b/packages/react/components/menu/menu.module.css @@ -99,6 +99,22 @@ text-wrap: pretty; } +.empty:empty { + padding: 0; +} + +.status { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .menuBarTrigger[data-pressed] { background-color: var(--rs-color-background-base-primary-hover); } diff --git a/packages/react/components/menu/menu.tsx b/packages/react/components/menu/menu.tsx index 348eccd4d..afcbdde3a 100644 --- a/packages/react/components/menu/menu.tsx +++ b/packages/react/components/menu/menu.tsx @@ -1,12 +1,16 @@ import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { MenuCheckboxItem } from './menu-checkbox-item'; import { MenuContent, MenuSubContent } from './menu-content'; import { MenuItem } from './menu-item'; +import { MenuLinkItem } from './menu-link-item'; import { MenuEmptyState, MenuGroup, MenuLabel, - MenuSeparator + MenuSeparator, + MenuStatus } from './menu-misc'; +import { MenuRadioGroup, MenuRadioItem } from './menu-radio'; import { MenuRoot, MenuSubMenu } from './menu-root'; import { MenuSubTrigger, MenuTrigger } from './menu-trigger'; @@ -14,10 +18,15 @@ export const Menu = Object.assign(MenuRoot, { Trigger: MenuTrigger, Content: MenuContent, Item: MenuItem, + CheckboxItem: MenuCheckboxItem, + RadioGroup: MenuRadioGroup, + RadioItem: MenuRadioItem, + LinkItem: MenuLinkItem, Group: MenuGroup, Label: MenuLabel, Separator: MenuSeparator, EmptyState: MenuEmptyState, + Status: MenuStatus, Submenu: MenuSubMenu, SubmenuTrigger: MenuSubTrigger, SubmenuContent: MenuSubContent, diff --git a/packages/react/shared/item-count/index.ts b/packages/react/shared/item-count/index.ts new file mode 100644 index 000000000..a6b9c1531 --- /dev/null +++ b/packages/react/shared/item-count/index.ts @@ -0,0 +1 @@ +export * from './item-count'; diff --git a/packages/react/shared/item-count/item-count.tsx b/packages/react/shared/item-count/item-count.tsx new file mode 100644 index 000000000..a1b9d8774 --- /dev/null +++ b/packages/react/shared/item-count/item-count.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useState +} from 'react'; +import { useIsomorphicLayoutEffect } from '~/hooks/useIsomorphicLayoutEffect'; + +/* + * Counts the items a list renders. Lists that filter their own children + * (instead of using Base UI's `items` prop) use the count to know when nothing + * matches and how many results to announce. + */ + +type RegisterItem = () => () => void; + +const RegisterItemContext = createContext(null); +const ItemCountContext = createContext(null); + +export function ItemCountProvider({ children }: { children: ReactNode }) { + const [count, setCount] = useState(0); + + const register = useCallback(() => { + setCount(value => value + 1); + return () => setCount(value => value - 1); + }, []); + + return ( + + {children} + + ); +} + +/** Adds the calling item to the nearest count while `enabled` is true. */ +export function useRegisterItem(enabled = true) { + const register = useContext(RegisterItemContext); + + useIsomorphicLayoutEffect(() => { + if (!enabled || !register) return; + return register(); + }, [enabled, register]); +} + +/** The number of registered items, or `null` outside a provider. */ +export function useItemCount() { + return useContext(ItemCountContext); +} From 9da5ce184a4d923caaf33b169e3664fa8cc1471e Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 00:35:05 +0530 Subject: [PATCH 2/7] refactor: [menu, context-menu] remove the status part and item count Menu.EmptyState and ContextMenu.EmptyState go back to a plain element that the app shows and hides. Menu.Status, ContextMenu.Status, and the shared item count are removed, with their docs, demos, and tests. --- .../docs/components/context-menu/demo.ts | 17 ---- .../docs/components/context-menu/index.mdx | 17 +--- .../docs/components/context-menu/props.ts | 14 +-- .../src/content/docs/components/menu/demo.ts | 15 ---- .../content/docs/components/menu/index.mdx | 18 +--- .../src/content/docs/components/menu/props.ts | 14 +-- .../__tests__/context-menu.test.tsx | 15 +--- .../__tests__/data-slots.test.tsx | 4 +- .../context-menu/context-menu-content.tsx | 9 +- .../context-menu/context-menu-misc.tsx | 27 +++--- .../context-menu/context-menu-trigger.tsx | 9 +- .../components/context-menu/context-menu.tsx | 4 +- .../menu/__tests__/data-slots.test.tsx | 4 +- .../components/menu/__tests__/menu.test.tsx | 61 +------------ .../react/components/menu/menu-content.tsx | 9 +- packages/react/components/menu/menu-item.tsx | 5 +- packages/react/components/menu/menu-misc.tsx | 87 ++----------------- .../react/components/menu/menu-trigger.tsx | 9 +- .../react/components/menu/menu.module.css | 16 ---- packages/react/components/menu/menu.tsx | 4 +- packages/react/shared/item-count/index.ts | 1 - .../react/shared/item-count/item-count.tsx | 51 ----------- 22 files changed, 48 insertions(+), 362 deletions(-) delete mode 100644 packages/react/shared/item-count/index.ts delete mode 100644 packages/react/shared/item-count/item-count.tsx 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 7d11d1155..9e6a976e8 100644 --- a/apps/www/src/content/docs/components/context-menu/demo.ts +++ b/apps/www/src/content/docs/components/context-menu/demo.ts @@ -269,20 +269,3 @@ function SelectionContextMenu() { ); }` }; - -export const emptyDemo = { - type: 'code', - code: ` - - - Right click here - - - Copy - Rename - Delete - No actions found - - - ` -}; 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 46bf6dd6e..56becec99 100644 --- a/apps/www/src/content/docs/components/context-menu/index.mdx +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -13,7 +13,6 @@ import { autocompleteDemo, controlledDemo, selectionDemo, - emptyDemo, } from "./demo.ts"; @@ -76,12 +75,6 @@ Pass `autocomplete` to the ContextMenu root. Each menu manages its own autocompl -### Empty state and status - -`ContextMenu.EmptyState` shows its children only when the menu has no items, for example when nothing matches the search. `ContextMenu.Status` announces the number of matching items to screen readers while the search input has text. It is not visible. - - - ### Controlled Pass `open` with `onOpenChange` to own the state. Do this when the menu has to close on something other than a selection, or when only one menu may be open at a time. @@ -162,16 +155,10 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Content to show when the menu has no items. With `autocomplete`, it shows when nothing matches the search. The element stays in the DOM as a live region (`role="status"`), so screen readers announce it when it appears. +Placeholder content when there are no menu items to display. -### Status - -A live region that announces the number of matching items while the search input has text. It is visually hidden. It renders only in a searchable menu (`autocomplete`), and renders nothing otherwise. - - - ### Submenu Wraps a submenu root. Use with `ContextMenu.SubmenuTrigger` and `ContextMenu.SubmenuContent` to create nested menus. @@ -216,7 +203,6 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `context-menu-radio-item-indicator` | Dot inside a radio item | | `context-menu-link-item` | Each `ContextMenu.LinkItem` | | `context-menu-empty-state` | `ContextMenu.EmptyState` | -| `context-menu-status` | `ContextMenu.Status` (when `autocomplete`) | | `menu-subtrigger` | `ContextMenu.SubmenuTrigger` (shared with `Menu`'s internals) | | `menu-cell-leading-icon` | Leading icon inside an item or submenu trigger (when provided; shared with `Menu`'s internals) | | `menu-cell-trailing-icon` | Trailing icon inside an item or submenu trigger (when provided; shared with `Menu`'s internals) | @@ -238,4 +224,3 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test - 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`. -- `ContextMenu.EmptyState` and `ContextMenu.Status` are polite live regions. 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 9f2f7e0da..36d04c9ce 100644 --- a/apps/www/src/content/docs/components/context-menu/props.ts +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -235,18 +235,8 @@ export interface ContextMenuLinkItemProps { } export interface ContextMenuEmptyStateProps { - /** Content to show when the menu has no items or nothing matches the search */ - children: ReactNode; - - /** Additional CSS class names */ - className?: string; -} - -export interface ContextMenuStatusProps { - /** Text to announce. A function receives the number of matching items. - * @default count => `${count} results` - */ - children?: ReactNode | ((count: number) => ReactNode); + /** React nodes to render in empty state */ + children?: ReactNode; /** Additional CSS class names */ className?: string; diff --git a/apps/www/src/content/docs/components/menu/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts index 42c616f4d..83d641720 100644 --- a/apps/www/src/content/docs/components/menu/demo.ts +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -582,18 +582,3 @@ export const linkDemo = { ` }; - -export const emptyDemo = { - type: 'code', - code: ` - - }>Assign - - Ada Lovelace - Grace Hopper - Alan Turing - No people found - - - ` -}; diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index ec9932676..b60bffdec 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -16,7 +16,6 @@ import { checkboxDemo, radioDemo, linkDemo, - emptyDemo, } from "./demo.ts"; @@ -98,12 +97,6 @@ Pass `autocomplete` to the Menu root. Each menu manages its own autocomplete sta -### Empty state and status - -`Menu.EmptyState` shows its children only when the menu has no items, for example when nothing matches the search. `Menu.Status` announces the number of matching items to screen readers while the search input has text. It is not visible. - - - ### Linear inspired Menu This is a Linear-inspired menu component that supports custom filtering and displays nested options. Users can search through all nested items using a single input field. @@ -196,16 +189,10 @@ Visual divider between menu items or groups. Hidden when filtering is active. ### EmptyState -Content to show when the menu has no items. With `autocomplete`, it shows when nothing matches the search. The element stays in the DOM as a live region (`role="status"`), so screen readers announce it when it appears. Built on [Base UI Autocomplete.Empty](https://base-ui.com/react/components/autocomplete) in a searchable menu. +Placeholder content when there are no menu items to display. -### Status - -A live region that announces the number of matching items while the search input has text. It is visually hidden. It renders only in a searchable menu (`autocomplete`), and renders nothing otherwise. Built on [Base UI Autocomplete.Status](https://base-ui.com/react/components/autocomplete). - - - ### Submenu Wraps a submenu root. Use with `Menu.SubmenuTrigger` and `Menu.SubmenuContent` to create nested menus. @@ -252,7 +239,6 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `menu-radio-item-indicator` | Dot inside a radio item | | `menu-link-item` | Each `Menu.LinkItem` | | `menu-empty-state` | `Menu.EmptyState` | -| `menu-status` | `Menu.Status` (when `autocomplete`) | | `menu-subtrigger` | `Menu.SubmenuTrigger` | ## Accessibility @@ -275,5 +261,3 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test render as `role="option"` and keep `aria-checked`. - `Menu.LinkItem` renders an `
` with `role="menuitem"`, or `role="option"` in a searchable menu. -- `Menu.EmptyState` and `Menu.Status` are polite live regions, so screen readers - announce when nothing matches and how many items match. diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts index b8fdc0719..9049c3299 100644 --- a/apps/www/src/content/docs/components/menu/props.ts +++ b/apps/www/src/content/docs/components/menu/props.ts @@ -237,18 +237,8 @@ export interface MenuSeparatorProps { } export interface MenuEmptyStateProps { - /** Content to show when the menu has no items or nothing matches the search */ - children: ReactNode; - - /** Additional CSS class names */ - className?: string; -} - -export interface MenuStatusProps { - /** Text to announce. A function receives the number of matching items. - * @default count => `${count} results` - */ - children?: ReactNode | ((count: number) => ReactNode); + /** React nodes to render in empty state */ + children?: ReactNode; /** Additional CSS class names */ className?: string; 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 df4d13e4e..f14f9b4d8 100644 --- a/packages/react/components/context-menu/__tests__/context-menu.test.tsx +++ b/packages/react/components/context-menu/__tests__/context-menu.test.tsx @@ -187,7 +187,7 @@ describe('ContextMenu', () => { }); describe('Autocomplete', () => { - it('filters items and shows the empty state only with no matches', async () => { + it('filters checkbox items with the other items', async () => { await renderAndOpenContextMenu( {TRIGGER_TEXT} @@ -196,29 +196,16 @@ describe('ContextMenu', () => { {item.label} ))} Show grid - No results - ); const input = screen.getByRole('combobox'); - const empty = document.querySelector( - '[data-slot="context-menu-empty-state"]' - ); - const status = document.querySelector( - '[data-slot="context-menu-status"]' - ); - expect(empty).toBeEmptyDOMElement(); - fireEvent.change(input, { target: { value: 'grid' } }); expect(screen.getAllByRole('option')).toHaveLength(1); - expect(status).toHaveTextContent('1 result'); - expect(empty).toBeEmptyDOMElement(); fireEvent.change(input, { target: { value: 'zzz' } }); expect(screen.queryAllByRole('option')).toHaveLength(0); - expect(empty).toHaveTextContent('No results'); }); }); }); diff --git a/packages/react/components/context-menu/__tests__/data-slots.test.tsx b/packages/react/components/context-menu/__tests__/data-slots.test.tsx index 8280cc37b..59d713f4c 100644 --- a/packages/react/components/context-menu/__tests__/data-slots.test.tsx +++ b/packages/react/components/context-menu/__tests__/data-slots.test.tsx @@ -105,7 +105,6 @@ describe('ContextMenu data-slot contract', () => { {TRIGGER_TEXT} Item - ); @@ -113,8 +112,7 @@ describe('ContextMenu data-slot contract', () => { expectSlots(document.body, [ 'context-menu-search-input', 'context-menu-search-list', - 'context-menu-item', - 'context-menu-status' + 'context-menu-item' ]); }); }); diff --git a/packages/react/components/context-menu/context-menu-content.tsx b/packages/react/components/context-menu/context-menu-content.tsx index 8fa7e1619..c1d05a89e 100644 --- a/packages/react/components/context-menu/context-menu-content.tsx +++ b/packages/react/components/context-menu/context-menu-content.tsx @@ -6,7 +6,6 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { KeyboardEvent, useCallback, useRef } from 'react'; -import { ItemCountProvider } from '~/shared/item-count'; import { type Radius, radiusStyle } from '../../shared/radius'; import styles from '../menu/menu.module.css'; import { useMenuContext } from '../menu/menu-root'; @@ -167,7 +166,9 @@ export const ContextMenuContent = ({ placeholder={searchPlaceholder} className={styles.comboboxInput} ref={inputRef} - onPointerEnter={focusInput} + onPointerEnter={e => { + focusInput(); + }} onKeyDown={e => { if (e.key === 'ArrowLeft') return; if (e.key === 'Escape') return checkAndCloseSubMenu(e); @@ -182,11 +183,11 @@ export const ContextMenuContent = ({ className={styles.comboboxContent} ref={containerRef} > - {children} + {children} ) : ( - {children} + children )} diff --git a/packages/react/components/context-menu/context-menu-misc.tsx b/packages/react/components/context-menu/context-menu-misc.tsx index abdd4bbbe..bc1ce7826 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -4,12 +4,6 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ComponentProps, Fragment } from 'react'; import styles from '../menu/menu.module.css'; -import { - EmptyStateBase, - MenuEmptyStateProps, - MenuStatusProps, - StatusBase -} from '../menu/menu-misc'; import { useMenuContext } from '../menu/menu-root'; export type ContextMenuGroupProps = ContextMenuPrimitive.Group.Props; @@ -78,14 +72,17 @@ export const ContextMenuSeparator = ({ }; ContextMenuSeparator.displayName = 'ContextMenu.Separator'; -export type ContextMenuEmptyStateProps = MenuEmptyStateProps; -export const ContextMenuEmptyState = (props: ContextMenuEmptyStateProps) => ( - +export const ContextMenuEmptyState = ({ + className, + children, + ...props +}: ComponentProps<'div'>) => ( +
+ {children} +
); ContextMenuEmptyState.displayName = 'ContextMenu.EmptyState'; - -export type ContextMenuStatusProps = MenuStatusProps; -export const ContextMenuStatus = (props: ContextMenuStatusProps) => ( - -); -ContextMenuStatus.displayName = 'ContextMenu.Status'; diff --git a/packages/react/components/context-menu/context-menu-trigger.tsx b/packages/react/components/context-menu/context-menu-trigger.tsx index 031fca621..9822a36f5 100644 --- a/packages/react/components/context-menu/context-menu-trigger.tsx +++ b/packages/react/components/context-menu/context-menu-trigger.tsx @@ -5,7 +5,6 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { ChevronRightIcon } from '~/icons'; -import { useRegisterItem } from '~/shared/item-count'; import { Cell, CellBaseProps } from '../menu/cell'; import { useMenuContext } from '../menu/menu-root'; import { getMatch } from '../menu/utils'; @@ -40,11 +39,9 @@ export const ContextMenuSubTrigger = ({ }: ContextMenuSubTriggerProps) => { const { parent, inputRef } = useMenuContext(); - const hidden = - parent?.shouldFilter && !getMatch(value, children, parent?.inputValue); - useRegisterItem(!hidden); - - if (hidden) return null; + if (parent?.shouldFilter && !getMatch(value, children, parent?.inputValue)) { + return null; + } const cell = ; return ( diff --git a/packages/react/components/context-menu/context-menu.tsx b/packages/react/components/context-menu/context-menu.tsx index 539fad1f8..b2dc28dae 100644 --- a/packages/react/components/context-menu/context-menu.tsx +++ b/packages/react/components/context-menu/context-menu.tsx @@ -13,8 +13,7 @@ import { ContextMenuEmptyState, ContextMenuGroup, ContextMenuLabel, - ContextMenuSeparator, - ContextMenuStatus + ContextMenuSeparator } from './context-menu-misc'; import { ContextMenuRoot, ContextMenuSubMenu } from './context-menu-root'; import { @@ -34,7 +33,6 @@ export const ContextMenu = Object.assign(ContextMenuRoot, { Label: ContextMenuLabel, Separator: ContextMenuSeparator, EmptyState: ContextMenuEmptyState, - Status: ContextMenuStatus, Submenu: ContextMenuSubMenu, SubmenuTrigger: ContextMenuSubTrigger, SubmenuContent: ContextMenuSubContent diff --git a/packages/react/components/menu/__tests__/data-slots.test.tsx b/packages/react/components/menu/__tests__/data-slots.test.tsx index e85a18657..182756216 100644 --- a/packages/react/components/menu/__tests__/data-slots.test.tsx +++ b/packages/react/components/menu/__tests__/data-slots.test.tsx @@ -115,7 +115,6 @@ describe('Menu data-slot contract', () => { {TRIGGER_TEXT} Item - ); @@ -123,8 +122,7 @@ describe('Menu data-slot contract', () => { expectSlots(document.body, [ 'menu-search-input', 'menu-search-list', - 'menu-item', - 'menu-status' + 'menu-item' ]); }); }); diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index c6e8f193c..4587bbca0 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../button/button'; import { Menu } from '../menu'; @@ -239,33 +240,6 @@ describe('Menu', () => { }); }); - describe('Empty State', () => { - it('shows only when the menu has no items', async () => { - const { unmount } = render( - - {TRIGGER_TEXT} - - Profile - No actions - - - ); - fireEvent.click(screen.getByText(TRIGGER_TEXT)); - expect(screen.queryByText(/No actions/)).not.toBeInTheDocument(); - unmount(); - - await renderAndOpenDropdown( - - {TRIGGER_TEXT} - - No actions - - - ); - expect(screen.getByRole('status')).toHaveTextContent('No actions'); - }); - }); - describe('Autocomplete', () => { const SearchMenu = () => ( @@ -280,8 +254,6 @@ describe('Menu', () => { Board view Docs - No results - ); @@ -303,35 +275,6 @@ describe('Menu', () => { expect(screen.getAllByRole('option')).toHaveLength(2); }); - it('shows the empty state only when nothing matches', async () => { - await renderAndOpenDropdown(); - const empty = document.querySelector('[data-slot="menu-empty-state"]'); - expect(empty).toHaveAttribute('role', 'status'); - expect(empty).toBeEmptyDOMElement(); - - search('zzz'); - expect(empty).toHaveTextContent('No results'); - - search('team'); - expect(empty).toBeEmptyDOMElement(); - }); - - it('announces the number of matches', async () => { - await renderAndOpenDropdown(); - const status = document.querySelector('[data-slot="menu-status"]'); - expect(status).toHaveAttribute('role', 'status'); - expect(status).toBeEmptyDOMElement(); - - search('view'); - expect(status).toHaveTextContent('2 results'); - - search('docs'); - expect(status).toHaveTextContent('1 result'); - - search('zzz'); - expect(status).toHaveTextContent('0 results'); - }); - it('toggles checkbox and radio items and keeps the search', async () => { await renderAndOpenDropdown(); diff --git a/packages/react/components/menu/menu-content.tsx b/packages/react/components/menu/menu-content.tsx index 4cd32a828..bebefc19f 100644 --- a/packages/react/components/menu/menu-content.tsx +++ b/packages/react/components/menu/menu-content.tsx @@ -6,7 +6,6 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { KeyboardEvent, useCallback, useRef } from 'react'; -import { ItemCountProvider } from '~/shared/item-count'; import { type Radius, radiusStyle } from '../../shared/radius'; import { useThemeInjection } from '../theme/portal'; import styles from './menu.module.css'; @@ -168,7 +167,9 @@ export function MenuContent({ placeholder={searchPlaceholder} className={styles.comboboxInput} ref={inputRef} - onPointerEnter={focusInput} + onPointerEnter={e => { + focusInput(); + }} onKeyDown={e => { if (e.key === 'ArrowLeft') return; if (e.key === 'Escape') return checkAndCloseSubMenu(e); @@ -183,11 +184,11 @@ export function MenuContent({ className={styles.comboboxContent} ref={containerRef} > - {children} + {children} ) : ( - {children} + children )} diff --git a/packages/react/components/menu/menu-item.tsx b/packages/react/components/menu/menu-item.tsx index c987de383..bb49776b9 100644 --- a/packages/react/components/menu/menu-item.tsx +++ b/packages/react/components/menu/menu-item.tsx @@ -5,14 +5,12 @@ import { Menu as MenuPrimitive } from '@base-ui/react'; import { ReactNode } from 'react'; -import { useRegisterItem } from '~/shared/item-count'; 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), - * and counts it for `Menu.EmptyState` and `Menu.Status` when it shows. + * 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) { @@ -24,7 +22,6 @@ export function useMenuItemFilter(value: unknown, children: ReactNode) { children, inputValue ); - useRegisterItem(!hidden); return { autocomplete, hidden }; } diff --git a/packages/react/components/menu/menu-misc.tsx b/packages/react/components/menu/menu-misc.tsx index 209a8fe9d..78c3c8ba3 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -1,12 +1,8 @@ 'use client'; -import { - Autocomplete as AutocompletePrimitive, - Menu as MenuPrimitive -} from '@base-ui/react'; +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { cx } from 'class-variance-authority'; import { ComponentProps, Fragment, ReactNode } from 'react'; -import { useItemCount } from '~/shared/item-count'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; @@ -71,90 +67,19 @@ export function MenuSeparator({ className, ...props }: ComponentProps<'div'>) { } MenuSeparator.displayName = 'Menu.Separator'; -export interface MenuEmptyStateProps extends ComponentProps<'div'> { - children: ReactNode; -} - -/** @remarks Only for internal usage. */ -export function EmptyStateBase({ - slotPrefix, +export function MenuEmptyState({ className, children, ...props -}: MenuEmptyStateProps & { slotPrefix: string }) { - const { autocomplete } = useMenuContext(); - const count = useItemCount(); - const content = count ? null : children; - const sharedProps = { - 'data-slot': `${slotPrefix}-empty-state`, - className: cx(styles.empty, className) - }; - - if (autocomplete) { - return ( - - {content} - - ); - } - +}: ComponentProps<'div'> & { children: ReactNode }) { return (
- {content} + {children}
); } - -export function MenuEmptyState(props: MenuEmptyStateProps) { - return ; -} MenuEmptyState.displayName = 'Menu.EmptyState'; - -export interface MenuStatusProps - extends Omit, 'children'> { - /** Text to announce. Receives the number of matching items. */ - children?: ReactNode | ((count: number) => ReactNode); -} - -const defaultStatus = (count: number) => - `${count} ${count === 1 ? 'result' : 'results'}`; - -/** @remarks Only for internal usage. */ -export function StatusBase({ - slotPrefix, - className, - children = defaultStatus, - ...props -}: MenuStatusProps & { slotPrefix: string }) { - const { autocomplete, inputValue } = useMenuContext(); - const count = useItemCount() ?? 0; - - if (!autocomplete) return null; - - const content = inputValue - ? typeof children === 'function' - ? children(count) - : children - : null; - - return ( - - {content} - - ); -} - -export function MenuStatus(props: MenuStatusProps) { - return ; -} -MenuStatus.displayName = 'Menu.Status'; diff --git a/packages/react/components/menu/menu-trigger.tsx b/packages/react/components/menu/menu-trigger.tsx index e5ee7f545..85a2ce36c 100644 --- a/packages/react/components/menu/menu-trigger.tsx +++ b/packages/react/components/menu/menu-trigger.tsx @@ -4,7 +4,6 @@ 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'; -import { useRegisterItem } from '~/shared/item-count'; import { Button } from '../button'; import { useMenubarContext } from '../menubar/menubar'; import { Cell, CellBaseProps } from './cell'; @@ -64,11 +63,9 @@ export function MenuSubTrigger({ }: MenuSubTriggerProps) { const { parent, inputRef } = useMenuContext(); - const hidden = - parent?.shouldFilter && !getMatch(value, children, parent?.inputValue); - useRegisterItem(!hidden); - - if (hidden) return null; + if (parent?.shouldFilter && !getMatch(value, children, parent?.inputValue)) { + return null; + } const cell = ; return ( diff --git a/packages/react/components/menu/menu.module.css b/packages/react/components/menu/menu.module.css index 71d9d34df..e500bdb15 100644 --- a/packages/react/components/menu/menu.module.css +++ b/packages/react/components/menu/menu.module.css @@ -99,22 +99,6 @@ text-wrap: pretty; } -.empty:empty { - padding: 0; -} - -.status { - position: absolute; - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border: 0; -} - .menuBarTrigger[data-pressed] { background-color: var(--rs-color-background-base-primary-hover); } diff --git a/packages/react/components/menu/menu.tsx b/packages/react/components/menu/menu.tsx index afcbdde3a..26d24ffe4 100644 --- a/packages/react/components/menu/menu.tsx +++ b/packages/react/components/menu/menu.tsx @@ -7,8 +7,7 @@ import { MenuEmptyState, MenuGroup, MenuLabel, - MenuSeparator, - MenuStatus + MenuSeparator } from './menu-misc'; import { MenuRadioGroup, MenuRadioItem } from './menu-radio'; import { MenuRoot, MenuSubMenu } from './menu-root'; @@ -26,7 +25,6 @@ export const Menu = Object.assign(MenuRoot, { Label: MenuLabel, Separator: MenuSeparator, EmptyState: MenuEmptyState, - Status: MenuStatus, Submenu: MenuSubMenu, SubmenuTrigger: MenuSubTrigger, SubmenuContent: MenuSubContent, diff --git a/packages/react/shared/item-count/index.ts b/packages/react/shared/item-count/index.ts deleted file mode 100644 index a6b9c1531..000000000 --- a/packages/react/shared/item-count/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './item-count'; diff --git a/packages/react/shared/item-count/item-count.tsx b/packages/react/shared/item-count/item-count.tsx deleted file mode 100644 index a1b9d8774..000000000 --- a/packages/react/shared/item-count/item-count.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client'; - -import { - createContext, - type ReactNode, - useCallback, - useContext, - useState -} from 'react'; -import { useIsomorphicLayoutEffect } from '~/hooks/useIsomorphicLayoutEffect'; - -/* - * Counts the items a list renders. Lists that filter their own children - * (instead of using Base UI's `items` prop) use the count to know when nothing - * matches and how many results to announce. - */ - -type RegisterItem = () => () => void; - -const RegisterItemContext = createContext(null); -const ItemCountContext = createContext(null); - -export function ItemCountProvider({ children }: { children: ReactNode }) { - const [count, setCount] = useState(0); - - const register = useCallback(() => { - setCount(value => value + 1); - return () => setCount(value => value - 1); - }, []); - - return ( - - {children} - - ); -} - -/** Adds the calling item to the nearest count while `enabled` is true. */ -export function useRegisterItem(enabled = true) { - const register = useContext(RegisterItemContext); - - useIsomorphicLayoutEffect(() => { - if (!enabled || !register) return; - return register(); - }, [enabled, register]); -} - -/** The number of registered items, or `null` outside a provider. */ -export function useItemCount() { - return useContext(ItemCountContext); -} From c80991a168ee08d852ee219a3e18f379f7481aeb Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 00:36:10 +0530 Subject: [PATCH 3/7] fix: [menu, context-menu] keep checkbox and radio state while filtering A filtered-out checkbox item unmounted the Base UI item, and a group switched to a fragment while filtering, which remounted its items. Both reset uncontrolled checked and radio values. The checkbox item now holds its checked state, and groups stay rendered while filtering. The empty-list styles ignore groups that have no matches. --- .../docs/components/context-menu/index.mdx | 2 +- .../content/docs/components/menu/index.mdx | 2 +- .../context-menu/context-menu-misc.tsx | 9 ++---- .../components/menu/__tests__/menu.test.tsx | 30 +++++++++++++++++++ .../components/menu/menu-checkbox-item.tsx | 28 +++++++++++++++-- packages/react/components/menu/menu-misc.tsx | 9 ++---- .../react/components/menu/menu.module.css | 5 ++-- 7 files changed, 65 insertions(+), 20 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 56becec99..0b451fa2e 100644 --- a/apps/www/src/content/docs/components/context-menu/index.mdx +++ b/apps/www/src/content/docs/components/context-menu/index.mdx @@ -137,7 +137,7 @@ 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 wrapper is removed and items are rendered flat. +A way to group related menu items together. When filtering is active, the group label is hidden. diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index b60bffdec..373f4442a 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -171,7 +171,7 @@ 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 wrapper is removed and items are rendered flat. +A way to group related menu items together. When filtering is active, the group label is hidden. diff --git a/packages/react/components/context-menu/context-menu-misc.tsx b/packages/react/components/context-menu/context-menu-misc.tsx index bc1ce7826..a9add10e7 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -2,7 +2,7 @@ import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ComponentProps, Fragment } from 'react'; +import { ComponentProps } from 'react'; import styles from '../menu/menu.module.css'; import { useMenuContext } from '../menu/menu-root'; @@ -12,12 +12,7 @@ export const ContextMenuGroup = ({ children, ...props }: ContextMenuGroupProps) => { - const { shouldFilter } = useMenuContext(); - - if (shouldFilter) { - return {children}; - } - + // Stays rendered while filtering. Removing it would remount the items and reset their state. return ( { ); }); + it('keeps uncontrolled checkbox and radio state while filtering', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Show grid + + Show rulers + + List view + Board view + + + + + ); + const option = (name: string) => screen.getByRole('option', { name }); + fireEvent.click(option('Show grid')); + fireEvent.click(option('Show rulers')); + fireEvent.click(option('Board view')); + + search('view'); + expect(option('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'); + }); + it('renders link items as anchors', async () => { await renderAndOpenDropdown(); const link = screen.getByRole('option', { name: 'Docs' }); diff --git a/packages/react/components/menu/menu-checkbox-item.tsx b/packages/react/components/menu/menu-checkbox-item.tsx index 2f0ade1fa..3271f6350 100644 --- a/packages/react/components/menu/menu-checkbox-item.tsx +++ b/packages/react/components/menu/menu-checkbox-item.tsx @@ -4,6 +4,7 @@ import { Autocomplete as AutocompletePrimitive, 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'; @@ -24,12 +25,35 @@ export function CheckboxItemBase({ leadingIcon, trailingIcon, render, + checked: checkedProp, + defaultChecked = false, + 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 [checked, setCheckedUnwrapped] = useControlled({ + controlled: checkedProp, + default: defaultChecked, + name: 'MenuCheckboxItem', + state: 'checked' + }); if (hidden) return null; + const itemProps = { + ...props, + checked, + onCheckedChange: ( + next: boolean, + eventDetails: MenuPrimitive.CheckboxItem.ChangeEventDetails + ) => { + onCheckedChange?.(next, eventDetails); + if (eventDetails.isCanceled) return; + setCheckedUnwrapped(next); + } + }; + const cell = render ?? ( } + render={} > {children} @@ -63,7 +87,7 @@ export function CheckboxItemBase({ {children} diff --git a/packages/react/components/menu/menu-misc.tsx b/packages/react/components/menu/menu-misc.tsx index 78c3c8ba3..59e6ae1a1 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -2,7 +2,7 @@ import { Menu as MenuPrimitive } from '@base-ui/react/menu'; import { cx } from 'class-variance-authority'; -import { ComponentProps, Fragment, ReactNode } from 'react'; +import { ComponentProps, ReactNode } from 'react'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; @@ -11,12 +11,7 @@ export function MenuGroup({ children, ...props }: MenuPrimitive.Group.Props) { - const { shouldFilter } = useMenuContext(); - - if (shouldFilter) { - return {children}; - } - + // Stays rendered while filtering. Removing it would remount the items and reset their state. return ( :not(:empty))) { padding: 0; } @@ -69,7 +70,7 @@ border-bottom: 0.5px solid var(--rs-color-border-base-primary); } -.comboboxContainer:has(.comboboxContent:empty) .comboboxInput { +.comboboxContainer:not(:has(.comboboxContent > :not(:empty))) .comboboxInput { border-bottom: none; } From abd691f070aff762c78589df1faa7c14ba845720 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 00:36:51 +0530 Subject: [PATCH 4/7] feat: [menu, context-menu] announce the empty state in search menus In a searchable menu, Menu.EmptyState and ContextMenu.EmptyState render Base UI Autocomplete.Empty, a polite live region. The app still decides what it shows. An empty state with no children has no padding, so it can stay mounted. --- .../context-menu/context-menu-misc.tsx | 28 ++++++++++++------- packages/react/components/menu/menu-misc.tsx | 8 ++++-- .../react/components/menu/menu.module.css | 4 +++ 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/react/components/context-menu/context-menu-misc.tsx b/packages/react/components/context-menu/context-menu-misc.tsx index a9add10e7..d9b2f456e 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -1,6 +1,9 @@ 'use client'; -import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; +import { + Autocomplete as AutocompletePrimitive, + ContextMenu as ContextMenuPrimitive +} from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ComponentProps } from 'react'; import styles from '../menu/menu.module.css'; @@ -71,13 +74,18 @@ export const ContextMenuEmptyState = ({ className, children, ...props -}: ComponentProps<'div'>) => ( -
- {children} -
-); +}: ComponentProps<'div'>) => { + const { autocomplete } = useMenuContext(); + const Component = autocomplete ? AutocompletePrimitive.Empty : 'div'; + + return ( + + {children} + + ); +}; ContextMenuEmptyState.displayName = 'ContextMenu.EmptyState'; diff --git a/packages/react/components/menu/menu-misc.tsx b/packages/react/components/menu/menu-misc.tsx index 59e6ae1a1..12bf30384 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -1,5 +1,6 @@ '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'; @@ -67,14 +68,17 @@ export function MenuEmptyState({ children, ...props }: ComponentProps<'div'> & { children: ReactNode }) { + const { autocomplete } = useMenuContext(); + const Component = autocomplete ? AutocompletePrimitive.Empty : 'div'; + return ( -
{children} -
+ ); } MenuEmptyState.displayName = 'Menu.EmptyState'; diff --git a/packages/react/components/menu/menu.module.css b/packages/react/components/menu/menu.module.css index 76eea7d96..1ba6d5b23 100644 --- a/packages/react/components/menu/menu.module.css +++ b/packages/react/components/menu/menu.module.css @@ -100,6 +100,10 @@ text-wrap: pretty; } +.empty:empty { + padding: 0; +} + .menuBarTrigger[data-pressed] { background-color: var(--rs-color-background-base-primary-hover); } From 4a91408f116e0e355dbff1fdb14d4b5257f9cf02 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 00:36:58 +0530 Subject: [PATCH 5/7] feat: [menu, context-menu] export the new item props types Export the props types of CheckboxItem, RadioGroup, RadioItem, and LinkItem for Menu and ContextMenu. --- packages/react/components/context-menu/index.ts | 6 ++++++ packages/react/components/menu/index.ts | 3 +++ packages/react/index.tsx | 16 ++++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/react/components/context-menu/index.ts b/packages/react/components/context-menu/index.ts index f628d09c8..e226880a9 100644 --- a/packages/react/components/context-menu/index.ts +++ b/packages/react/components/context-menu/index.ts @@ -1 +1,7 @@ export { ContextMenu } from './context-menu'; +export type { + ContextMenuCheckboxItemProps, + ContextMenuLinkItemProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps +} from './context-menu-item'; diff --git a/packages/react/components/menu/index.ts b/packages/react/components/menu/index.ts index 01435c7b0..ca6398217 100644 --- a/packages/react/components/menu/index.ts +++ b/packages/react/components/menu/index.ts @@ -1 +1,4 @@ export { Menu } from './menu'; +export type { MenuCheckboxItemProps } from './menu-checkbox-item'; +export type { MenuLinkItemProps } from './menu-link-item'; +export type { MenuRadioGroupProps, MenuRadioItemProps } from './menu-radio'; diff --git a/packages/react/index.tsx b/packages/react/index.tsx index c8018c342..7a4aaf3cc 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -93,7 +93,13 @@ export * from './components/color-picker'; export { Combobox } from './components/combobox'; export { Command } from './components/command'; export { Container } from './components/container'; -export { ContextMenu } from './components/context-menu'; +export { + ContextMenu, + type ContextMenuCheckboxItemProps, + type ContextMenuLinkItemProps, + type ContextMenuRadioGroupProps, + type ContextMenuRadioItemProps +} from './components/context-menu'; export { CopyButton } from './components/copy-button'; export { DataTable, @@ -183,7 +189,13 @@ export { Kbd } from './components/kbd'; export { Label } from './components/label'; export { Link } from './components/link'; export { List } from './components/list'; -export { Menu } from './components/menu'; +export { + Menu, + type MenuCheckboxItemProps, + type MenuLinkItemProps, + type MenuRadioGroupProps, + type MenuRadioItemProps +} from './components/menu'; export { Menubar } from './components/menubar'; export { Message } from './components/message'; export { Meter } from './components/meter'; From 6667e4acd8f25e3c97775bdcf39d4291d4a95066 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 27 Oct 2026 00:37:18 +0530 Subject: [PATCH 6/7] chore: [menu, context-menu] fix lint warnings Remove an unused handler argument and an unused test import. --- packages/react/components/context-menu/context-menu-content.tsx | 2 +- packages/react/components/menu/__tests__/menu.test.tsx | 1 - packages/react/components/menu/menu-content.tsx | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react/components/context-menu/context-menu-content.tsx b/packages/react/components/context-menu/context-menu-content.tsx index c1d05a89e..d1fc9fc1b 100644 --- a/packages/react/components/context-menu/context-menu-content.tsx +++ b/packages/react/components/context-menu/context-menu-content.tsx @@ -166,7 +166,7 @@ export const ContextMenuContent = ({ placeholder={searchPlaceholder} className={styles.comboboxInput} ref={inputRef} - onPointerEnter={e => { + onPointerEnter={() => { focusInput(); }} onKeyDown={e => { diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index 8e3c20115..c554abf28 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -1,5 +1,4 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../button/button'; import { Menu } from '../menu'; diff --git a/packages/react/components/menu/menu-content.tsx b/packages/react/components/menu/menu-content.tsx index bebefc19f..7729c69bf 100644 --- a/packages/react/components/menu/menu-content.tsx +++ b/packages/react/components/menu/menu-content.tsx @@ -167,7 +167,7 @@ export function MenuContent({ placeholder={searchPlaceholder} className={styles.comboboxInput} ref={inputRef} - onPointerEnter={e => { + onPointerEnter={() => { focusInput(); }} onKeyDown={e => { From 5bc3a9bc5111fcf486a6f8e80a5f88e36ec19bce Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Oct 2026 00:58:56 +0530 Subject: [PATCH 7/7] fix: [menu, context-menu] remove live region attributes from the searchable empty state The search list is a listbox, which only allows options and groups inside. Autocomplete.Empty sets role status, aria-live and aria-atomic, and axe reports the element as a child the listbox does not allow. --- .../context-menu/context-menu-misc.tsx | 4 ++++ .../components/menu/__tests__/menu.test.tsx | 16 ++++++++++++++++ packages/react/components/menu/menu-misc.tsx | 4 ++++ 3 files changed, 24 insertions(+) diff --git a/packages/react/components/context-menu/context-menu-misc.tsx b/packages/react/components/context-menu/context-menu-misc.tsx index d9b2f456e..926b47440 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -81,6 +81,10 @@ export const ContextMenuEmptyState = ({ return ( diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index 021b8b7eb..a22260336 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -408,5 +408,21 @@ describe('Menu', () => { expect(link.tagName).toBe('A'); expect(link).toHaveAttribute('href', '/docs'); }); + + it('renders the empty state without live region attributes', async () => { + await renderAndOpenDropdown( + + {TRIGGER_TEXT} + + Profile + No results + + + ); + 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'); + }); }); }); diff --git a/packages/react/components/menu/menu-misc.tsx b/packages/react/components/menu/menu-misc.tsx index e0f94b424..b040a9a90 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -78,6 +78,10 @@ export function MenuEmptyState({ return (