diff --git a/docs/data/material/components/menus/CheckboxMenu.js b/docs/data/material/components/menus/CheckboxMenu.js new file mode 100644 index 00000000000000..82d65b01c9a002 --- /dev/null +++ b/docs/data/material/components/menus/CheckboxMenu.js @@ -0,0 +1,39 @@ +import * as React from 'react'; +import Paper from '@mui/material/Paper'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Check from '@mui/icons-material/Check'; + +const options = ['Show toolbar', 'Show sidebar', 'Show status bar']; + +export default function CheckboxMenu() { + const [checked, setChecked] = React.useState({ + 'Show toolbar': true, + }); + + const handleToggle = (option) => () => { + setChecked((prev) => ({ ...prev, [option]: !prev[option] })); + }; + + return ( + + + {options.map((option) => ( + + + {checked[option] ? : null} + + {option} + + ))} + + + ); +} diff --git a/docs/data/material/components/menus/CheckboxMenu.tsx b/docs/data/material/components/menus/CheckboxMenu.tsx new file mode 100644 index 00000000000000..b7518fb5e79c39 --- /dev/null +++ b/docs/data/material/components/menus/CheckboxMenu.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import Paper from '@mui/material/Paper'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Check from '@mui/icons-material/Check'; + +const options = ['Show toolbar', 'Show sidebar', 'Show status bar']; + +export default function CheckboxMenu() { + const [checked, setChecked] = React.useState>({ + 'Show toolbar': true, + }); + + const handleToggle = (option: string) => () => { + setChecked((prev) => ({ ...prev, [option]: !prev[option] })); + }; + + return ( + + + {options.map((option) => ( + + + {checked[option] ? : null} + + {option} + + ))} + + + ); +} diff --git a/docs/data/material/components/menus/RadioMenu.js b/docs/data/material/components/menus/RadioMenu.js new file mode 100644 index 00000000000000..1455be200f3735 --- /dev/null +++ b/docs/data/material/components/menus/RadioMenu.js @@ -0,0 +1,38 @@ +import * as React from 'react'; +import Paper from '@mui/material/Paper'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import RadioButtonChecked from '@mui/icons-material/RadioButtonChecked'; +import RadioButtonUnchecked from '@mui/icons-material/RadioButtonUnchecked'; + +const options = ['Name', 'Date modified', 'Size']; + +export default function RadioMenu() { + const [selected, setSelected] = React.useState('Name'); + + return ( + + + {options.map((option) => ( + setSelected(option)} + > + + {selected === option ? ( + + ) : ( + + )} + + {option} + + ))} + + + ); +} diff --git a/docs/data/material/components/menus/RadioMenu.tsx b/docs/data/material/components/menus/RadioMenu.tsx new file mode 100644 index 00000000000000..1455be200f3735 --- /dev/null +++ b/docs/data/material/components/menus/RadioMenu.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import Paper from '@mui/material/Paper'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import RadioButtonChecked from '@mui/icons-material/RadioButtonChecked'; +import RadioButtonUnchecked from '@mui/icons-material/RadioButtonUnchecked'; + +const options = ['Name', 'Date modified', 'Size']; + +export default function RadioMenu() { + const [selected, setSelected] = React.useState('Name'); + + return ( + + + {options.map((option) => ( + setSelected(option)} + > + + {selected === option ? ( + + ) : ( + + )} + + {option} + + ))} + + + ); +} diff --git a/docs/data/material/components/menus/menus.md b/docs/data/material/components/menus/menus.md index 9ae8536ade0a43..0ae32bfd270d10 100644 --- a/docs/data/material/components/menus/menus.md +++ b/docs/data/material/components/menus/menus.md @@ -52,6 +52,17 @@ To use a selected menu item without impacting the initial focus, set the `varian {{"demo": "SimpleListMenu.js"}} +## Checkbox and radio menu items + +To build a menu of toggleable options, set each item's `role` to `menuitemcheckbox` for independent toggles, or `menuitemradio` for a single choice within a group. +For these roles, the `selected` prop drives `aria-checked`, so assistive technologies announce the checked state. + +{{"demo": "CheckboxMenu.js", "bg": true}} + +For a single choice within a group, use `menuitemradio`: + +{{"demo": "RadioMenu.js", "bg": true}} + ## Positioned menu Because the `Menu` component uses the `Popover` component to position itself, you can use the same [positioning props](/material-ui/react-popover/#anchor-playground) to position it. diff --git a/docs/translations/api-docs/menu-item/menu-item.json b/docs/translations/api-docs/menu-item/menu-item.json index 777888417dec8f..6ce4ae16a19147 100644 --- a/docs/translations/api-docs/menu-item/menu-item.json +++ b/docs/translations/api-docs/menu-item/menu-item.json @@ -21,7 +21,9 @@ "focusVisibleClassName": { "description": "This prop can help identify which element has keyboard focus. The class name will be applied when the element gains the focus through keyboard interaction. It's a polyfill for the CSS :focus-visible selector. The rationale for using this feature is explained here. A polyfill can be used to apply a focus-visible class to other components if needed." }, - "selected": { "description": "If true, the component is selected." }, + "selected": { + "description": "If true, the component is selected. For menuitemcheckbox and menuitemradio roles, this also drives aria-checked." + }, "sx": { "description": "The system prop that allows defining system overrides as well as additional CSS styles." } diff --git a/packages/mui-material/src/MenuItem/MenuItem.d.ts b/packages/mui-material/src/MenuItem/MenuItem.d.ts index 01ea591389ab78..90f51d0633c5af 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.d.ts +++ b/packages/mui-material/src/MenuItem/MenuItem.d.ts @@ -39,6 +39,7 @@ export interface MenuItemOwnProps { divider?: boolean | undefined; /** * If `true`, the component is selected. + * For `menuitemcheckbox` and `menuitemradio` roles, this also drives `aria-checked`. * @default false */ selected?: boolean | undefined; diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js index 68df44938b4404..1fc1ecbba1a95b 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.js +++ b/packages/mui-material/src/MenuItem/MenuItem.js @@ -181,6 +181,11 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { ...other } = props; + // `menuitemcheckbox`/`menuitemradio` require `aria-checked`; derive it from `selected` + // (an omitted `selected` means unchecked). Other roles keep `selected` presentational. + const isCheckableRole = role === 'menuitemcheckbox' || role === 'menuitemradio'; + const ariaChecked = isCheckableRole ? Boolean(props.selected) : undefined; + const focusSource = useSelectFocusSource(); const context = React.useContext(ListContext); const childContext = React.useMemo( @@ -250,6 +255,7 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { ', () => { expect(menuitem).to.have.class(classes.selected); expect(menuitem).not.to.have.attribute('aria-selected'); + expect(menuitem).not.to.have.attribute('aria-checked'); + }); + + it('drives aria-checked from `selected` for `role="menuitemcheckbox"`', () => { + const { rerender } = render( + + + , + ); + const menuitem = screen.getByRole('menuitemcheckbox'); + + expect(menuitem).to.have.attribute('aria-checked', 'true'); + expect(menuitem).not.to.have.attribute('aria-selected'); + + rerender( + + + , + ); + + expect(screen.getByRole('menuitemcheckbox')).to.have.attribute('aria-checked', 'false'); + }); + + it('sets aria-checked="false" for a `menuitemcheckbox` when `selected` is omitted', () => { + render( + + + , + ); + + expect(screen.getByRole('menuitemcheckbox')).to.have.attribute('aria-checked', 'false'); + }); + + it('drives aria-checked from `selected` for `role="menuitemradio"`', () => { + render( + + + , + ); + const menuitem = screen.getByRole('menuitemradio'); + + expect(menuitem).to.have.attribute('aria-checked', 'true'); + expect(menuitem).not.to.have.attribute('aria-selected'); + }); + + it('keeps `selected` presentational and omits aria-checked for non-checkable roles', () => { + render( + + + , + ); + const option = screen.getByRole('option'); + + expect(option).to.have.class(classes.selected); + expect(option).not.to.have.attribute('aria-checked'); }); it('can have a role of option', () => {