diff --git a/docs/pages/api-docs/list-item-button.json b/docs/pages/api-docs/list-item-button.json index f25ff8d3172ffc..96e78a7956382b 100644 --- a/docs/pages/api-docs/list-item-button.json +++ b/docs/pages/api-docs/list-item-button.json @@ -38,7 +38,7 @@ "spread": true, "forwardsRefTo": "HTMLDivElement", "filename": "/packages/material-ui/src/ListItemButton/ListItemButton.js", - "inheritance": null, + "inheritance": { "component": "ButtonBase", "pathname": "/api/button-base/" }, "demos": "", "styledComponent": true, "cssComponent": false diff --git a/docs/pages/api-docs/menu-item.json b/docs/pages/api-docs/menu-item.json index 3fc70f2330d873..e73cb95a5bcfa1 100644 --- a/docs/pages/api-docs/menu-item.json +++ b/docs/pages/api-docs/menu-item.json @@ -1,23 +1,29 @@ { "props": { + "autoFocus": { "type": { "name": "bool" } }, "children": { "type": { "name": "node" } }, "classes": { "type": { "name": "object" } }, "component": { "type": { "name": "elementType" } }, "dense": { "type": { "name": "bool" } }, "disableGutters": { "type": { "name": "bool" } }, - "ListItemClasses": { "type": { "name": "object" } }, + "divider": { "type": { "name": "bool" } }, + "focusVisibleClassName": { "type": { "name": "string" } }, "sx": { "type": { "name": "object" } } }, "name": "MenuItem", "styles": { - "classes": ["root", "gutters", "selected", "dense"], - "globalClasses": { "selected": "Mui-selected" }, + "classes": ["root", "focusVisible", "dense", "disabled", "divider", "gutters", "selected"], + "globalClasses": { + "focusVisible": "Mui-focusVisible", + "disabled": "Mui-disabled", + "selected": "Mui-selected" + }, "name": "MuiMenuItem" }, "spread": true, "forwardsRefTo": "HTMLLIElement", "filename": "/packages/material-ui/src/MenuItem/MenuItem.js", - "inheritance": { "component": "ListItem", "pathname": "/api/list-item/" }, + "inheritance": { "component": "ButtonBase", "pathname": "/api/button-base/" }, "demos": "", "styledComponent": true, "cssComponent": false diff --git a/docs/src/pages/components/menus/AccountMenu.js b/docs/src/pages/components/menus/AccountMenu.js new file mode 100644 index 00000000000000..8a2523dca04afb --- /dev/null +++ b/docs/src/pages/components/menus/AccountMenu.js @@ -0,0 +1,97 @@ +import * as React from 'react'; +import Box from '@material-ui/core/Box'; +import Avatar from '@material-ui/core/Avatar'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Divider from '@material-ui/core/Divider'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import Tooltip from '@material-ui/core/Tooltip'; +import PersonAdd from '@material-ui/icons/PersonAdd'; +import Settings from '@material-ui/icons/Settings'; +import Logout from '@material-ui/icons/Logout'; + +export default function AccountMenu() { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return ( + + + Contact + Profile + + + M + + + + + + Profile + + + My account + + + + + + + Add another account + + + + + + Settings + + + + + + Logout + + + + ); +} diff --git a/docs/src/pages/components/menus/AccountMenu.tsx b/docs/src/pages/components/menus/AccountMenu.tsx new file mode 100644 index 00000000000000..f4d01f670800b0 --- /dev/null +++ b/docs/src/pages/components/menus/AccountMenu.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import Box from '@material-ui/core/Box'; +import Avatar from '@material-ui/core/Avatar'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Divider from '@material-ui/core/Divider'; +import IconButton from '@material-ui/core/IconButton'; +import Typography from '@material-ui/core/Typography'; +import Tooltip from '@material-ui/core/Tooltip'; +import PersonAdd from '@material-ui/icons/PersonAdd'; +import Settings from '@material-ui/icons/Settings'; +import Logout from '@material-ui/icons/Logout'; + +export default function AccountMenu() { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + return ( + + + Contact + Profile + + + M + + + + + + Profile + + + My account + + + + + + + Add another account + + + + + + Settings + + + + + + Logout + + + + ); +} diff --git a/docs/src/pages/components/menus/CustomizedMenus.js b/docs/src/pages/components/menus/CustomizedMenus.js index b4e7be1759ca2a..e1fca61c861d45 100644 --- a/docs/src/pages/components/menus/CustomizedMenus.js +++ b/docs/src/pages/components/menus/CustomizedMenus.js @@ -32,11 +32,10 @@ const StyledMenu = styled((props) => ( theme.palette.mode === 'light' ? 'rgb(55, 65, 81)' : theme.palette.grey[300], boxShadow: 'rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px', - '& .MuiList-root': { + '& .MuiMenu-list': { padding: '4px 0', }, - '& .MuiListItem-root': { - ...theme.typography.body1, + '& .MuiMenuItem-root': { '& .MuiSvgIcon-root': { fontSize: 18, color: theme.palette.text.secondary, diff --git a/docs/src/pages/components/menus/CustomizedMenus.tsx b/docs/src/pages/components/menus/CustomizedMenus.tsx index 9fea958d73cdc4..9bfcd2030aa7cf 100644 --- a/docs/src/pages/components/menus/CustomizedMenus.tsx +++ b/docs/src/pages/components/menus/CustomizedMenus.tsx @@ -32,11 +32,10 @@ const StyledMenu = styled((props: MenuProps) => ( theme.palette.mode === 'light' ? 'rgb(55, 65, 81)' : theme.palette.grey[300], boxShadow: 'rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px', - '& .MuiList-root': { + '& .MuiMenu-list': { padding: '4px 0', }, - '& .MuiListItem-root': { - ...theme.typography.body1, + '& .MuiMenuItem-root': { '& .MuiSvgIcon-root': { fontSize: 18, color: theme.palette.text.secondary, diff --git a/docs/src/pages/components/menus/DenseMenu.js b/docs/src/pages/components/menus/DenseMenu.js new file mode 100644 index 00000000000000..41643f16cb39ec --- /dev/null +++ b/docs/src/pages/components/menus/DenseMenu.js @@ -0,0 +1,43 @@ +import * as React from 'react'; +import Paper from '@material-ui/core/Paper'; +import Divider from '@material-ui/core/Divider'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Check from '@material-ui/icons/Check'; + +export default function DenseMenu() { + return ( + + + + Single + + + 1.15 + + + Double + + + + + + Custom: 1.2 + + + + Add space before paragraph + + + Add space after paragraph + + + + Custom spacing... + + + + ); +} diff --git a/docs/src/pages/components/menus/DenseMenu.tsx b/docs/src/pages/components/menus/DenseMenu.tsx new file mode 100644 index 00000000000000..41643f16cb39ec --- /dev/null +++ b/docs/src/pages/components/menus/DenseMenu.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import Paper from '@material-ui/core/Paper'; +import Divider from '@material-ui/core/Divider'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Check from '@material-ui/icons/Check'; + +export default function DenseMenu() { + return ( + + + + Single + + + 1.15 + + + Double + + + + + + Custom: 1.2 + + + + Add space before paragraph + + + Add space after paragraph + + + + Custom spacing... + + + + ); +} diff --git a/docs/src/pages/components/menus/IconMenu.js b/docs/src/pages/components/menus/IconMenu.js new file mode 100644 index 00000000000000..d20f15849ce337 --- /dev/null +++ b/docs/src/pages/components/menus/IconMenu.js @@ -0,0 +1,55 @@ +import * as React from 'react'; +import Divider from '@material-ui/core/Divider'; +import Paper from '@material-ui/core/Paper'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Typography from '@material-ui/core/Typography'; +import ContentCut from '@material-ui/icons/ContentCut'; +import ContentCopy from '@material-ui/icons/ContentCopy'; +import ContentPaste from '@material-ui/icons/ContentPaste'; +import Cloud from '@material-ui/icons/Cloud'; + +export default function IconMenu() { + return ( + + + + + + + Cut + + ⌘X + + + + + + + Copy + + ⌘C + + + + + + + Paste + + ⌘V + + + + + + + + Web Clipboard + + + + ); +} diff --git a/docs/src/pages/components/menus/IconMenu.tsx b/docs/src/pages/components/menus/IconMenu.tsx new file mode 100644 index 00000000000000..d20f15849ce337 --- /dev/null +++ b/docs/src/pages/components/menus/IconMenu.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import Divider from '@material-ui/core/Divider'; +import Paper from '@material-ui/core/Paper'; +import MenuList from '@material-ui/core/MenuList'; +import MenuItem from '@material-ui/core/MenuItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import Typography from '@material-ui/core/Typography'; +import ContentCut from '@material-ui/icons/ContentCut'; +import ContentCopy from '@material-ui/icons/ContentCopy'; +import ContentPaste from '@material-ui/icons/ContentPaste'; +import Cloud from '@material-ui/icons/Cloud'; + +export default function IconMenu() { + return ( + + + + + + + Cut + + ⌘X + + + + + + + Copy + + ⌘C + + + + + + + Paste + + ⌘V + + + + + + + + Web Clipboard + + + + ); +} diff --git a/docs/src/pages/components/menus/PositionedMenu.js b/docs/src/pages/components/menus/PositionedMenu.js index 38ccd186946817..4cd9c813f5bc29 100644 --- a/docs/src/pages/components/menus/PositionedMenu.js +++ b/docs/src/pages/components/menus/PositionedMenu.js @@ -31,7 +31,7 @@ export default function PositionedMenu() { open={open} onClose={handleClose} anchorOrigin={{ - vertical: 'bottom', + vertical: 'top', horizontal: 'left', }} transformOrigin={{ diff --git a/docs/src/pages/components/menus/PositionedMenu.tsx b/docs/src/pages/components/menus/PositionedMenu.tsx index 251f9dbddbc253..44c044668f6457 100644 --- a/docs/src/pages/components/menus/PositionedMenu.tsx +++ b/docs/src/pages/components/menus/PositionedMenu.tsx @@ -31,7 +31,7 @@ export default function PositionedMenu() { open={open} onClose={handleClose} anchorOrigin={{ - vertical: 'bottom', + vertical: 'top', horizontal: 'left', }} transformOrigin={{ diff --git a/docs/src/pages/components/menus/menus.md b/docs/src/pages/components/menus/menus.md index f9f3967c3e282b..6e1f07908416ba 100644 --- a/docs/src/pages/components/menus/menus.md +++ b/docs/src/pages/components/menus/menus.md @@ -24,6 +24,18 @@ Choosing an option should immediately ideally commit the option and close the me {{"demo": "pages/components/menus/BasicMenu.js"}} +## Icon menu + +In desktop viewport, padding is increased to give more space to the menu. + +{{"demo": "pages/components/menus/IconMenu.js", "bg": true}} + +## Dense menu + +For the menu that has long list and long text, you can use the `dense` prop to reduce the padding (this property only affects desktop viewport). + +{{"demo": "pages/components/menus/DenseMenu.js", "bg": true}} + ## Selected menu If used for item selection, when opened, simple menus places the initial focus on the selected menu item. @@ -35,7 +47,7 @@ To use a selected menu item without impacting the initial focus, set the `varian ## Positioned menu Because the `Menu` component uses the `Popover` component to position itself, you can use the same [positioning props](/components/popover/#anchor-playground) to position it. -For instance, you can display the menu below the anchor: +For instance, you can display the menu on top of the anchor: {{"demo": "pages/components/menus/PositionedMenu.js"}} @@ -49,6 +61,12 @@ The primary responsibility of the `MenuList` component is to handle the focus. {{"demo": "pages/components/menus/MenuListComposition.js", "bg": true}} +## Account menu + +`Menu` content can be mixed with other components like `Avatar`. + +{{"demo": "pages/components/menus/AccountMenu.js"}} + ## Customized menu Here is an example of customizing the component. You can learn more about this in the diff --git a/docs/src/pages/guides/migration-v4/migration-v4.md b/docs/src/pages/guides/migration-v4/migration-v4.md index 23e6efbc6481b0..b66b55e80d8a06 100644 --- a/docs/src/pages/guides/migration-v4/migration-v4.md +++ b/docs/src/pages/guides/migration-v4/migration-v4.md @@ -1004,6 +1004,36 @@ You can use the [`collapse-rename-collapsedheight` codemod](https://github.com/m You can use the [`use-transitionprops` codemod](https://github.com/mui-org/material-ui/tree/HEAD/packages/material-ui-codemod#use-transitionprops) for automatic migration. +- Change the default value of `anchorOrigin.vertical` to follow the Material Design guidelines. The menu now displays below the anchor instead of on top of it. + You can restore the previous behavior with: + + ```diff + + +
  • + ``` + +- prop `listItemClasses` is removed, use `classes` instead. + + ```diff + - + + + ``` + + Read more about [MenuItem CSS API](/api/menu-item/#css) + ### Modal - Remove the `disableBackdropClick` prop because it is redundant. diff --git a/docs/translations/api-docs/menu-item/menu-item.json b/docs/translations/api-docs/menu-item/menu-item.json index 8bd0c94914f53a..e1c8ac149b3a82 100644 --- a/docs/translations/api-docs/menu-item/menu-item.json +++ b/docs/translations/api-docs/menu-item/menu-item.json @@ -1,30 +1,47 @@ { "componentDescription": "", "propDescriptions": { + "autoFocus": "If true, the list item is focused during the first mount. Focus will also be triggered if the value changes from false to true.", "children": "The content of the component.", "classes": "Override or extend the styles applied to the component. See CSS API below for more details.", "component": "The component used for the root node. Either a string to use a HTML element or a component.", - "dense": "If true, compact vertical padding designed for keyboard and mouse input is used. The prop defaults to the value inherited from the parent List component.", + "dense": "If true, compact vertical padding designed for keyboard and mouse input is used. The prop defaults to the value inherited from the parent Menu component.", "disableGutters": "If true, the left and right padding is removed.", - "ListItemClasses": "classes prop applied to the ListItem element.", + "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.", "sx": "The system prop that allows defining system overrides as well as additional CSS styles. See the `sx` page for more details." }, "classDescriptions": { "root": { "description": "Styles applied to the root element." }, - "gutters": { - "description": "Styles applied to {{nodeName}} unless {{conditions}}.", + "focusVisible": { + "description": "Pseudo-class applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", - "conditions": "disableGutters={true}" + "conditions": "keyboard focused" }, - "selected": { + "dense": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", - "conditions": "selected={true}" + "conditions": "dense" }, - "dense": { + "disabled": { + "description": "Pseudo-class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "disabled={true}" + }, + "divider": { "description": "Styles applied to {{nodeName}} if {{conditions}}.", "nodeName": "the root element", - "conditions": "dense" + "conditions": "divider={true}" + }, + "gutters": { + "description": "Styles applied to {{nodeName}} unless {{conditions}}.", + "nodeName": "the inner `component` element", + "conditions": "disableGutters={true}" + }, + "selected": { + "description": "Pseudo-class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "selected={true}" } } } diff --git a/packages/material-ui/src/ListItemButton/ListItemButton.d.ts b/packages/material-ui/src/ListItemButton/ListItemButton.d.ts index 5996855958dbe6..a452f58e486fc3 100644 --- a/packages/material-ui/src/ListItemButton/ListItemButton.d.ts +++ b/packages/material-ui/src/ListItemButton/ListItemButton.d.ts @@ -58,11 +58,13 @@ interface ListItemButtonBaseProps { sx?: SxProps; } -export type ListItemButtonTypeMap

    = - ExtendButtonBaseTypeMap<{ - props: P & ListItemButtonBaseProps; - defaultComponent: D; - }>; +export type ListItemButtonTypeMap< + P = {}, + D extends React.ElementType = 'div', +> = ExtendButtonBaseTypeMap<{ + props: P & ListItemButtonBaseProps; + defaultComponent: D; +}>; /** * @@ -73,6 +75,7 @@ export type ListItemButtonTypeMap

    = * API: * * - [ListItemButton API](https://material-ui.com/api/list-item-button/) + * - inherits [ButtonBase API](https://material-ui.com/api/button-base/) */ declare const ListItemButton: ExtendButtonBase; diff --git a/packages/material-ui/src/ListItemButton/ListItemButton.js b/packages/material-ui/src/ListItemButton/ListItemButton.js index ddeaa134cbb611..1818b244e10425 100644 --- a/packages/material-ui/src/ListItemButton/ListItemButton.js +++ b/packages/material-ui/src/ListItemButton/ListItemButton.js @@ -11,7 +11,7 @@ import useForkRef from '../utils/useForkRef'; import ListContext from '../List/ListContext'; import listItemButtonClasses, { getListItemButtonUtilityClass } from './listItemButtonClasses'; -const overridesResolver = (props, styles) => { +export const overridesResolver = (props, styles) => { const { styleProps } = props; return { @@ -144,7 +144,7 @@ const ListItemButton = React.forwardRef(function ListItemButton(inProps, ref) { listItemRef.current.focus(); } else if (process.env.NODE_ENV !== 'production') { console.error( - 'Material-UI: Unable to set focus to a ListItem whose component has not been rendered.', + 'Material-UI: Unable to set focus to a ListItemButton whose component has not been rendered.', ); } } diff --git a/packages/material-ui/src/ListItemButton/ListItemButton.test.js b/packages/material-ui/src/ListItemButton/ListItemButton.test.js index 6e7161de3db096..2d3d530bdba876 100644 --- a/packages/material-ui/src/ListItemButton/ListItemButton.test.js +++ b/packages/material-ui/src/ListItemButton/ListItemButton.test.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { createMount, describeConformanceV5, act, createClientRender, fireEvent } from 'test/utils'; import ListItemButton, { listItemButtonClasses as classes } from '@material-ui/core/ListItemButton'; +import ButtonBase from '@material-ui/core/ButtonBase'; import ListContext from '../List/ListContext'; describe('', () => { @@ -10,10 +11,11 @@ describe('', () => { describeConformanceV5(, () => ({ classes, - inheritComponent: 'div', + inheritComponent: ButtonBase, render, mount, refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'a', muiName: 'MuiListItemButton', testVariantProps: { dense: true }, skip: ['componentsProp'], diff --git a/packages/material-ui/src/Menu/Menu.js b/packages/material-ui/src/Menu/Menu.js index 77e3510f7c28a3..805eda4eb677b0 100644 --- a/packages/material-ui/src/Menu/Menu.js +++ b/packages/material-ui/src/Menu/Menu.js @@ -157,7 +157,10 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { { - props: P & - DistributiveOmit['props'], 'children' | 'classes'> & { - /** - * The content of the component. - */ - children?: React.ReactNode; - /** - * Override or extend the styles applied to the component. - */ - classes?: Partial; - /** - * `classes` prop applied to the [`ListItem`](/api/list-item/) element. - */ - ListItemClasses?: ListItemProps['classes']; - /** - * The system prop that allows defining system overrides as well as additional CSS styles. - */ - sx?: SxProps; - }; +export type MenuItemTypeMap

    = ExtendButtonBaseTypeMap<{ + props: P & { + /** + * If `true`, the list item is focused during the first mount. + * Focus will also be triggered if the value changes from false to true. + * @default false + */ + autoFocus?: boolean; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial; + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * The prop defaults to the value inherited from the parent Menu component. + * @default false + */ + dense?: boolean; + /** + * If `true`, the component is disabled. + * @default false + */ + disabled?: boolean; + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters?: boolean; + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider?: boolean; + /** + * Use to apply selected styling. + * @default false + */ + selected?: boolean; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps; + }; defaultComponent: D; -} +}>; /** * @@ -38,12 +59,9 @@ export interface MenuItemTypeMap

    { * API: * * - [MenuItem API](https://material-ui.com/api/menu-item/) - * - inherits [ListItem API](https://material-ui.com/api/list-item/) + * - inherits [ButtonBase API](https://material-ui.com/api/button-base/) */ -declare const MenuItem: OverridableComponent< - MenuItemTypeMap<{ button: false }, MenuItemTypeMap['defaultComponent']> -> & - ExtendButtonBase>; +declare const MenuItem: ExtendButtonBase; export type MenuItemProps< D extends React.ElementType = MenuItemTypeMap['defaultComponent'], diff --git a/packages/material-ui/src/MenuItem/MenuItem.js b/packages/material-ui/src/MenuItem/MenuItem.js index 4bb1706f6baf2c..4c0ff33ef3ff85 100644 --- a/packages/material-ui/src/MenuItem/MenuItem.js +++ b/packages/material-ui/src/MenuItem/MenuItem.js @@ -2,87 +2,201 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { unstable_composeClasses as composeClasses } from '@material-ui/unstyled'; +import { alpha } from '@material-ui/system'; import styled, { rootShouldForwardProp } from '../styles/styled'; import useThemeProps from '../styles/useThemeProps'; -import { getMenuItemUtilityClass } from './menuItemClasses'; -import ListItem from '../ListItem'; -import { overridesResolver as listItemOverridesResolver, ListItemRoot } from '../ListItem/ListItem'; +import ListContext from '../List/ListContext'; +import ButtonBase from '../ButtonBase'; +import useEnhancedEffect from '../utils/useEnhancedEffect'; +import useForkRef from '../utils/useForkRef'; +import { dividerClasses } from '../Divider'; +import { listItemIconClasses } from '../ListItemIcon'; +import { listItemTextClasses } from '../ListItemText'; +import menuItemClasses, { getMenuItemUtilityClass } from './menuItemClasses'; + +export const overridesResolver = (props, styles) => { + const { styleProps } = props; + + return { + ...styles.root, + ...(styleProps.dense && styles.dense), + ...(styleProps.divider && styles.divider), + ...(!styleProps.disableGutters && styles.gutters), + }; +}; const useUtilityClasses = (styleProps) => { - const { selected, dense, classes } = styleProps; + const { disabled, dense, divider, disableGutters, selected, classes } = styleProps; const slots = { - root: ['root', selected && 'selected', dense && 'dense'], + root: [ + 'root', + dense && 'dense', + disabled && 'disabled', + !disableGutters && 'gutters', + divider && 'divider', + selected && 'selected', + ], }; - return composeClasses(slots, getMenuItemUtilityClass, classes); + const composedClasses = composeClasses(slots, getMenuItemUtilityClass, classes); + + return { + ...classes, + ...composedClasses, + }; }; -const MenuItemRoot = styled(ListItemRoot, { +const MenuItemRoot = styled(ButtonBase, { shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes', name: 'MuiMenuItem', slot: 'Root', - overridesResolver: (props, styles) => { - const { styleProps } = props; - return { - ...listItemOverridesResolver(props, styles), - ...(styleProps.dense && styles.dense), - }; - }, + overridesResolver, })(({ theme, styleProps }) => ({ ...theme.typography.body1, + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + position: 'relative', + textDecoration: 'none', minHeight: 48, paddingTop: 6, paddingBottom: 6, boxSizing: 'border-box', - width: 'auto', whiteSpace: 'nowrap', - [theme.breakpoints.up('sm')]: { - minHeight: 'auto', + ...(!styleProps.disableGutters && { + paddingLeft: 16, + paddingRight: 16, + }), + ...(styleProps.divider && { + borderBottom: `1px solid ${theme.palette.divider}`, + backgroundClip: 'padding-box', + }), + '&:hover': { + textDecoration: 'none', + backgroundColor: theme.palette.action.hover, + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + [`&.${menuItemClasses.selected}`]: { + backgroundColor: alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity), + [`&.${menuItemClasses.focusVisible}`]: { + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity + theme.palette.action.focusOpacity, + ), + }, + }, + [`&.${menuItemClasses.selected}:hover`]: { + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity + theme.palette.action.hoverOpacity, + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: alpha(theme.palette.primary.main, theme.palette.action.selectedOpacity), + }, + }, + [`&.${menuItemClasses.focusVisible}`]: { + backgroundColor: theme.palette.action.focus, + }, + [`&.${menuItemClasses.disabled}`]: { + opacity: theme.palette.action.disabledOpacity, + }, + [`& + .${dividerClasses.root}`]: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + [`& + .${dividerClasses.inset}`]: { + marginLeft: 52, + }, + [`& .${listItemTextClasses.root}`]: { + marginTop: 0, + marginBottom: 0, + }, + [`& .${listItemTextClasses.inset}`]: { + paddingLeft: 36, }, + [`& .${listItemIconClasses.root}`]: { + minWidth: 36, + }, + ...(!styleProps.dense && { + [theme.breakpoints.up('sm')]: { + minHeight: 'auto', + }, + }), ...(styleProps.dense && { + minHeight: 36, ...theme.typography.body2, - minHeight: 'auto', + [`& .${listItemIconClasses.root} svg`]: { + fontSize: '1.25rem', + }, }), })); const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { const props = useThemeProps({ props: inProps, name: 'MuiMenuItem' }); const { - className, + autoFocus = false, component = 'li', dense = false, + divider = false, disableGutters = false, - ListItemClasses, + focusVisibleClassName, role = 'menuitem', - selected, tabIndex: tabIndexProp, ...other } = props; - const styleProps = { dense }; + const context = React.useContext(ListContext); + const childContext = { + dense: dense || context.dense || false, + disableGutters, + }; + + const menuItemRef = React.useRef(null); + useEnhancedEffect(() => { + if (autoFocus) { + if (menuItemRef.current) { + menuItemRef.current.focus(); + } else if (process.env.NODE_ENV !== 'production') { + console.error( + 'Material-UI: Unable to set focus to a MenuItem whose component has not been rendered.', + ); + } + } + }, [autoFocus]); + + const styleProps = { + ...props, + dense: childContext.dense, + divider, + disableGutters, + }; const classes = useUtilityClasses(props); + const handleRef = useForkRef(menuItemRef, ref); + let tabIndex; if (!props.disabled) { tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1; } return ( - + + + ); }); @@ -92,9 +206,11 @@ MenuItem.propTypes /* remove-proptypes */ = { // | To update them edit the d.ts file and run "yarn proptypes" | // ---------------------------------------------------------------------- /** - * @ignore + * If `true`, the list item is focused during the first mount. + * Focus will also be triggered if the value changes from false to true. + * @default false */ - button: PropTypes.bool, + autoFocus: PropTypes.bool, /** * The content of the component. */ @@ -103,10 +219,6 @@ MenuItem.propTypes /* remove-proptypes */ = { * Override or extend the styles applied to the component. */ classes: PropTypes.object, - /** - * @ignore - */ - className: PropTypes.string, /** * The component used for the root node. * Either a string to use a HTML element or a component. @@ -114,7 +226,7 @@ MenuItem.propTypes /* remove-proptypes */ = { component: PropTypes.elementType, /** * If `true`, compact vertical padding designed for keyboard and mouse input is used. - * The prop defaults to the value inherited from the parent List component. + * The prop defaults to the value inherited from the parent Menu component. * @default false */ dense: PropTypes.bool, @@ -128,9 +240,19 @@ MenuItem.propTypes /* remove-proptypes */ = { */ disableGutters: PropTypes.bool, /** - * `classes` prop applied to the [`ListItem`](/api/list-item/) element. + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * 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](https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo). + * The rationale for using this feature [is explained here](https://github.com/WICG/focus-visible/blob/master/explainer.md). + * A [polyfill can be used](https://github.com/WICG/focus-visible) to apply a `focus-visible` class to other components + * if needed. */ - ListItemClasses: PropTypes.object, + focusVisibleClassName: PropTypes.string, /** * @ignore */ @@ -144,7 +266,7 @@ MenuItem.propTypes /* remove-proptypes */ = { */ sx: PropTypes.object, /** - * @ignore + * @default 0 */ tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), }; diff --git a/packages/material-ui/src/MenuItem/MenuItem.test.js b/packages/material-ui/src/MenuItem/MenuItem.test.js index 601dd1f64c98b5..5d281e29c82dd2 100644 --- a/packages/material-ui/src/MenuItem/MenuItem.test.js +++ b/packages/material-ui/src/MenuItem/MenuItem.test.js @@ -1,4 +1,3 @@ -// @ts-check import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; @@ -10,8 +9,8 @@ import { screen, } from 'test/utils'; import MenuItem, { menuItemClasses as classes } from '@material-ui/core/MenuItem'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import ButtonBase from '@material-ui/core/ButtonBase'; +import ListContext from '../List/ListContext'; describe('', () => { const render = createClientRender(); @@ -19,13 +18,13 @@ describe('', () => { describeConformanceV5(, () => ({ classes, - inheritComponent: ListItem, + inheritComponent: ButtonBase, render, mount, refInstanceof: window.HTMLLIElement, testComponentPropWith: 'a', muiName: 'MuiMenuItem', - testVariantProps: { disableGutters: true }, + testVariantProps: { dense: true }, skip: ['componentsProp'], })); @@ -127,31 +126,6 @@ describe('', () => { }); }); - // Regression test for #10452. - // Kept for backwards compatibility. - // In the future we should have a better pattern for this UI. - it('should not fail with a li > li error message', () => { - const { rerender } = render( - - -

    - - , - ); - - expect(document.querySelectorAll('li')).to.have.length(1); - - rerender( - - -
    - - , - ); - - expect(document.querySelectorAll('li')).to.have.length(1); - }); - it('can be disabled', () => { render(); const menuitem = screen.getByRole('menuitem'); @@ -159,22 +133,39 @@ describe('', () => { expect(menuitem).to.have.attribute('aria-disabled', 'true'); }); - describe('prop: ListItemClasses', () => { - it('should be able to change the style of ListItem', () => { - render( - , - ); - const menuitem = screen.getByRole('menuitem'); + it('can be selected', () => { + render(); + const menuitem = screen.getByRole('menuitem'); - expect(menuitem).not.to.have.class('foo'); - expect(menuitem).to.have.class('bar'); + expect(menuitem).to.have.class(classes.selected); + }); + + it('prop: disableGutters', () => { + const { rerender } = render(); + const menuitem = screen.getByRole('menuitem'); + + expect(menuitem).to.have.class(classes.gutters); + + rerender(); + + expect(menuitem).not.to.have.class(classes.gutters); + }); + + describe('context: dense', () => { + it('should forward the context', () => { + let context = null; + const { setProps } = render( + + + {(options) => { + context = options; + }} + + , + ); + expect(context).to.have.property('dense', false); + setProps({ dense: true }); + expect(context).to.have.property('dense', true); }); }); }); diff --git a/packages/material-ui/src/MenuItem/menuItemClasses.ts b/packages/material-ui/src/MenuItem/menuItemClasses.ts index ae4b7897b85e9b..02f5512f3dcb22 100644 --- a/packages/material-ui/src/MenuItem/menuItemClasses.ts +++ b/packages/material-ui/src/MenuItem/menuItemClasses.ts @@ -3,12 +3,18 @@ import { generateUtilityClass, generateUtilityClasses } from '@material-ui/unsty export interface MenuItemClasses { /** Styles applied to the root element. */ root: string; - /** Styles applied to the root element unless `disableGutters={true}`. */ - gutters: string; - /** Styles applied to the root element if `selected={true}`. */ - selected: string; + /** Pseudo-class applied to the root element if keyboard focused. */ + focusVisible: string; /** Styles applied to the root element if dense. */ dense: string; + /** Pseudo-class applied to the root element if `disabled={true}`. */ + disabled: string; + /** Styles applied to the root element if `divider={true}`. */ + divider: string; + /** Styles applied to the inner `component` element unless `disableGutters={true}`. */ + gutters: string; + /** Pseudo-class applied to the root element if `selected={true}`. */ + selected: string; } export type MenuItemClassKey = keyof MenuItemClasses; @@ -19,9 +25,12 @@ export function getMenuItemUtilityClass(slot: string): string { const menuItemClasses: MenuItemClasses = generateUtilityClasses('MuiMenuItem', [ 'root', + 'focusVisible', + 'dense', + 'disabled', + 'divider', 'gutters', 'selected', - 'dense', ]); export default menuItemClasses; diff --git a/packages/material-ui/test/typescript/components.spec.tsx b/packages/material-ui/test/typescript/components.spec.tsx index 2eb1e0d397f881..e427e47edbcb7a 100644 --- a/packages/material-ui/test/typescript/components.spec.tsx +++ b/packages/material-ui/test/typescript/components.spec.tsx @@ -600,7 +600,6 @@ const MenuTest = () => { Link Item { expectType(elem); }} @@ -609,8 +608,6 @@ const MenuTest = () => { action={(action) => { buttonActionRef.current = action; }} - // @ts-expect-error 'false' is not assignable to true | undefined - button={false} ref={(elem) => { // inferred from `button={false}` instead of `action` expectType(elem); diff --git a/test/regressions/fixtures/Menu/MenuContentAnchors.js b/test/regressions/fixtures/Menu/MenuContentAnchors.js index 30dfb8ea593528..52eed220a76c1b 100644 --- a/test/regressions/fixtures/Menu/MenuContentAnchors.js +++ b/test/regressions/fixtures/Menu/MenuContentAnchors.js @@ -46,13 +46,13 @@ function SimpleMenu({ selectedItem, ...props }) { {null} - + Item 1 - + Item 2 - Item 3 + Item 3 );