@@ -166,7 +170,7 @@ describe('
', () => {
}
render(
,
);
@@ -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(
,
);
@@ -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 (
+
+
+
+
+ );
+ };
+
+ const { getByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseMove(getByRole('menuitem', { name: 'Test' }));
+ });
+ clock.restore();
+ expect(getByRole('menuitem', { name: expected })).to.not.equal(null);
+ });
+
+ it('renders a nested subMenu', () => {
+ const expected = 'NestedSubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseMove(getByRole('menuitem', { name: 'Test' }));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseMove(getByRole('menuitem', { name: 'Test2' }));
+ });
+
+ clock.restore();
+ expect(getByRole('menuitem', { name: expected })).to.not.equal(null);
+ });
+
+ it('collapses the subMenu when active parent item is changed', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole, queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseMove(getByRole('menuitem', { name: 'Test' }));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseMove(getByRole('menuitem', { name: 'Other', hidden: true }));
+ });
+
+ clock.restore();
+ expect(queryByRole('menuitem', { name: expected })).to.equal(null);
+ });
+
+ it('keeps subMenus open when mousing outside of menus', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole, queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseMove(getByRole('menuitem', { name: 'Test' }));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseOut(getByRole('menuitem', { name: 'Test', hidden: true }));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.mouseEnter(getByRole('button', { hidden: true }));
+ });
+
+ clock.restore();
+ expect(queryByRole('menuitem', { name: expected })).to.not.equal(null);
+ });
+
+ it('opens a subMenu on right arrow keydown', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole, queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ clock.restore();
+ expect(queryByRole('menuitem', { name: expected })).to.not.equal(null);
+ });
+
+ it('closes a subMenu on left arrow keydown', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole, queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: expected }), { key: 'ArrowLeft' });
+ });
+
+ clock.restore();
+ expect(queryByRole('menuitem', { name: expected })).to.equal(null);
+ });
+
+ it('closes all menus on tab keydown', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole, queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: expected }), { key: 'Tab' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+ clock.restore();
+
+ expect(queryByRole('menuitem', { name: expected })).to.equal(null);
+ expect(queryByRole('menuitem', { name: 'Test' })).to.equal(null);
+ });
+
+ it('closes all menus on escape keydown', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole, queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: expected }), { key: 'Escape' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ clock.restore();
+
+ expect(queryByRole('menuitem', { name: expected })).to.equal(null);
+ expect(queryByRole('menuitem', { name: 'Test' })).to.equal(null);
+ });
+
+ it('changes subMenu item focus with down arrow', () => {
+ const expected = 'Second';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'First' }), { key: 'ArrowDown' });
+ });
+
+ clock.restore();
+ expect(getByRole('menuitem', { name: expected })).to.equal(document.activeElement); // is focused
+ expect(Array.from(getByRole('menuitem', { name: expected }).classList)).to.include(
+ 'Mui-focusVisible',
+ ); // looks focused
+ });
+
+ it('changes subMenu item focus with up arrow', () => {
+ const expected = 'Second';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'First' }), { key: 'ArrowUp' });
+ });
+
+ clock.restore();
+ expect(getByRole('menuitem', { name: expected })).to.equal(document.activeElement); // is focused
+ expect(Array.from(getByRole('menuitem', { name: expected }).classList)).to.include(
+ 'Mui-focusVisible',
+ ); // looks focused
+ });
+
+ it('focuses first item when it opens a subMenu', () => {
+ const expected = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: 'Test' }), { key: 'ArrowRight' });
+ });
+
+ clock.restore();
+ expect(getByRole('menuitem', { name: expected })).to.equal(document.activeElement); // is focused
+ expect(Array.from(getByRole('menuitem', { name: expected }).classList)).to.include(
+ 'Mui-focusVisible',
+ ); // looks focused
+ });
+
+ it('changes focus with right and left arrow keys', () => {
+ const firstFocus = 'MenuItem';
+ const secondFocus = 'SubMenuItem';
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { getByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(getByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ // ensure focus is on first item in root menu
+ expect(getByRole('menuitem', { name: firstFocus })).to.equal(document.activeElement); // is focused
+ expect(Array.from(getByRole('menuitem', { name: firstFocus }).classList)).to.include(
+ 'Mui-focusVisible',
+ ); // looks focused
+
+ // arrow right
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: firstFocus }), { key: 'ArrowRight' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ // ensure focus moved to first item in submenu
+ expect(getByRole('menuitem', { name: secondFocus })).to.equal(document.activeElement); // is focused
+ expect(Array.from(getByRole('menuitem', { name: secondFocus }).classList)).to.include(
+ 'Mui-focusVisible',
+ ); // looks focused
+
+ // arrow back left
+ act(() => {
+ fireEvent.keyDown(getByRole('menuitem', { name: secondFocus }), { key: 'ArrowLeft' });
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ // ensure focus moved back to first item in root menu
+ expect(getByRole('menuitem', { name: firstFocus })).to.equal(document.activeElement); // is focused
+ expect(Array.from(getByRole('menuitem', { name: firstFocus }).classList)).to.include(
+ 'Mui-focusVisible',
+ ); // looks focused
+ });
+
+ it('keeps parent items of open sub menus highlighted', () => {
+ const CascadingMenu = () => {
+ const [anchorEl, setAnchorEl] = React.useState(null);
+
+ const handleButtonClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ return (
+
+
+
+
+ );
+ };
+
+ const { queryByRole } = render(
);
+
+ act(() => {
+ fireEvent.click(queryByRole('button'));
+ });
+
+ act(() => {
+ clock.tick(0);
+ });
+
+ act(() => {
+ fireEvent.keyDown(queryByRole('menuitem', { name: 'MenuItem' }), { key: 'ArrowRight' });
+ });
+
+ clock.restore();
+
+ const classList = Array.from(
+ queryByRole('menuitem', { name: 'MenuItem', hidden: true }).classList,
+ );
+ const findClassLike = (matchString, list) => {
+ const matches = list.filter((c) => c.indexOf(matchString) > -1);
+ return matches.length > 0;
+ };
+
+ expect(findClassLike('openSubMenuParent', classList)).to.equal(true);
+ });
+ });
});
diff --git a/packages/mui-material/src/MenuItem/MenuItem.d.ts b/packages/mui-material/src/MenuItem/MenuItem.d.ts
index 68e5bc63f668d5..5ce56634ca6d52 100644
--- a/packages/mui-material/src/MenuItem/MenuItem.d.ts
+++ b/packages/mui-material/src/MenuItem/MenuItem.d.ts
@@ -46,6 +46,27 @@ export type MenuItemTypeMap
= Extend
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
+ onArrowRightKeydown?: React.KeyboardEventHandler;
+ onKeyDown?: React.KeyboardEventHandler;
+ onMouseEnter?: React.MouseEventHandler;
+ onParentClose?: React.ReactEventHandler<{}>;
+ /**
+ * When `true`, opens the subMenu, if provided.
+ * @default false
+ */
+ openSubMenu?: boolean;
+ setParentOpenSubMenuIndex?: (index: number) => void;
+ /**
+ * Menu to display as a sub-menu.
+ */
+ subMenu?: React.ReactNode;
+ /**
+ * Normally `Icon`, `SvgIcon`, or a `@material-ui/icons`
+ * SVG icon element rendered on a MenuItem that
+ * contains a subMenu
+ * @default KeyboardArrowRight
+ */
+ subMenuIcon?: React.ReactNode;
};
defaultComponent: D;
}>;
diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js
index aa004d101b28ac..15e56ecdb3ff24 100644
--- a/packages/mui-material/src/MenuItem/MenuItem.js
+++ b/packages/mui-material/src/MenuItem/MenuItem.js
@@ -3,8 +3,10 @@ import PropTypes from 'prop-types';
import clsx from 'clsx';
import { unstable_composeClasses as composeClasses } from '@mui/core';
import { alpha } from '@mui/system';
+import { makeStyles } from '@mui/styles';
import styled, { rootShouldForwardProp } from '../styles/styled';
import useThemeProps from '../styles/useThemeProps';
+import useTheme from '../styles/useTheme';
import ListContext from '../List/ListContext';
import ButtonBase from '../ButtonBase';
import useEnhancedEffect from '../utils/useEnhancedEffect';
@@ -13,6 +15,28 @@ import { dividerClasses } from '../Divider';
import { listItemIconClasses } from '../ListItemIcon';
import { listItemTextClasses } from '../ListItemText';
import menuItemClasses, { getMenuItemUtilityClass } from './menuItemClasses';
+import KeyboardArrowRight from '../internal/svg-icons/KeyboardArrowRight';
+import createChainedFunction from '../utils/createChainedFunction';
+
+const RTL_ANCHOR_ORIGIN = {
+ vertical: 'top',
+ horizontal: 'left',
+};
+
+const LTR_ANCHOR_ORIGIN = {
+ vertical: 'top',
+ horizontal: 'right',
+};
+
+const RTL_TRANSFORM_ORIGIN = {
+ vertical: 'top',
+ horizontal: 'right',
+};
+
+const LTR_TRANSFORM_ORIGIN = {
+ vertical: 'top',
+ horizontal: 'left',
+};
export const overridesResolver = (props, styles) => {
const { ownerState } = props;
@@ -26,7 +50,7 @@ export const overridesResolver = (props, styles) => {
};
const useUtilityClasses = (ownerState) => {
- const { disabled, dense, divider, disableGutters, selected, classes } = ownerState;
+ const { disabled, dense, divider, disableGutters, openSubMenu, selected, classes } = ownerState;
const slots = {
root: [
'root',
@@ -35,6 +59,7 @@ const useUtilityClasses = (ownerState) => {
!disableGutters && 'gutters',
divider && 'divider',
selected && 'selected',
+ openSubMenu && 'openSubMenuParent',
],
};
@@ -104,6 +129,9 @@ const MenuItemRoot = styled(ButtonBase, {
[`&.${menuItemClasses.disabled}`]: {
opacity: theme.palette.action.disabledOpacity,
},
+ [`&.${menuItemClasses.openSubMenuParent}`]: {
+ backgroundColor: theme.palette.action.hover,
+ },
[`& + .${dividerClasses.root}`]: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
@@ -137,17 +165,43 @@ const MenuItemRoot = styled(ButtonBase, {
}),
}));
+/* Styles applied to a Menu Item's children when a subMenu is present */
+const useStyles = makeStyles({
+ subMenuItemWrapper: {
+ width: '100%',
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ /* Styles applied to the subMenuIcon when it is present. */
+ subMenuIcon: {
+ marginLeft: ({ theme }) => theme.spacing(2),
+ },
+ /* Styles applied to subMenuIcon when direction is 'rtl'. */
+ rtlSubMenuIcon: {
+ transform: 'rotate(-180deg)',
+ },
+});
+
const MenuItem = React.forwardRef(function MenuItem(inProps, ref) {
const props = useThemeProps({ props: inProps, name: 'MuiMenuItem' });
const {
autoFocus = false,
+ children,
component = 'li',
dense = false,
divider = false,
disableGutters = false,
focusVisibleClassName,
+ onArrowRightKeydown,
+ openSubMenu = false,
+ onKeyDown,
role = 'menuitem',
+ selected,
+ subMenu,
+ subMenuIcon: SubMenuIcon = KeyboardArrowRight,
+ setParentOpenSubMenuIndex,
tabIndex: tabIndexProp,
+ onParentClose,
...other
} = props;
@@ -157,6 +211,8 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) {
disableGutters,
};
+ const theme = useTheme();
+
const menuItemRef = React.useRef(null);
useEnhancedEffect(() => {
if (autoFocus) {
@@ -178,6 +234,7 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) {
};
const classes = useUtilityClasses(props);
+ const hookClasses = useStyles({ theme });
const handleRef = useForkRef(menuItemRef, ref);
@@ -186,9 +243,26 @@ const MenuItem = React.forwardRef(function MenuItem(inProps, ref) {
tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
}
+ const {
+ anchorEl, // disallowed
+ onParentClose: onParentCloseProp, // disallowed
+ MenuListProps, // Needs to be spread into subMenu prop
+ isSubMenu, // disallowed
+ open, // disallowed
+ setParentOpenSubMenuIndex: setParentOpenSubMenuIndexProp, // disallowed
+ onClose: subOnClose, // Needs to be combined with parentOnClose on the subMenu
+ ...allowedSubMenuProps
+ } = subMenu ? subMenu.props : {};
+
+ const listItemAnchorEl = menuItemRef.current;
+ const renderSubMenu = openSubMenu && listItemAnchorEl;
+
return (
+ >
+ {subMenu ? (
+
+ {children}
+
+
+ ) : (
+ children
+ )}
+
+ {renderSubMenu &&
+ React.cloneElement(subMenu, {
+ key: 'subMenu',
+ anchorEl: listItemAnchorEl,
+ anchorOrigin: theme.direction === 'rtl' ? RTL_ANCHOR_ORIGIN : LTR_ANCHOR_ORIGIN,
+ MenuListProps: { ...MenuListProps, isSubMenu: true },
+ open: openSubMenu,
+ onClose: createChainedFunction(onParentClose, subOnClose),
+ setParentOpenSubMenuIndex,
+ transformOrigin: theme.direction === 'rtl' ? RTL_TRANSFORM_ORIGIN : LTR_TRANSFORM_ORIGIN,
+ ...allowedSubMenuProps,
+ })}
);
});
@@ -255,6 +354,27 @@ MenuItem.propTypes /* remove-proptypes */ = {
* if needed.
*/
focusVisibleClassName: PropTypes.string,
+ /**
+ * @ignore
+ */
+ onArrowRightKeydown: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onKeyDown: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onMouseEnter: PropTypes.func,
+ /**
+ * @ignore
+ */
+ onParentClose: PropTypes.func,
+ /**
+ * When `true`, opens the subMenu, if provided.
+ * @default false
+ */
+ openSubMenu: PropTypes.bool,
/**
* @ignore
*/
@@ -263,6 +383,21 @@ MenuItem.propTypes /* remove-proptypes */ = {
* @ignore
*/
selected: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ setParentOpenSubMenuIndex: PropTypes.func,
+ /**
+ * Menu to display as a sub-menu.
+ */
+ subMenu: PropTypes.node,
+ /**
+ * Normally `Icon`, `SvgIcon`, or a `@material-ui/icons`
+ * SVG icon element rendered on a MenuItem that
+ * contains a subMenu
+ * @default KeyboardArrowRight
+ */
+ subMenuIcon: PropTypes.node,
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
diff --git a/packages/mui-material/src/MenuItem/menuItemClasses.ts b/packages/mui-material/src/MenuItem/menuItemClasses.ts
index ff892b7375730b..503e87cf532ba1 100644
--- a/packages/mui-material/src/MenuItem/menuItemClasses.ts
+++ b/packages/mui-material/src/MenuItem/menuItemClasses.ts
@@ -15,6 +15,8 @@ export interface MenuItemClasses {
gutters: string;
/** State class applied to the root element if `selected={true}`. */
selected: string;
+ /** State class applied to the root element if its submenu is open. */
+ openSubMenuParent: string;
}
export type MenuItemClassKey = keyof MenuItemClasses;
@@ -31,6 +33,7 @@ const menuItemClasses: MenuItemClasses = generateUtilityClasses('MuiMenuItem', [
'divider',
'gutters',
'selected',
+ 'openSubMenuParent',
]);
export default menuItemClasses;
diff --git a/packages/mui-material/src/MenuList/MenuList.d.ts b/packages/mui-material/src/MenuList/MenuList.d.ts
index b99f0c0ff379b2..e72ec4fdb560c5 100644
--- a/packages/mui-material/src/MenuList/MenuList.d.ts
+++ b/packages/mui-material/src/MenuList/MenuList.d.ts
@@ -27,6 +27,7 @@ export interface MenuListProps extends ListProps {
* @default false
*/
disableListWrap?: boolean;
+ isSubMenu?: boolean;
/**
* The variant to use. Use `menu` to prevent selected items from impacting the initial focus
* and the vertical alignment relative to the anchor element.
diff --git a/packages/mui-material/src/MenuList/MenuList.js b/packages/mui-material/src/MenuList/MenuList.js
index 42d0a40b44efdb..02a652e4964bad 100644
--- a/packages/mui-material/src/MenuList/MenuList.js
+++ b/packages/mui-material/src/MenuList/MenuList.js
@@ -103,6 +103,7 @@ const MenuList = React.forwardRef(function MenuList(props, ref) {
className,
disabledItemsFocusable = false,
disableListWrap = false,
+ isSubMenu,
onKeyDown,
variant = 'selectedMenu',
...other
@@ -151,51 +152,58 @@ const MenuList = React.forwardRef(function MenuList(props, ref) {
*/
const currentFocus = ownerDocument(list).activeElement;
- if (key === 'ArrowDown') {
- // Prevent scroll of the page
- event.preventDefault();
- moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, nextItem);
- } else if (key === 'ArrowUp') {
- event.preventDefault();
- moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, previousItem);
- } else if (key === 'Home') {
- event.preventDefault();
- moveFocus(list, null, disableListWrap, disabledItemsFocusable, nextItem);
- } else if (key === 'End') {
- event.preventDefault();
- moveFocus(list, null, disableListWrap, disabledItemsFocusable, previousItem);
- } else if (key.length === 1) {
- const criteria = textCriteriaRef.current;
- const lowerKey = key.toLowerCase();
- const currTime = performance.now();
- if (criteria.keys.length > 0) {
- // Reset
- if (currTime - criteria.lastTime > 500) {
- criteria.keys = [];
- criteria.repeating = true;
- criteria.previousKeyMatched = true;
- } else if (criteria.repeating && lowerKey !== criteria.keys[0]) {
- criteria.repeating = false;
- }
- }
- criteria.lastTime = currTime;
- criteria.keys.push(lowerKey);
- const keepFocusOnCurrent =
- currentFocus && !criteria.repeating && textCriteriaMatches(currentFocus, criteria);
- if (
- criteria.previousKeyMatched &&
- (keepFocusOnCurrent ||
- moveFocus(list, currentFocus, false, disabledItemsFocusable, nextItem, criteria))
- ) {
+ if (!event.defaultPrevented) {
+ if (key === 'ArrowDown') {
+ // Prevent scroll of the page
+ event.preventDefault();
+ moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, nextItem);
+ } else if (key === 'ArrowUp') {
+ event.preventDefault();
+ moveFocus(list, currentFocus, disableListWrap, disabledItemsFocusable, previousItem);
+ } else if (key === 'Home') {
+ event.preventDefault();
+ moveFocus(list, null, disableListWrap, disabledItemsFocusable, nextItem);
+ } else if (key === 'End') {
event.preventDefault();
- } else {
- criteria.previousKeyMatched = false;
+ moveFocus(list, null, disableListWrap, disabledItemsFocusable, previousItem);
+ } else if (key.length === 1) {
+ const criteria = textCriteriaRef.current;
+ const lowerKey = key.toLowerCase();
+ const currTime = performance.now();
+ if (criteria.keys.length > 0) {
+ // Reset
+ if (currTime - criteria.lastTime > 500) {
+ criteria.keys = [];
+ criteria.repeating = true;
+ criteria.previousKeyMatched = true;
+ } else if (criteria.repeating && lowerKey !== criteria.keys[0]) {
+ criteria.repeating = false;
+ }
+ }
+ criteria.lastTime = currTime;
+ criteria.keys.push(lowerKey);
+ const keepFocusOnCurrent =
+ currentFocus && !criteria.repeating && textCriteriaMatches(currentFocus, criteria);
+ if (
+ criteria.previousKeyMatched &&
+ (keepFocusOnCurrent ||
+ moveFocus(list, currentFocus, false, disabledItemsFocusable, nextItem, criteria))
+ ) {
+ event.preventDefault();
+ } else {
+ criteria.previousKeyMatched = false;
+ }
}
}
- if (onKeyDown) {
+ if (!event.defaultPrevented && onKeyDown) {
onKeyDown(event);
}
+
+ const capturedKeys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
+ if (isSubMenu && capturedKeys.includes(key)) {
+ event.preventDefault();
+ }
};
const handleRef = useForkRef(listRef, ref);
@@ -298,6 +306,10 @@ MenuList.propTypes /* remove-proptypes */ = {
* @default false
*/
disableListWrap: PropTypes.bool,
+ /**
+ * @ignore
+ */
+ isSubMenu: PropTypes.bool,
/**
* @ignore
*/
diff --git a/packages/mui-material/src/SubMenu/SubMenu.d.ts b/packages/mui-material/src/SubMenu/SubMenu.d.ts
new file mode 100644
index 00000000000000..136cc50f8a4780
--- /dev/null
+++ b/packages/mui-material/src/SubMenu/SubMenu.d.ts
@@ -0,0 +1,15 @@
+import { MenuProps } from '@mui/material/Menu';
+
+export type SubMenuProps = Omit;
+
+/**
+ *
+ * Demos:
+ *
+ * - [Menus](https://mui.com/components/menus/)
+ *
+ * API:
+ *
+ * - [SubMenu API](https://mui.com/api/sub-menu/)
+ */
+export default function SubMenu(props: SubMenuProps): JSX.Element;
diff --git a/packages/mui-material/src/SubMenu/SubMenu.js b/packages/mui-material/src/SubMenu/SubMenu.js
new file mode 100644
index 00000000000000..811120e0484cf3
--- /dev/null
+++ b/packages/mui-material/src/SubMenu/SubMenu.js
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import Menu from '../Menu';
+
+const SubMenu = React.forwardRef(function SubMenu(props, ref) {
+ const { children, ...restProps } = props;
+ return (
+
+ );
+});
+
+SubMenu.propTypes /* remove-proptypes */ = {
+ // ----------------------------- Warning --------------------------------
+ // | These PropTypes are generated from the TypeScript type definitions |
+ // | To update them edit the d.ts file and run "yarn proptypes" |
+ // ----------------------------------------------------------------------
+ /**
+ * Menu contents, normally `MenuItem`s.
+ */
+ children: PropTypes.node,
+};
+
+export default SubMenu;
diff --git a/packages/mui-material/src/SubMenu/SubMenu.test.js b/packages/mui-material/src/SubMenu/SubMenu.test.js
new file mode 100644
index 00000000000000..75b5a3eb8b5f72
--- /dev/null
+++ b/packages/mui-material/src/SubMenu/SubMenu.test.js
@@ -0,0 +1,3 @@
+describe('', () => {
+ it('Does not have useful tests yet...', () => {});
+});
diff --git a/packages/mui-material/src/SubMenu/index.d.ts b/packages/mui-material/src/SubMenu/index.d.ts
new file mode 100644
index 00000000000000..81427dc80e9fbf
--- /dev/null
+++ b/packages/mui-material/src/SubMenu/index.d.ts
@@ -0,0 +1,2 @@
+export { default } from './SubMenu';
+export * from './SubMenu';
diff --git a/packages/mui-material/src/SubMenu/index.js b/packages/mui-material/src/SubMenu/index.js
new file mode 100644
index 00000000000000..6dfbe3e7e32c82
--- /dev/null
+++ b/packages/mui-material/src/SubMenu/index.js
@@ -0,0 +1,3 @@
+import SubMenu from './SubMenu';
+
+export default SubMenu;
diff --git a/packages/mui-material/src/index.d.ts b/packages/mui-material/src/index.d.ts
index 2afba6018f9a02..1c68b1090d82cb 100644
--- a/packages/mui-material/src/index.d.ts
+++ b/packages/mui-material/src/index.d.ts
@@ -388,6 +388,9 @@ export * from './StepLabel';
export { default as Stepper } from './Stepper';
export * from './Stepper';
+export { default as SubMenu } from './SubMenu';
+export * from './SubMenu';
+
export { default as SvgIcon } from './SvgIcon';
export * from './SvgIcon';
diff --git a/packages/mui-material/src/index.js b/packages/mui-material/src/index.js
index 1196f18273e6fd..478ab829f8af80 100644
--- a/packages/mui-material/src/index.js
+++ b/packages/mui-material/src/index.js
@@ -320,6 +320,9 @@ export * from './StepLabel';
export { default as Stepper } from './Stepper';
export * from './Stepper';
+export { default as SubMenu } from './SubMenu';
+export * from './SubMenu';
+
export { default as SvgIcon } from './SvgIcon';
export * from './SvgIcon';