diff --git a/docs/data/api/menu-radio-group.json b/docs/data/api/menu-radio-group.json
new file mode 100644
index 000000000..7c93dbc87
--- /dev/null
+++ b/docs/data/api/menu-radio-group.json
@@ -0,0 +1,23 @@
+{
+ "props": {
+ "children": { "type": { "name": "node" } },
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "defaultValue": { "type": { "name": "any" } },
+ "onValueChange": { "type": { "name": "func" }, "default": "() => {}" },
+ "render": { "type": { "name": "union", "description": "element
| func" } },
+ "value": { "type": { "name": "any" } }
+ },
+ "name": "MenuRadioGroup",
+ "imports": [
+ "import * as Menu from '@base_ui/react/Menu';\nconst MenuRadioGroup = Menu.RadioGroup;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "MenuRadioGroup",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx",
+ "inheritance": null,
+ "demos": "
",
+ "cssComponent": false
+}
diff --git a/docs/data/api/menu-radio-item-indicator.json b/docs/data/api/menu-radio-item-indicator.json
new file mode 100644
index 000000000..9cd21ccb5
--- /dev/null
+++ b/docs/data/api/menu-radio-item-indicator.json
@@ -0,0 +1,20 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "keepMounted": { "type": { "name": "bool" }, "default": "true" },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "MenuRadioItemIndicator",
+ "imports": [
+ "import * as Menu from '@base_ui/react/Menu';\nconst MenuRadioItemIndicator = Menu.RadioItemIndicator;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "MenuRadioItemIndicator",
+ "forwardsRefTo": "HTMLSpanElement",
+ "filename": "/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/api/menu-radio-item.json b/docs/data/api/menu-radio-item.json
new file mode 100644
index 000000000..fc3ac0f34
--- /dev/null
+++ b/docs/data/api/menu-radio-item.json
@@ -0,0 +1,23 @@
+{
+ "props": {
+ "value": { "type": { "name": "any" }, "required": true },
+ "closeOnClick": { "type": { "name": "bool" }, "default": "true" },
+ "disabled": { "type": { "name": "bool" }, "default": "false" },
+ "id": { "type": { "name": "string" } },
+ "label": { "type": { "name": "string" } },
+ "onClick": { "type": { "name": "func" } }
+ },
+ "name": "MenuRadioItem",
+ "imports": [
+ "import * as Menu from '@base_ui/react/Menu';\nconst MenuRadioItem = Menu.RadioItem;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "MenuRadioItem",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/components/menu/RadioItems.js b/docs/data/components/menu/RadioItems.js
new file mode 100644
index 000000000..314d6f34d
--- /dev/null
+++ b/docs/data/components/menu/RadioItems.js
@@ -0,0 +1,193 @@
+'use client';
+import * as React from 'react';
+import * as Menu from '@base_ui/react/Menu';
+import { styled } from '@mui/system';
+
+export default function RadioItems() {
+ return (
+
+ Font
+
+
+
+
+
+ Cascadia Code
+
+
+
+ Consolas
+
+
+
+ DejaVu Sans Mono
+
+
+
+ Fira Code
+
+
+
+ JetBrains Mono
+
+
+
+ Menlo
+
+
+
+ Monaco
+
+
+
+ Monolisa
+
+
+
+ Source Code Pro
+
+
+
+
+
+ );
+}
+
+const blue = {
+ 50: '#F0F7FF',
+ 100: '#C2E0FF',
+ 200: '#99CCF3',
+ 300: '#66B2FF',
+ 400: '#3399FF',
+ 500: '#007FFF',
+ 600: '#0072E6',
+ 700: '#0059B3',
+ 800: '#004C99',
+ 900: '#003A75',
+};
+
+const grey = {
+ 50: '#F3F6F9',
+ 100: '#E5EAF2',
+ 200: '#DAE2ED',
+ 300: '#C7D0DD',
+ 400: '#B0B8C4',
+ 500: '#9DA8B7',
+ 600: '#6B7A90',
+ 700: '#434D5B',
+ 800: '#303740',
+ 900: '#1C2025',
+};
+
+const MenuPopup = styled(Menu.Popup)(
+ ({ theme }) => `
+ font-family: 'IBM Plex Sans', sans-serif;
+ font-size: 0.875rem;
+ box-sizing: border-box;
+ padding: 6px;
+ margin: 12px 0;
+ min-width: 200px;
+ border-radius: 12px;
+ overflow: auto;
+ outline: 0;
+ background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
+ border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
+ color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
+ box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
+ z-index: 1;
+ transform-origin: var(--transform-origin);
+ opacity: 0;
+ transform: scale(0.8);
+ transition: opacity 100ms ease-in, transform 100ms ease-in;
+
+ &[data-menu='open'] {
+ opacity: 1;
+ transform: scale(1);
+ }
+ `,
+);
+
+const RadioItem = styled(Menu.RadioItem)(
+ ({ theme }) => `
+ list-style: none;
+ padding: 8px;
+ border-radius: 8px;
+ cursor: default;
+ user-select: none;
+
+ &:last-of-type {
+ border-bottom: none;
+ }
+
+ &:focus {
+ outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
+ background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
+ color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
+ }
+
+ &[data-disabled] {
+ color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
+ }
+ `,
+);
+
+const Indicator = styled(Menu.RadioItemIndicator)(
+ ({ theme }) => `
+ display: inline-block;
+ width: 0.75rem;
+ height: 0.75rem;
+ border: 1px solid;
+ vertical-align: baseline;
+ margin-right: 8px;
+ border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
+ box-sizing: border-box;
+ border-radius: 50%;
+
+ &[data-radioitem=checked] {
+ background: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
+ box-shadow: 0 0 0 2px ${theme.palette.mode === 'dark' ? grey[900] : '#fff'} inset;
+ }
+ `,
+);
+
+const MenuButton = styled(Menu.Trigger)(
+ ({ theme }) => `
+ font-family: 'IBM Plex Sans', sans-serif;
+ font-weight: 600;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ padding: 8px 16px;
+ border-radius: 8px;
+ color: white;
+ transition: all 150ms ease;
+ cursor: pointer;
+ background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
+ border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
+ color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+
+ &:hover {
+ background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
+ border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
+ }
+
+ &:active {
+ background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]};
+ outline: none;
+ }
+ `,
+);
+
+const MenuPositioner = styled(Menu.Positioner)`
+ &:focus-visible {
+ outline: 0;
+ }
+
+ &[data-menu='closed'] {
+ pointer-events: none;
+ }
+`;
diff --git a/docs/data/components/menu/RadioItems.tsx b/docs/data/components/menu/RadioItems.tsx
new file mode 100644
index 000000000..314d6f34d
--- /dev/null
+++ b/docs/data/components/menu/RadioItems.tsx
@@ -0,0 +1,193 @@
+'use client';
+import * as React from 'react';
+import * as Menu from '@base_ui/react/Menu';
+import { styled } from '@mui/system';
+
+export default function RadioItems() {
+ return (
+
+ Font
+
+
+
+
+
+ Cascadia Code
+
+
+
+ Consolas
+
+
+
+ DejaVu Sans Mono
+
+
+
+ Fira Code
+
+
+
+ JetBrains Mono
+
+
+
+ Menlo
+
+
+
+ Monaco
+
+
+
+ Monolisa
+
+
+
+ Source Code Pro
+
+
+
+
+
+ );
+}
+
+const blue = {
+ 50: '#F0F7FF',
+ 100: '#C2E0FF',
+ 200: '#99CCF3',
+ 300: '#66B2FF',
+ 400: '#3399FF',
+ 500: '#007FFF',
+ 600: '#0072E6',
+ 700: '#0059B3',
+ 800: '#004C99',
+ 900: '#003A75',
+};
+
+const grey = {
+ 50: '#F3F6F9',
+ 100: '#E5EAF2',
+ 200: '#DAE2ED',
+ 300: '#C7D0DD',
+ 400: '#B0B8C4',
+ 500: '#9DA8B7',
+ 600: '#6B7A90',
+ 700: '#434D5B',
+ 800: '#303740',
+ 900: '#1C2025',
+};
+
+const MenuPopup = styled(Menu.Popup)(
+ ({ theme }) => `
+ font-family: 'IBM Plex Sans', sans-serif;
+ font-size: 0.875rem;
+ box-sizing: border-box;
+ padding: 6px;
+ margin: 12px 0;
+ min-width: 200px;
+ border-radius: 12px;
+ overflow: auto;
+ outline: 0;
+ background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
+ border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
+ color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
+ box-shadow: 0px 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]};
+ z-index: 1;
+ transform-origin: var(--transform-origin);
+ opacity: 0;
+ transform: scale(0.8);
+ transition: opacity 100ms ease-in, transform 100ms ease-in;
+
+ &[data-menu='open'] {
+ opacity: 1;
+ transform: scale(1);
+ }
+ `,
+);
+
+const RadioItem = styled(Menu.RadioItem)(
+ ({ theme }) => `
+ list-style: none;
+ padding: 8px;
+ border-radius: 8px;
+ cursor: default;
+ user-select: none;
+
+ &:last-of-type {
+ border-bottom: none;
+ }
+
+ &:focus {
+ outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[200]};
+ background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
+ color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]};
+ }
+
+ &[data-disabled] {
+ color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]};
+ }
+ `,
+);
+
+const Indicator = styled(Menu.RadioItemIndicator)(
+ ({ theme }) => `
+ display: inline-block;
+ width: 0.75rem;
+ height: 0.75rem;
+ border: 1px solid;
+ vertical-align: baseline;
+ margin-right: 8px;
+ border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
+ box-sizing: border-box;
+ border-radius: 50%;
+
+ &[data-radioitem=checked] {
+ background: ${theme.palette.mode === 'dark' ? grey[800] : grey[700]};
+ box-shadow: 0 0 0 2px ${theme.palette.mode === 'dark' ? grey[900] : '#fff'} inset;
+ }
+ `,
+);
+
+const MenuButton = styled(Menu.Trigger)(
+ ({ theme }) => `
+ font-family: 'IBM Plex Sans', sans-serif;
+ font-weight: 600;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ padding: 8px 16px;
+ border-radius: 8px;
+ color: white;
+ transition: all 150ms ease;
+ cursor: pointer;
+ background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'};
+ border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
+ color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]};
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+
+ &:hover {
+ background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]};
+ border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]};
+ }
+
+ &:active {
+ background: ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 4px ${theme.palette.mode === 'dark' ? blue[300] : blue[200]};
+ outline: none;
+ }
+ `,
+);
+
+const MenuPositioner = styled(Menu.Positioner)`
+ &:focus-visible {
+ outline: 0;
+ }
+
+ &[data-menu='closed'] {
+ pointer-events: none;
+ }
+`;
diff --git a/docs/data/components/menu/menu.mdx b/docs/data/components/menu/menu.mdx
index 4ee2dbb69..b3417f5df 100644
--- a/docs/data/components/menu/menu.mdx
+++ b/docs/data/components/menu/menu.mdx
@@ -2,7 +2,7 @@
productId: base-ui
title: React Menu component
description: The Menu component provide end users with a list of options on temporary surfaces.
-components: MenuItem, MenuPositioner, MenuPopup, MenuRoot, MenuTrigger, SubmenuTrigger, MenuArrow
+components: MenuItem, MenuPositioner, MenuPopup, MenuRoot, MenuTrigger, SubmenuTrigger, MenuArrow, MenuRadioGroup, MenuRadioItem, MenuRadioItemIndicator
githubLabel: 'component: menu'
waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
---
@@ -30,6 +30,9 @@ Menus are implemented using a collection of related components:
- `` is the menu item.
- `` renders an optional pointing arrow, placed inside the popup.
- `` is a menu item that opens a submenu. See [Nested menu](#nested-menu) for more details.
+- `` is a menu item that acts as a radio button. See [Radio items](#radio-items) for more details.
+- `` groups RadioItems together. Only one RadioItem in a group can be selected at a time.
+- `` is a visual indicator for the selected RadioItem.
```tsx
@@ -141,6 +144,39 @@ To change how long the menu waits until it opens or closes when `openOnHover` is
```
+## Radio items
+
+Menu items can be used as radio buttons. To group them together, use the `Menu.RadioGroup` component:
+
+
+
+
+ If you rely on the RadioItem to manage its state (e.g., you use the `defaultChecked` and `onCheckedChange` props), ensure that the item is not unmounted when its parent menu is closed.
+ Unmounting the component resets its state.
+
+To do this, add the `keepMounted` prop to the `Menu.Positioner` the checkbox item is in (and all parent positioners, in the case of a nested menu):
+
+```jsx
+
+
+
+
+
+ Light
+
+
+ Dark
+
+
+
+
+
+```
+
+If you keep the state externally (and use the `checked` prop), this isn't required.
+
+
+
## Nested menu
Menu items can open submenus.
diff --git a/docs/data/translations/api-docs/menu-radio-group/menu-radio-group.json b/docs/data/translations/api-docs/menu-radio-group/menu-radio-group.json
new file mode 100644
index 000000000..150d54171
--- /dev/null
+++ b/docs/data/translations/api-docs/menu-radio-group/menu-radio-group.json
@@ -0,0 +1,16 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "children": { "description": "The content of the component." },
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "defaultValue": {
+ "description": "The default value of the selected radio button. This is the uncontrolled equivalent of value
."
+ },
+ "onValueChange": { "description": "Function called when the selected value changes." },
+ "render": { "description": "A function to customize rendering of the component." },
+ "value": { "description": "The value of the selected radio button." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json b/docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json
new file mode 100644
index 000000000..35acde828
--- /dev/null
+++ b/docs/data/translations/api-docs/menu-radio-item-indicator/menu-radio-item-indicator.json
@@ -0,0 +1,13 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "keepMounted": {
+ "description": "If true
, the component is mounted even if the Radio is not checked."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json b/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json
new file mode 100644
index 000000000..49a3980c9
--- /dev/null
+++ b/docs/data/translations/api-docs/menu-radio-item/menu-radio-item.json
@@ -0,0 +1,18 @@
+{
+ "componentDescription": "An unstyled menu item to be used within a Menu.",
+ "propDescriptions": {
+ "closeOnClick": {
+ "description": "If true
, the menu will close when the menu item is clicked."
+ },
+ "disabled": { "description": "If true
, the menu item will be disabled." },
+ "id": { "description": "The id of the menu item." },
+ "label": {
+ "description": "A text representation of the menu item's content. Used for keyboard text navigation matching."
+ },
+ "onClick": { "description": "The click handler for the menu item." },
+ "value": {
+ "description": "Value of the radio item. This is the value that will be set in the MenuRadioGroup when the item is selected."
+ }
+ },
+ "classDescriptions": {}
+}
diff --git a/packages/mui-base/src/Menu/Item/useMenuItem.ts b/packages/mui-base/src/Menu/Item/useMenuItem.ts
index 42f77ebdb..afc2e29f2 100644
--- a/packages/mui-base/src/Menu/Item/useMenuItem.ts
+++ b/packages/mui-base/src/Menu/Item/useMenuItem.ts
@@ -37,7 +37,14 @@ export function useMenuItem(params: useMenuItem.Parameters): useMenuItem.ReturnV
event.defaultMuiPrevented = true;
}
},
- onClick: (event: React.MouseEvent) => {
+ onClick: (event: React.MouseEvent | React.KeyboardEvent) => {
+ if (event.type === 'keydown') {
+ if ((event as React.KeyboardEvent).key === 'Enter') {
+ menuEvents.emit('close', event);
+ return;
+ }
+ }
+
if (closeOnClick) {
menuEvents.emit('close', event);
}
diff --git a/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx b/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx
index 4d6a1a5f7..5dd0f6c96 100644
--- a/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx
+++ b/packages/mui-base/src/Menu/Positioner/MenuPositioner.tsx
@@ -141,7 +141,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner(
modal={false}
initialFocus={nested ? -1 : 0}
returnFocus
- key={mounted.toString()}
+ disabled={!mounted}
>
{renderElement()}
diff --git a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts
index a8d133770..02c1890eb 100644
--- a/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts
+++ b/packages/mui-base/src/Menu/Positioner/useMenuPositioner.ts
@@ -43,6 +43,7 @@ export function useMenuPositioner(
zIndex: 2147483647, // max z-index
},
'aria-hidden': !open || undefined,
+ inert: !open ? '' : undefined,
});
},
[positionerStyles, open, keepMounted, hidden],
diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx
new file mode 100644
index 000000000..0116f9c17
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.test.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import * as Menu from '@base_ui/react/Menu';
+import { createRenderer, describeConformance } from '#test-utils';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ render,
+ refInstanceof: window.HTMLDivElement,
+ }));
+
+ it('renders a div with the `group` role', async () => {
+ const { getByRole } = await render();
+ expect(getByRole('group')).toBeVisible();
+ });
+});
diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx
new file mode 100644
index 000000000..53d8dedd0
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroup.tsx
@@ -0,0 +1,139 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { MenuRadioGroupContext } from './MenuRadioGroupContext';
+import { BaseUIComponentProps } from '../../utils/types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useControlled } from '../../utils/useControlled';
+import { useEventCallback } from '../../utils/useEventCallback';
+
+const EMPTY_OBJECT = {};
+const NOOP = () => {};
+
+const MenuRadioGroup = React.forwardRef(function MenuRadioGroup(
+ props: MenuRadioGroup.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ render,
+ className,
+ value: valueProp,
+ defaultValue,
+ onValueChange: onValueChangeProp = NOOP,
+ ...other
+ } = props;
+
+ const [value, setValueUnwrapped] = useControlled({
+ controlled: valueProp,
+ default: defaultValue,
+ name: 'MenuRadioGroup',
+ });
+
+ const onValueChange = useEventCallback(onValueChangeProp);
+
+ const setValue = React.useCallback(
+ (newValue: any, event: Event) => {
+ setValueUnwrapped(newValue);
+ onValueChange?.(newValue, event);
+ },
+ [onValueChange, setValueUnwrapped],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ render: render || 'div',
+ className,
+ ownerState: EMPTY_OBJECT,
+ extraProps: {
+ role: 'group',
+ ...other,
+ },
+ ref: forwardedRef,
+ });
+
+ const context = React.useMemo(
+ () => ({
+ value,
+ setValue,
+ }),
+ [value, setValue],
+ );
+
+ return (
+
+ {renderElement()}
+
+ );
+});
+
+namespace MenuRadioGroup {
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {
+ /**
+ * The content of the component.
+ */
+ children?: React.ReactNode;
+ /**
+ * The value of the selected radio button.
+ */
+ value?: any;
+ /**
+ * The default value of the selected radio button.
+ * This is the uncontrolled equivalent of `value`.
+ */
+ defaultValue?: any;
+ /**
+ * Function called when the selected value changes.
+ *
+ * @default () => {}
+ */
+ onValueChange?: (newValue: any, event: Event) => void;
+ }
+
+ export type OwnerState = {};
+}
+
+MenuRadioGroup.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * The content of the component.
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * The default value of the selected radio button.
+ * This is the uncontrolled equivalent of `value`.
+ */
+ defaultValue: PropTypes.any,
+ /**
+ * Function called when the selected value changes.
+ *
+ * @default () => {}
+ */
+ onValueChange: PropTypes.func,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * The value of the selected radio button.
+ */
+ value: PropTypes.any,
+} as any;
+
+const MemoizedMenuRadioGroup = React.memo(MenuRadioGroup);
+
+/**
+ *
+ * Demos:
+ *
+ * - [Menu](https://base-ui.netlify.app/components/react-menu/)
+ *
+ * API:
+ *
+ * - [MenuRadioGroup API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuRadioGroup)
+ */
+export { MemoizedMenuRadioGroup as MenuRadioGroup };
diff --git a/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts
new file mode 100644
index 000000000..10ac8dc81
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioGroup/MenuRadioGroupContext.ts
@@ -0,0 +1,21 @@
+import * as React from 'react';
+
+export interface MenuRadioGroupContext {
+ value: any;
+ setValue: (newValue: any, event: Event) => void;
+}
+
+export const MenuRadioGroupContext = React.createContext(null);
+
+if (process.env.NODE_ENV !== 'production') {
+ MenuRadioGroupContext.displayName = 'MenuRadioGroupContext';
+}
+
+export function useMenuRadioGroupContext() {
+ const context = React.useContext(MenuRadioGroupContext);
+ if (context === null) {
+ throw new Error('useMenuRadioGroupContext must be used within a MenuRadioGroup');
+ }
+
+ return context;
+}
diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx
new file mode 100644
index 000000000..fc08c1c87
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.test.tsx
@@ -0,0 +1,313 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { spy } from 'sinon';
+import userEvent from '@testing-library/user-event';
+import { fireEvent, act, waitFor } from '@mui/internal-test-utils';
+import { FloatingRootContext, FloatingTree } from '@floating-ui/react';
+import * as Menu from '@base_ui/react/Menu';
+import { MenuRootContext } from '@base_ui/react/Menu';
+import { describeConformance, createRenderer } from '#test-utils';
+import { MenuRadioGroupContext } from '../RadioGroup/MenuRadioGroupContext';
+
+const testRootContext: MenuRootContext = {
+ floatingRootContext: {} as FloatingRootContext,
+ getPositionerProps: (p) => ({ ...p }),
+ getTriggerProps: (p) => ({ ...p }),
+ getItemProps: (p) => ({ ...p }),
+ parentContext: null,
+ nested: false,
+ triggerElement: null,
+ setTriggerElement: () => {},
+ setPositionerElement: () => {},
+ activeIndex: null,
+ disabled: false,
+ itemDomElements: { current: [] },
+ itemLabels: { current: [] },
+ open: true,
+ setOpen: () => {},
+ clickAndDragEnabled: false,
+ setClickAndDragEnabled: () => {},
+ popupRef: { current: null },
+ mounted: true,
+ transitionStatus: undefined,
+ typingRef: { current: false },
+};
+
+const testRadioGroupContext = {
+ value: '0',
+ setValue: () => {},
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+ const user = userEvent.setup();
+
+ describeConformance(, () => ({
+ render: (node) => {
+ return render(
+
+
+
+ {node}
+
+
+ ,
+ );
+ },
+ refInstanceof: window.HTMLDivElement,
+ }));
+
+ it('perf: does not rerender menu items unnecessarily', async function test() {
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ const renderItem1Spy = spy();
+ const renderItem2Spy = spy();
+ const renderItem3Spy = spy();
+ const renderItem4Spy = spy();
+
+ const LoggingRoot = React.forwardRef(function LoggingRoot(
+ props: any & { renderSpy: () => void },
+ ref: React.ForwardedRef,
+ ) {
+ const { renderSpy, ownerState, ...other } = props;
+ renderSpy();
+ return ;
+ });
+
+ const { getAllByRole } = await render(
+
+
+
+
+ }
+ id="item-1"
+ >
+ 1
+
+ }
+ id="item-2"
+ >
+ 2
+
+ }
+ id="item-3"
+ >
+ 3
+
+ }
+ id="item-4"
+ >
+ 4
+
+
+
+
+ ,
+ );
+
+ const menuItems = getAllByRole('menuitemradio');
+ await act(() => {
+ menuItems[0].focus();
+ });
+
+ renderItem1Spy.resetHistory();
+ renderItem2Spy.resetHistory();
+ renderItem3Spy.resetHistory();
+ renderItem4Spy.resetHistory();
+
+ expect(renderItem1Spy.callCount).to.equal(0);
+
+ fireEvent.keyDown(menuItems[0], { key: 'ArrowDown' }); // highlights '2'
+
+ // React renders twice in strict mode, so we expect twice the number of spy calls
+ // Also, useButton's focusVisible polyfill causes an extra render when focus is gained/lost.
+
+ await waitFor(
+ () => {
+ expect(renderItem1Spy.callCount).to.equal(4); // '1' rerenders as it loses highlight
+ },
+ { timeout: 1000 },
+ );
+
+ await waitFor(
+ () => {
+ expect(renderItem2Spy.callCount).to.equal(4); // '2' rerenders as it receives highlight
+ },
+ { timeout: 1000 },
+ );
+
+ // neither the highlighted nor the selected state of these options changed,
+ // so they don't need to rerender:
+ expect(renderItem3Spy.callCount).to.equal(0);
+ expect(renderItem4Spy.callCount).to.equal(0);
+ });
+
+ describe('state management', () => {
+ it('adds the state and ARIA attributes when selected', async () => {
+ const { getByRole } = await render(
+
+ Open
+
+
+
+ Item
+
+
+
+ ,
+ );
+
+ const trigger = getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ const item = getByRole('menuitemradio');
+ await user.click(item);
+
+ expect(item).to.have.attribute('aria-checked', 'true');
+ expect(item).to.have.attribute('data-radioitem', 'checked');
+ });
+
+ ['Space', 'Enter'].forEach((key) => {
+ it(`selects the item when ${key} is pressed`, async () => {
+ const { getByRole } = await render(
+
+ Open
+
+
+ Item
+
+
+ ,
+ );
+
+ const trigger = getByRole('button', { name: 'Open' });
+ await act(() => {
+ trigger.focus();
+ });
+ await user.keyboard('[ArrowDown]');
+ const item = getByRole('menuitemradio');
+
+ await waitFor(() => {
+ expect(item).toHaveFocus();
+ });
+
+ await user.keyboard(`[${key}]`);
+ expect(item).to.have.attribute('data-radioitem', 'checked');
+ });
+ });
+
+ it('calls `onValueChange` when the item is clicked', async () => {
+ const onValueChange = spy();
+ const { getByRole } = await render(
+
+ Open
+
+
+
+ Item
+
+
+
+ ,
+ );
+
+ const trigger = getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ const item = getByRole('menuitemradio');
+ await user.click(item);
+
+ expect(onValueChange.callCount).to.equal(1);
+ expect(onValueChange.lastCall.args[0]).to.equal(1);
+ });
+
+ it('keeps the state when closed and reopened', async () => {
+ const { getByRole } = await render(
+
+ Open
+
+
+
+ Item
+
+
+
+ ,
+ );
+
+ const trigger = getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ const item = getByRole('menuitemradio');
+ await user.click(item);
+
+ await user.click(trigger);
+
+ await user.click(trigger);
+
+ const itemAfterReopen = getByRole('menuitemradio');
+ expect(itemAfterReopen).to.have.attribute('aria-checked', 'true');
+ expect(itemAfterReopen).to.have.attribute('data-radioitem', 'checked');
+ });
+ });
+
+ describe('prop: closeOnClick', () => {
+ it('when `closeOnClick=true`, closes the menu when the item is clicked', async () => {
+ const { getByRole, queryByRole } = await render(
+
+ Open
+
+
+
+
+ Item
+
+
+
+
+ ,
+ );
+
+ const trigger = getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ const item = getByRole('menuitemradio');
+ await user.click(item);
+
+ expect(queryByRole('menu')).to.equal(null);
+ });
+
+ it('does not close the menu when the item is clicked by default', async () => {
+ const { getByRole, queryByRole } = await render(
+
+ Open
+
+
+
+ Item
+
+
+
+ ,
+ );
+
+ const trigger = getByRole('button', { name: 'Open' });
+ await user.click(trigger);
+
+ const item = getByRole('menuitemradio');
+ await user.click(item);
+
+ expect(queryByRole('menu')).not.to.equal(null);
+ });
+ });
+});
diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx
new file mode 100644
index 000000000..d9fc70fa2
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItem.tsx
@@ -0,0 +1,224 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingEvents, useFloatingTree, useListItem } from '@floating-ui/react';
+import { useMenuRadioItem } from './useMenuRadioItem';
+import { useMenuRootContext } from '../Root/MenuRootContext';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import { useId } from '../../utils/useId';
+import type { BaseUIComponentProps, GenericHTMLProps } from '../../utils/types';
+import { useForkRef } from '../../utils/useForkRef';
+import { useMenuRadioGroupContext } from '../RadioGroup/MenuRadioGroupContext';
+import { MenuRadioItemContext } from './MenuRadioItemContext';
+
+const customStyleHookMapping: CustomStyleHookMapping = {
+ checked: (value: boolean) => ({ 'data-radioitem': value ? 'checked' : 'unchecked' }),
+};
+
+const InnerMenuRadioItem = React.memo(
+ React.forwardRef(function InnerMenuItem(
+ props: InnerMenuRadioItemProps,
+ forwardedRef: React.ForwardedRef,
+ ) {
+ const {
+ checked,
+ setChecked,
+ className,
+ closeOnClick = false,
+ disabled = false,
+ highlighted,
+ id,
+ menuEvents,
+ propGetter,
+ render,
+ treatMouseupAsClick,
+ typingRef,
+ ...other
+ } = props;
+
+ const { getRootProps } = useMenuRadioItem({
+ checked,
+ setChecked,
+ closeOnClick,
+ disabled,
+ highlighted,
+ id,
+ menuEvents,
+ ref: forwardedRef,
+ treatMouseupAsClick,
+ typingRef,
+ });
+
+ const ownerState: MenuRadioItem.OwnerState = { disabled, highlighted, checked };
+
+ const { renderElement } = useComponentRenderer({
+ render: render || 'div',
+ className,
+ ownerState,
+ propGetter: (externalProps) => propGetter(getRootProps(externalProps)),
+ customStyleHookMapping,
+ extraProps: other,
+ });
+
+ return renderElement();
+ }),
+);
+
+/**
+ * An unstyled menu item to be used within a Menu.
+ *
+ * Demos:
+ *
+ * - [Menu](https://base-ui.netlify.app/components/react-menu/)
+ *
+ * API:
+ *
+ * - [MenuRadioItem API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuRadioItem)
+ */
+const MenuRadioItem = React.forwardRef(function MenuRadioItem(
+ props: MenuRadioItem.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { id: idProp, value, label, disabled = false, ...other } = props;
+
+ const itemRef = React.useRef(null);
+ const listItem = useListItem({ label: label ?? itemRef.current?.innerText });
+ const mergedRef = useForkRef(forwardedRef, listItem.ref, itemRef);
+
+ const { getItemProps, activeIndex, clickAndDragEnabled, typingRef } = useMenuRootContext();
+ const id = useId(idProp);
+
+ const highlighted = listItem.index === activeIndex;
+ const { events: menuEvents } = useFloatingTree()!;
+
+ const { value: selectedValue, setValue: setSelectedValue } = useMenuRadioGroupContext();
+
+ // This wrapper component is used as a performance optimization.
+ // MenuRadioItem reads the context and re-renders the actual MenuRadioItem
+ // only when it needs to.
+
+ const checked = selectedValue === value;
+
+ const setChecked = React.useCallback(
+ (event: Event) => {
+ setSelectedValue(value, event);
+ },
+ [setSelectedValue, value],
+ );
+
+ const contextValue = React.useMemo(
+ () => ({ checked, highlighted, disabled }),
+ [checked, highlighted, disabled],
+ );
+
+ return (
+
+
+
+ );
+});
+
+interface InnerMenuRadioItemProps extends Omit {
+ highlighted: boolean;
+ propGetter: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
+ menuEvents: FloatingEvents;
+ treatMouseupAsClick: boolean;
+ checked: boolean;
+ setChecked: (event: Event) => void;
+ typingRef: React.RefObject;
+}
+
+namespace MenuRadioItem {
+ export type OwnerState = {
+ disabled: boolean;
+ highlighted: boolean;
+ checked: boolean;
+ };
+
+ export interface Props extends BaseUIComponentProps<'div', OwnerState> {
+ /**
+ * Value of the radio item.
+ * This is the value that will be set in the MenuRadioGroup when the item is selected.
+ */
+ value: any;
+ children?: React.ReactNode;
+ /**
+ * The click handler for the menu item.
+ */
+ onClick?: React.MouseEventHandler;
+ /**
+ * If `true`, the menu item will be disabled.
+ * @default false
+ */
+ disabled?: boolean;
+ /**
+ * A text representation of the menu item's content.
+ * Used for keyboard text navigation matching.
+ */
+ label?: string;
+ /**
+ * The id of the menu item.
+ */
+ id?: string;
+ /**
+ * If `true`, the menu will close when the menu item is clicked.
+ *
+ * @default true
+ */
+ closeOnClick?: boolean;
+ }
+}
+
+MenuRadioItem.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * If `true`, the menu will close when the menu item is clicked.
+ *
+ * @default true
+ */
+ closeOnClick: PropTypes.bool,
+ /**
+ * If `true`, the menu item will be disabled.
+ * @default false
+ */
+ disabled: PropTypes.bool,
+ /**
+ * The id of the menu item.
+ */
+ id: PropTypes.string,
+ /**
+ * A text representation of the menu item's content.
+ * Used for keyboard text navigation matching.
+ */
+ label: PropTypes.string,
+ /**
+ * The click handler for the menu item.
+ */
+ onClick: PropTypes.func,
+ /**
+ * Value of the radio item.
+ * This is the value that will be set in the MenuRadioGroup when the item is selected.
+ */
+ value: PropTypes.any.isRequired,
+} as any;
+
+export { MenuRadioItem };
diff --git a/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts
new file mode 100644
index 000000000..760b3618d
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioItem/MenuRadioItemContext.ts
@@ -0,0 +1,20 @@
+import * as React from 'react';
+
+export interface MenuRadioItemContext {
+ checked: boolean;
+ highlighted: boolean;
+ disabled: boolean;
+}
+
+export const MenuRadioItemContext = React.createContext(
+ undefined,
+);
+
+export function useMenuRadioItemContext() {
+ const context = React.useContext(MenuRadioItemContext);
+ if (context === undefined) {
+ throw new Error('useMenuRadioItemContext must be used within a MenuRadioItemProvider');
+ }
+
+ return context;
+}
diff --git a/packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts b/packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts
new file mode 100644
index 000000000..c897c7440
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioItem/useMenuRadioItem.ts
@@ -0,0 +1,50 @@
+import * as React from 'react';
+import { useMenuItem } from '../Item/useMenuItem';
+import { GenericHTMLProps } from '../../utils/types';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+
+/**
+ *
+ * API:
+ *
+ * - [useMenuRadioItem API](https://mui.com/base-ui/api/use-menu-radio-item/)
+ */
+export function useMenuRadioItem(
+ params: useMenuRadioItem.Parameters,
+): useMenuRadioItem.ReturnValue {
+ const { checked, setChecked, ...other } = params;
+
+ const { getRootProps: getMenuItemRootProps, ...menuItem } = useMenuItem(other);
+
+ const getRootProps = React.useCallback(
+ (externalProps?: GenericHTMLProps): GenericHTMLProps => {
+ return getMenuItemRootProps(
+ mergeReactProps(externalProps, {
+ role: 'menuitemradio',
+ 'aria-checked': checked,
+ onClick: (event: React.MouseEvent) => {
+ setChecked(event.nativeEvent);
+ },
+ }),
+ );
+ },
+ [checked, getMenuItemRootProps, setChecked],
+ );
+
+ return {
+ ...menuItem,
+ getRootProps,
+ checked,
+ };
+}
+
+export namespace useMenuRadioItem {
+ export interface Parameters extends useMenuItem.Parameters {
+ checked: boolean;
+ setChecked: (event: Event) => void;
+ }
+
+ export interface ReturnValue extends useMenuItem.ReturnValue {
+ checked: boolean;
+ }
+}
diff --git a/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx
new file mode 100644
index 000000000..023aa4437
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.test.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+import * as Menu from '@base_ui/react/Menu';
+import { createRenderer, describeConformance } from '#test-utils';
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ refInstanceof: window.HTMLSpanElement,
+ render(node) {
+ return render(
+
+
+
+
+ {node}
+
+
+
+ ,
+ );
+ },
+ }));
+});
diff --git a/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx
new file mode 100644
index 000000000..2afe03d28
--- /dev/null
+++ b/packages/mui-base/src/Menu/RadioItemIndicator/MenuRadioItemIndicator.tsx
@@ -0,0 +1,89 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { MenuRadioItem } from '../RadioItem/MenuRadioItem';
+import { useMenuRadioItemContext } from '../RadioItem/MenuRadioItemContext';
+import { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { BaseUIComponentProps } from '../../utils/types';
+
+const customStyleHookMapping: CustomStyleHookMapping = {
+ checked: (value: boolean) => ({ 'data-radioitem': value ? 'checked' : 'unchecked' }),
+};
+
+/**
+ *
+ * Demos:
+ *
+ * - [Menu](https://base-ui.netlify.app/components/react-menu/)
+ *
+ * API:
+ *
+ * - [MenuRadioItemIndicator API](https://base-ui.netlify.app/components/react-menu/#api-reference-MenuRadioItemIndicator)
+ */
+const MenuRadioItemIndicator = React.forwardRef(function MenuRadioItemIndicatorComponent(
+ props: MenuRadioItemIndicator.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, keepMounted = true, ...other } = props;
+
+ const ownerState = useMenuRadioItemContext();
+
+ const { renderElement } = useComponentRenderer({
+ render: render || 'span',
+ className,
+ ownerState,
+ customStyleHookMapping,
+ extraProps: other,
+ ref: forwardedRef,
+ });
+
+ if (!keepMounted && !ownerState.checked) {
+ return null;
+ }
+
+ return renderElement();
+});
+
+MenuRadioItemIndicator.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * If `true`, the component is mounted even if the Radio is not checked.
+ *
+ * @default true
+ */
+ keepMounted: PropTypes.bool,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+namespace MenuRadioItemIndicator {
+ export interface Props extends BaseUIComponentProps<'span', OwnerState> {
+ /**
+ * If `true`, the component is mounted even if the Radio is not checked.
+ *
+ * @default true
+ */
+ keepMounted?: boolean;
+ }
+
+ export interface OwnerState {
+ checked: boolean;
+ disabled: boolean;
+ highlighted: boolean;
+ }
+}
+
+export { MenuRadioItemIndicator };
diff --git a/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx b/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx
index ef290cc0b..b97d0dbdd 100644
--- a/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx
+++ b/packages/mui-base/src/Menu/Root/MenuRoot.test.tsx
@@ -561,7 +561,12 @@ describe('', () => {
const menuItem = getByRole('menuitem');
await user.click(menuItem);
- expect(button).toHaveFocus();
+ await waitFor(
+ () => {
+ expect(button).toHaveFocus();
+ },
+ { timeout: 1000 },
+ );
});
});
diff --git a/packages/mui-base/src/Menu/index.ts b/packages/mui-base/src/Menu/index.ts
index 33250fa38..11e63ca14 100644
--- a/packages/mui-base/src/Menu/index.ts
+++ b/packages/mui-base/src/Menu/index.ts
@@ -14,6 +14,13 @@ export {
useMenuPositionerContext,
} from './Positioner/MenuPositionerContext';
+export { MenuRadioGroup as RadioGroup } from './RadioGroup/MenuRadioGroup';
+
+export { MenuRadioItem as RadioItem } from './RadioItem/MenuRadioItem';
+export { useMenuRadioItem } from './RadioItem/useMenuRadioItem';
+
+export { MenuRadioItemIndicator as RadioItemIndicator } from './RadioItemIndicator/MenuRadioItemIndicator';
+
export { MenuRoot as Root } from './Root/MenuRoot';
export { useMenuRoot } from './Root/useMenuRoot';
export { MenuRootContext, useMenuRootContext } from './Root/MenuRootContext';