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..9e6a976e8 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,33 @@ 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 + + + ); +}` +}; 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..0b451fa2e 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,7 @@ import { submenuDemo, autocompleteDemo, controlledDemo, + selectionDemo, } from "./demo.ts"; @@ -59,6 +60,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. @@ -104,9 +111,33 @@ 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. +A way to group related menu items together. When filtering is active, the group label is hidden. @@ -165,6 +196,12 @@ 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` | | `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) | @@ -184,3 +221,6 @@ 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`. 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..36d04c9ce 100644 --- a/apps/www/src/content/docs/components/context-menu/props.ts +++ b/apps/www/src/content/docs/components/context-menu/props.ts @@ -127,6 +127,113 @@ 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; diff --git a/apps/www/src/content/docs/components/menu/demo.ts b/apps/www/src/content/docs/components/menu/demo.ts index 18b7860a9..4602da5b5 100644 --- a/apps/www/src/content/docs/components/menu/demo.ts +++ b/apps/www/src/content/docs/components/menu/demo.ts @@ -520,3 +520,65 @@ 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 + + + ` +}; diff --git a/apps/www/src/content/docs/components/menu/index.mdx b/apps/www/src/content/docs/components/menu/index.mdx index cde35ba55..373f4442a 100644 --- a/apps/www/src/content/docs/components/menu/index.mdx +++ b/apps/www/src/content/docs/components/menu/index.mdx @@ -13,6 +13,9 @@ import { autocompleteDemo, linearDemo, controlledDemo, + checkboxDemo, + radioDemo, + linkDemo, } from "./demo.ts"; @@ -60,6 +63,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. @@ -114,13 +141,37 @@ 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. +A way to group related menu items together. When filtering is active, the group label is hidden. @@ -181,6 +232,12 @@ 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-subtrigger` | `Menu.SubmenuTrigger` | @@ -199,3 +256,8 @@ 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. diff --git a/apps/www/src/content/docs/components/menu/props.ts b/apps/www/src/content/docs/components/menu/props.ts index 632c75f8a..9049c3299 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; 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 3e22c9556..8189d164a 100644 --- a/packages/react/components/context-menu/__tests__/context-menu.test.tsx +++ b/packages/react/components/context-menu/__tests__/context-menu.test.tsx @@ -168,4 +168,68 @@ describe('ContextMenu', () => { }); }); }); + + 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 checkbox items with the other items', async () => { + await renderAndOpenContextMenu( + + {TRIGGER_TEXT} + + {MENU_ITEMS.map(item => ( + {item.label} + ))} + Show grid + + + ); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, { target: { value: 'grid' } }); + expect(screen.getAllByRole('option')).toHaveLength(1); + + fireEvent.change(input, { target: { value: 'zzz' } }); + expect(screen.queryAllByRole('option')).toHaveLength(0); + }); + }); }); 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..59d713f4c 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' 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..d9b2f456e 100644 --- a/packages/react/components/context-menu/context-menu-misc.tsx +++ b/packages/react/components/context-menu/context-menu-misc.tsx @@ -1,8 +1,11 @@ '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, Fragment } from 'react'; +import { ComponentProps } from 'react'; import styles from '../menu/menu.module.css'; import { useMenuContext } from '../menu/menu-root'; @@ -12,12 +15,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 ( ) => ( -
- {children} -
-); +}: ComponentProps<'div'>) => { + const { autocomplete } = useMenuContext(); + const Component = autocomplete ? AutocompletePrimitive.Empty : 'div'; + + return ( + + {children} + + ); +}; ContextMenuEmptyState.displayName = 'ContextMenu.EmptyState'; diff --git a/packages/react/components/context-menu/context-menu.tsx b/packages/react/components/context-menu/context-menu.tsx index 15ce97fce..b2dc28dae 100644 --- a/packages/react/components/context-menu/context-menu.tsx +++ b/packages/react/components/context-menu/context-menu.tsx @@ -2,7 +2,13 @@ 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, @@ -19,6 +25,10 @@ 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, diff --git a/packages/react/components/context-menu/index.ts b/packages/react/components/context-menu/index.ts index b2276ee7e..571f06f84 100644 --- a/packages/react/components/context-menu/index.ts +++ b/packages/react/components/context-menu/index.ts @@ -1,6 +1,12 @@ export { ContextMenu } from './context-menu'; export type { ContextMenuContentProps } from './context-menu-content'; -export type { ContextMenuItemProps } from './context-menu-item'; +export type { + ContextMenuCheckboxItemProps, + ContextMenuItemProps, + ContextMenuLinkItemProps, + ContextMenuRadioGroupProps, + ContextMenuRadioItemProps +} from './context-menu-item'; export type { ContextMenuGroupProps, ContextMenuLabelProps diff --git a/packages/react/components/menu/__tests__/data-slots.test.tsx b/packages/react/components/menu/__tests__/data-slots.test.tsx index 51aedf5a2..182756216 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' ]); }); diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index 3455b9c39..021b8b7eb 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -211,4 +211,202 @@ describe('Menu', () => { expect(screen.getByText('Banana')).toBeInTheDocument(); }); }); + + 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('Autocomplete', () => { + const SearchMenu = () => ( + + {TRIGGER_TEXT} + + {MENU_ITEMS.map(item => ( + {item.label} + ))} + Show grid + + List view + Board view + + Docs + + + ); + + 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('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('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' }); + 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/index.ts b/packages/react/components/menu/index.ts index 53f7d3473..f681a2b01 100644 --- a/packages/react/components/menu/index.ts +++ b/packages/react/components/menu/index.ts @@ -1,6 +1,9 @@ export { Menu } from './menu'; +export type { MenuCheckboxItemProps } from './menu-checkbox-item'; export type { MenuContentProps } from './menu-content'; export type { MenuItemProps } from './menu-item'; +export type { MenuLinkItemProps } from './menu-link-item'; export type { MenuEmptyStateProps } from './menu-misc'; +export type { MenuRadioGroupProps, MenuRadioItemProps } from './menu-radio'; export type { MenuRootProps as MenuProps, MenuSubMenuProps } from './menu-root'; export type { MenuSubTriggerProps, MenuTriggerProps } from './menu-trigger'; 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..3271f6350 --- /dev/null +++ b/packages/react/components/menu/menu-checkbox-item.tsx @@ -0,0 +1,101 @@ +'use client'; + +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'; +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, + 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 ?? ( + + + + } + 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-item.tsx b/packages/react/components/menu/menu-item.tsx index d523f476a..bb49776b9 100644 --- a/packages/react/components/menu/menu-item.tsx +++ b/packages/react/components/menu/menu-item.tsx @@ -4,10 +4,41 @@ import { Autocomplete as AutocompletePrimitive, Menu as MenuPrimitive } from '@base-ui/react'; +import { ReactNode } from 'react'; import { Cell, CellBaseProps } from './cell'; import { useMenuContext } from './menu-root'; import { getMatch } from './utils'; +/** + * Hides the item when it does not match the search value (in auto mode). + * @remarks Only for internal usage. + */ +export function useMenuItemFilter(value: unknown, children: ReactNode) { + const { autocomplete, inputValue, shouldFilter } = useMenuContext(); + const hidden = + shouldFilter && + !getMatch( + typeof value === 'string' ? value : undefined, + children, + inputValue + ); + return { autocomplete, hidden }; +} + +/** + * Keeps focus handling out of Base UI for items in a plain menu. + * @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 +51,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 792b99486..e0f94b424 100644 --- a/packages/react/components/menu/menu-misc.tsx +++ b/packages/react/components/menu/menu-misc.tsx @@ -1,8 +1,9 @@ '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, Fragment, ReactNode } from 'react'; +import { ComponentProps, ReactNode } from 'react'; import styles from './menu.module.css'; import { useMenuContext } from './menu-root'; @@ -11,12 +12,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 ( {children} - + ); } MenuEmptyState.displayName = 'Menu.EmptyState'; 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.module.css b/packages/react/components/menu/menu.module.css index e500bdb15..1ba6d5b23 100644 --- a/packages/react/components/menu/menu.module.css +++ b/packages/react/components/menu/menu.module.css @@ -53,7 +53,8 @@ padding: var(--rs-space-2); } -.comboboxContent:empty { +/* Groups stay rendered while filtering, so a group with no matches is an empty child. */ +.comboboxContent:not(:has(> :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; } @@ -99,6 +100,10 @@ text-wrap: pretty; } +.empty:empty { + padding: 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..26d24ffe4 100644 --- a/packages/react/components/menu/menu.tsx +++ b/packages/react/components/menu/menu.tsx @@ -1,12 +1,15 @@ 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 } from './menu-misc'; +import { MenuRadioGroup, MenuRadioItem } from './menu-radio'; import { MenuRoot, MenuSubMenu } from './menu-root'; import { MenuSubTrigger, MenuTrigger } from './menu-trigger'; @@ -14,6 +17,10 @@ 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, diff --git a/packages/react/index.tsx b/packages/react/index.tsx index f8102d8ec..11db7c623 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -151,11 +151,15 @@ export { export { Container, type ContainerProps } from './components/container'; export { ContextMenu, + type ContextMenuCheckboxItemProps, type ContextMenuContentProps, type ContextMenuGroupProps, type ContextMenuItemProps, type ContextMenuLabelProps, + type ContextMenuLinkItemProps, type ContextMenuProps, + type ContextMenuRadioGroupProps, + type ContextMenuRadioItemProps, type ContextMenuSubMenuProps, type ContextMenuSubTriggerProps, type ContextMenuTriggerProps @@ -288,10 +292,14 @@ export { } from './components/list'; export { Menu, + type MenuCheckboxItemProps, type MenuContentProps, type MenuEmptyStateProps, type MenuItemProps, + type MenuLinkItemProps, type MenuProps, + type MenuRadioGroupProps, + type MenuRadioItemProps, type MenuSubMenuProps, type MenuSubTriggerProps, type MenuTriggerProps