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 (
+
+ );
+}`
+};
+
+export const radioDemo = {
+ type: 'code',
+ code: `
+function RadioMenu() {
+ const [layout, setLayout] = React.useState("list");
+
+ return (
+
+ );
+}`
+};
+
+export const linkDemo = {
+ type: 'code',
+ code: `
+ `
+};
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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ const link = screen.getByRole('menuitem', { name: 'Settings' });
+ expect(link).toHaveAttribute('data-router');
+ expect(link).toHaveAttribute('href', '/settings');
+ });
+ });
+
+ describe('Autocomplete', () => {
+ const SearchMenu = () => (
+
+ );
+
+ 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(
+
+ );
+ 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
|