diff --git a/docs/pages/api-docs/menu-item.json b/docs/pages/api-docs/menu-item.json index fd477bac741d54..5ec13d4047e660 100644 --- a/docs/pages/api-docs/menu-item.json +++ b/docs/pages/api-docs/menu-item.json @@ -8,11 +8,23 @@ "disableGutters": { "type": { "name": "bool" } }, "divider": { "type": { "name": "bool" } }, "focusVisibleClassName": { "type": { "name": "string" } }, + "openSubMenu": { "type": { "name": "bool" } }, + "subMenu": { "type": { "name": "node" } }, + "subMenuIcon": { "type": { "name": "node" }, "default": "KeyboardArrowRight" }, "sx": { "type": { "name": "union", "description": "func
| object" } } }, "name": "MenuItem", "styles": { - "classes": ["root", "focusVisible", "dense", "disabled", "divider", "gutters", "selected"], + "classes": [ + "root", + "focusVisible", + "dense", + "disabled", + "divider", + "gutters", + "selected", + "openSubMenuParent" + ], "globalClasses": { "focusVisible": "Mui-focusVisible", "disabled": "Mui-disabled", diff --git a/docs/pages/api-docs/sub-menu.js b/docs/pages/api-docs/sub-menu.js new file mode 100644 index 00000000000000..0c85829b18a55c --- /dev/null +++ b/docs/pages/api-docs/sub-menu.js @@ -0,0 +1,19 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './sub-menu.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context('docs/translations/api-docs/sub-menu', false, /sub-menu.*.json$/); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/sub-menu.json b/docs/pages/api-docs/sub-menu.json new file mode 100644 index 00000000000000..f95535ecfc8406 --- /dev/null +++ b/docs/pages/api-docs/sub-menu.json @@ -0,0 +1,10 @@ +{ + "props": { "children": { "type": { "name": "node" } } }, + "name": "SubMenu", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "filename": "/packages/mui-material/src/SubMenu/SubMenu.js", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/src/pages/components/menus/CascadingMenu.js b/docs/src/pages/components/menus/CascadingMenu.js new file mode 100644 index 00000000000000..5c558ad5777138 --- /dev/null +++ b/docs/src/pages/components/menus/CascadingMenu.js @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import Button from '@mui/material/Button'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import SubMenu from '@mui/material/SubMenu'; +import useTheme from '@mui/styles/useTheme'; + +export default function CascadingMenu() { + const theme = useTheme(); + const [anchorEl, setAnchorEl] = useState(null); + + const handleButtonClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( +
+ + + + View + + 75% + 100% + 125% + + } + > + Zoom + + Help + + } + > + Options + + My account + Logout + +
+ ); +} diff --git a/docs/src/pages/components/menus/menus.md b/docs/src/pages/components/menus/menus.md index 562667354adc1c..70de29be97a7c8 100644 --- a/docs/src/pages/components/menus/menus.md +++ b/docs/src/pages/components/menus/menus.md @@ -1,6 +1,6 @@ --- title: React Menu component -components: Menu, MenuItem, MenuList, ClickAwayListener, Popover, Popper +components: Menu, SubMenu, MenuItem, MenuList, ClickAwayListener, Popover, Popper githubLabel: 'component: Menu' materialDesign: https://material.io/components/menus waiAria: https://www.w3.org/TR/wai-aria-practices/#menubutton @@ -67,7 +67,13 @@ The primary responsibility of the `MenuList` component is to handle the focus. {{"demo": "pages/components/menus/AccountMenu.js"}} -## Customization +## Cascading menu + +Cascading menus allow users to choose from a large variety of choices, by displaying menus with multiple levels of hierarchy. + +{{"demo": "pages/components/menus/CascadingMenu.js"}} + +## Customized menus Here is an example of customizing the component. You can learn more about this in the [overrides documentation page](/customization/how-to-customize/). diff --git a/docs/src/pagesApi.js b/docs/src/pagesApi.js index c6c6d0c224f1c6..2f8bcc3e47cee3 100644 --- a/docs/src/pagesApi.js +++ b/docs/src/pagesApi.js @@ -133,6 +133,7 @@ module.exports = [ { pathname: '/api-docs/step-icon' }, { pathname: '/api-docs/step-label' }, { pathname: '/api-docs/stepper' }, + { pathname: '/api-docs/sub-menu' }, { pathname: '/api-docs/svg-icon' }, { pathname: '/api-docs/swipeable-drawer' }, { pathname: '/api-docs/switch' }, diff --git a/docs/translations/api-docs/menu-item/menu-item.json b/docs/translations/api-docs/menu-item/menu-item.json index 0d74cf52545795..19337cb76849db 100644 --- a/docs/translations/api-docs/menu-item/menu-item.json +++ b/docs/translations/api-docs/menu-item/menu-item.json @@ -9,6 +9,9 @@ "disableGutters": "If true, the left and right padding is removed.", "divider": "If true, a 1px light border is added to the bottom of the menu item.", "focusVisibleClassName": "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.", + "openSubMenu": "When true, opens the subMenu, if provided.", + "subMenu": "Menu to display as a sub-menu.", + "subMenuIcon": "Normally Icon, SvgIcon, or a @material-ui/icons SVG icon element rendered on a MenuItem that contains a subMenu", "sx": "The system prop that allows defining system overrides as well as additional CSS styles. See the `sx` page for more details." }, "classDescriptions": { @@ -42,6 +45,11 @@ "description": "State class applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", "conditions": "selected={true}" + }, + "openSubMenuParent": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "its submenu is open" } } } diff --git a/docs/translations/api-docs/sub-menu/sub-menu-de.json b/docs/translations/api-docs/sub-menu/sub-menu-de.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-de.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu-es.json b/docs/translations/api-docs/sub-menu/sub-menu-es.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-es.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu-fr.json b/docs/translations/api-docs/sub-menu/sub-menu-fr.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-fr.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu-ja.json b/docs/translations/api-docs/sub-menu/sub-menu-ja.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-ja.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu-pt.json b/docs/translations/api-docs/sub-menu/sub-menu-pt.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-pt.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu-ru.json b/docs/translations/api-docs/sub-menu/sub-menu-ru.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-ru.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu-zh.json b/docs/translations/api-docs/sub-menu/sub-menu-zh.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu-zh.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/sub-menu/sub-menu.json b/docs/translations/api-docs/sub-menu/sub-menu.json new file mode 100644 index 00000000000000..a860016aa1146d --- /dev/null +++ b/docs/translations/api-docs/sub-menu/sub-menu.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "children": "Menu contents, normally MenuItems." }, + "classDescriptions": {} +} diff --git a/packages/mui-material/src/Menu/Menu.d.ts b/packages/mui-material/src/Menu/Menu.d.ts index 88856b7b699ba5..34d5db4c37c9a0 100644 --- a/packages/mui-material/src/Menu/Menu.d.ts +++ b/packages/mui-material/src/Menu/Menu.d.ts @@ -57,6 +57,7 @@ export interface MenuProps extends StandardProps { * `classes` prop applied to the [`Popover`](/api/popover/) element. */ PopoverClasses?: PopoverProps['classes']; + setParentOpenSubMenuIndex?: (index: number) => void; /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/Menu/Menu.js b/packages/mui-material/src/Menu/Menu.js index e539589c503c60..c0233ff720967f 100644 --- a/packages/mui-material/src/Menu/Menu.js +++ b/packages/mui-material/src/Menu/Menu.js @@ -52,6 +52,8 @@ const MenuPaper = styled(Paper, { maxHeight: 'calc(100% - 96px)', // Add iOS momentum scrolling for iOS < 13.0 WebkitOverflowScrolling: 'touch', + // Turn pointer events back on for any submenus whose root had it turned off + pointerEvents: 'auto', }); const MenuMenuList = styled(MenuList, { @@ -75,8 +77,9 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { open, PaperProps = {}, PopoverClasses, + setParentOpenSubMenuIndex, transitionDuration = 'auto', - TransitionProps: { onEntering, ...TransitionProps } = {}, + TransitionProps: { onEnter, onEntering, onEntered, ...TransitionProps } = {}, variant = 'selectedMenu', ...other } = props; @@ -84,6 +87,16 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { const theme = useTheme(); const isRtl = theme.direction === 'rtl'; + const [openSubMenuIndex, setOpenSubMenuIndex] = React.useState(null); + const [entering, setEntering] = React.useState(false); + const isSubMenu = typeof setParentOpenSubMenuIndex !== 'undefined'; + + const atLeastOneSubMenu = + isSubMenu || + React.Children.toArray(children).some( + (child) => React.isValidElement(child) && child.props && child.props.subMenu, + ); + const ownerState = { ...props, autoFocus, @@ -101,6 +114,18 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { const autoFocusItem = autoFocus && !disableAutoFocusItem && open; const menuListActionsRef = React.useRef(null); + const contentAnchorRef = React.useRef(null); + + const handleEnter = (element, isAppearing) => { + if (atLeastOneSubMenu) { + setEntering(true); + setOpenSubMenuIndex(null); + } + + if (onEnter) { + onEnter(element, isAppearing); + } + }; const handleEntering = (element, isAppearing) => { if (menuListActionsRef.current) { @@ -112,13 +137,42 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { } }; + const handleEntered = (element, isAppearing) => { + if (atLeastOneSubMenu) { + setEntering(false); + } + + if (onEntered) { + onEntered(element, isAppearing); + } + }; + + const handleOnClose = (event) => { + event.preventDefault(); + setOpenSubMenuIndex(null); + if (onClose) { + onClose(event, `${event.key.toLowerCase()}KeyDown`); + } + }; + const handleListKeyDown = (event) => { - if (event.key === 'Tab') { - event.preventDefault(); + const { anchorEl } = other; + const closeKeys = ['Tab', 'Escape']; + if (closeKeys.includes(event.key)) { + handleOnClose(event); + } + + if (event.key === 'ArrowLeft' && isSubMenu) { + // When arrowing left, we need to pass focus back to the parent MenuItem of + // the currently focused subMenu. + anchorEl.focus(); - if (onClose) { - onClose(event, 'tabKeyDown'); + // Close only the currently focused subMenu. Don't trigger the close cascade + // for the entire Menu structure. + if (!event.defaultPrevented) { + setParentOpenSubMenuIndex(null); } + event.preventDefault(); } }; @@ -148,18 +202,90 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { } if (!child.props.disabled) { - if (variant === 'selectedMenu' && child.props.selected) { - activeItemIndex = index; - } else if (activeItemIndex === -1) { + if ((variant === 'selectedMenu' && child.props.selected) || activeItemIndex === -1) { activeItemIndex = index; } } }); + const handleSetOpenSubMenuIndex = (value) => { + if (value === null) { + if (contentAnchorRef.current.parentElement) { + contentAnchorRef.current.parentElement.children[openSubMenuIndex].focus(); + } + } + setOpenSubMenuIndex(value); + }; + + const items = React.Children.map(children, (child, index) => { + if (!React.isValidElement(child)) { + return undefined; + } + + const { subMenu, onMouseMove: onMouseMoveChildProp } = child.props; + const { anchorEl } = other; + + const hasSubMenu = Boolean(subMenu); + const parentMenuOpen = Boolean(anchorEl); + + const additionalProps = {}; + + if (atLeastOneSubMenu && index === activeItemIndex) { + additionalProps.ref = (instance) => { + contentAnchorRef.current = instance; + }; + } + + // If the current Menu item in this map has a subMenu, + // we need the parent Menu to orchestrate its subMenu + if (hasSubMenu && parentMenuOpen) { + additionalProps.onArrowRightKeydown = (e) => { + if (e.key === 'ArrowRight') { + e.preventDefault(); + setOpenSubMenuIndex(index); + } + }; + additionalProps.openSubMenu = index === openSubMenuIndex && !entering; + additionalProps.setParentOpenSubMenuIndex = handleSetOpenSubMenuIndex; + } + + // If there are ANY children with subMenus, then ALL + // of the children need to know how to close any open subMenus + // and reset the state that controls which subMenu is open. + if (atLeastOneSubMenu) { + additionalProps.onMouseMove = (e) => { + setOpenSubMenuIndex(index); + if (onMouseMoveChildProp) { + onMouseMoveChildProp(e); + } + }; + additionalProps.onParentClose = handleOnClose; + } + + if (Object.keys(additionalProps).length > 0) { + return React.cloneElement(child, { + ...additionalProps, + }); + } + + return child; + }); + + const rootPointerStyles = {}; + if (isSubMenu) { + rootPointerStyles.pointerEvents = 'none'; + } + + const menuOnClose = (event, name) => { + if (!event.defaultPrevented) { + onClose(event, name); + } + }; + return ( - {children} + {items} ); @@ -257,6 +389,10 @@ Menu.propTypes /* remove-proptypes */ = { * `classes` prop applied to the [`Popover`](/api/popover/) element. */ PopoverClasses: PropTypes.object, + /** + * @ignore + */ + setParentOpenSubMenuIndex: PropTypes.func, /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/Menu/Menu.test.js b/packages/mui-material/src/Menu/Menu.test.js index e9e3e9e000e3a6..13b614f1349cc5 100644 --- a/packages/mui-material/src/Menu/Menu.test.js +++ b/packages/mui-material/src/Menu/Menu.test.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { spy, useFakeTimers } from 'sinon'; import { expect } from 'chai'; import { + act, createClientRender, describeConformance, screen, @@ -10,6 +11,9 @@ import { } from 'test/utils'; import Menu, { menuClasses as classes } from '@mui/material/Menu'; import Popover from '@mui/material/Popover'; +import Button from '../Button'; +import SubMenu from '../SubMenu'; +import MenuItem from '../MenuItem'; describe('', () => { /** @@ -156,7 +160,7 @@ describe('', () => { }); it('should open during the initial mount', () => { - function MenuItem(props) { + function MenuItemMock(props) { const { autoFocus, children } = props; return (
@@ -166,7 +170,7 @@ describe('', () => { } render( - one + one , ); @@ -211,7 +215,7 @@ describe('', () => { }); it('should call onClose on tab', () => { - function MenuItem(props) { + function MenuItemMock(props) { const { autoFocus, children } = props; const ref = React.useRef(null); @@ -230,7 +234,7 @@ describe('', () => { const onCloseSpy = spy(); render( - hello + hello , ); @@ -270,4 +274,750 @@ describe('', () => { ]); }); }); + + describe('cascading menu', () => { + it('renders a subMenu', () => { + const expected = 'SubMenuItem'; + const CascadingMenu = () => { + const [anchorEl, setAnchorEl] = React.useState(null); + + const handleButtonClick = (event) => { + setAnchorEl(event.currentTarget); + }; + + return ( + +