diff --git a/docs/data/base/components/menu/MenuSimple.js b/docs/data/base/components/menu/MenuSimple.js new file mode 100644 index 00000000000000..e9ab28ff73f63a --- /dev/null +++ b/docs/data/base/components/menu/MenuSimple.js @@ -0,0 +1,181 @@ +import * as React from 'react'; +import MenuUnstyled from '@mui/base/MenuUnstyled'; +import MenuItemUnstyled, { + menuItemUnstyledClasses, +} from '@mui/base/MenuItemUnstyled'; +import { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const blue = { + 100: '#DAECFF', + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 900: '#003A75', +}; + +const grey = { + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledListbox = styled('ul')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 5px; + margin: 10px 0; + min-width: 200px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + overflow: auto; + outline: 0px; + `, +); + +const StyledMenuItem = styled(MenuItemUnstyled)( + ({ theme }) => ` + list-style: none; + padding: 8px; + border-radius: 0.45em; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${menuItemUnstyledClasses.focusVisible} { + 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]}; + } + + &.${menuItemUnstyledClasses.disabled} { + color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &:hover:not(.${menuItemUnstyledClasses.disabled}) { + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + } + `, +); + +const TriggerButton = styled('button')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + &:hover { + background: ${theme.palette.mode === 'dark' ? '' : grey[100]}; + border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &.${buttonUnstyledClasses.focusVisible} { + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[100]}; + } + + &::after { + content: '▾'; + float: right; + } + `, +); + +const Popper = styled(PopperUnstyled)` + z-index: 1; +`; + +export default function UnstyledMenuSimple() { + const [anchorEl, setAnchorEl] = React.useState(null); + const isOpen = Boolean(anchorEl); + const buttonRef = React.useRef(null); + const menuActions = React.useRef(null); + + const handleButtonClick = (event) => { + if (isOpen) { + setAnchorEl(null); + } else { + setAnchorEl(event.currentTarget); + } + }; + + const handleButtonKeyDown = (event) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setAnchorEl(event.currentTarget); + if (event.key === 'ArrowUp') { + menuActions.current?.highlightLastItem(); + } + } + }; + + const close = () => { + setAnchorEl(null); + buttonRef.current.focus(); + }; + + const createHandleMenuClick = (menuItem) => { + return () => { + // eslint-disable-next-line no-console + console.log(`Clicked on ${menuItem}`); + close(); + }; + }; + + return ( +
+ + Language + + + + + English + + 中文 + + Português + + +
+ ); +} diff --git a/docs/data/base/components/menu/MenuSimple.tsx b/docs/data/base/components/menu/MenuSimple.tsx new file mode 100644 index 00000000000000..2cef1b44d30f84 --- /dev/null +++ b/docs/data/base/components/menu/MenuSimple.tsx @@ -0,0 +1,181 @@ +import * as React from 'react'; +import MenuUnstyled, { MenuUnstyledActions } from '@mui/base/MenuUnstyled'; +import MenuItemUnstyled, { + menuItemUnstyledClasses, +} from '@mui/base/MenuItemUnstyled'; +import { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const blue = { + 100: '#DAECFF', + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 900: '#003A75', +}; + +const grey = { + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledListbox = styled('ul')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 5px; + margin: 10px 0; + min-width: 200px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + overflow: auto; + outline: 0px; + `, +); + +const StyledMenuItem = styled(MenuItemUnstyled)( + ({ theme }) => ` + list-style: none; + padding: 8px; + border-radius: 0.45em; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${menuItemUnstyledClasses.focusVisible} { + 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]}; + } + + &.${menuItemUnstyledClasses.disabled} { + color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &:hover:not(.${menuItemUnstyledClasses.disabled}) { + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + } + `, +); + +const TriggerButton = styled('button')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + &:hover { + background: ${theme.palette.mode === 'dark' ? '' : grey[100]}; + border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &.${buttonUnstyledClasses.focusVisible} { + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[100]}; + } + + &::after { + content: '▾'; + float: right; + } + `, +); + +const Popper = styled(PopperUnstyled)` + z-index: 1; +`; + +export default function UnstyledMenuSimple() { + const [anchorEl, setAnchorEl] = React.useState(null); + const isOpen = Boolean(anchorEl); + const buttonRef = React.useRef(null); + const menuActions = React.useRef(null); + + const handleButtonClick = (event: React.MouseEvent) => { + if (isOpen) { + setAnchorEl(null); + } else { + setAnchorEl(event.currentTarget); + } + }; + + const handleButtonKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setAnchorEl(event.currentTarget); + if (event.key === 'ArrowUp') { + menuActions.current?.highlightLastItem(); + } + } + }; + + const close = () => { + setAnchorEl(null); + buttonRef.current!.focus(); + }; + + const createHandleMenuClick = (menuItem: string) => { + return () => { + // eslint-disable-next-line no-console + console.log(`Clicked on ${menuItem}`); + close(); + }; + }; + + return ( +
+ + Language + + + + + English + + 中文 + + Português + + +
+ ); +} diff --git a/docs/data/base/components/menu/UseMenu.js b/docs/data/base/components/menu/UseMenu.js new file mode 100644 index 00000000000000..96c75dc51846a5 --- /dev/null +++ b/docs/data/base/components/menu/UseMenu.js @@ -0,0 +1,152 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { useMenu, MenuUnstyledContext } from '@mui/base/MenuUnstyled'; +import { useMenuItem } from '@mui/base/MenuItemUnstyled'; +import { GlobalStyles } from '@mui/system'; +import clsx from 'clsx'; + +const grey = { + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const styles = ` + .menu-root { + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 5px; + margin: 10px 0; + min-width: 200px; + background: #fff; + border: 1px solid ${grey[300]}; + border-radius: 0.75em; + color: ${grey[900]}; + overflow: auto; + outline: 0px; + } + + .mode-dark .menu-root { + background: ${grey[900]}; + border-color: ${grey[800]}; + color: ${grey[300]}; + } + + .menu-item { + list-style: none; + padding: 8px; + border-radius: 0.45em; + cursor: default; + } + + .menu-item:last-of-type { + border-bottom: none; + } + + .menu-item:focus { + background-color: ${grey[100]}; + color: ${grey[900]}; + outline: 0; + } + + .mode-dark .menu-item:focus { + background-color: ${grey[800]}; + color: ${grey[300]}; + } + + .menu-item.disabled { + color: ${grey[400]}; + } + + .mode-dark .menu-item.disabled { + color: ${grey[700]}; + } + + .menu-item:hover:not(.disabled) { + background-color: ${grey[100]}; + color: ${grey[900]}; + } + + .mode-dark .menu-item:hover:not(.disabled){ + background-color: ${grey[100]}; + color: ${grey[900]}; + } +`; + +const Menu = React.forwardRef(function Menu(props, ref) { + const { children, ...other } = props; + + const { + registerItem, + unregisterItem, + getListboxProps, + getItemProps, + getItemState, + } = useMenu({ + listboxRef: ref, + }); + + const contextValue = { + registerItem, + unregisterItem, + getItemState, + getItemProps, + open: true, + }; + + return ( +
    + + {children} + +
+ ); +}); + +Menu.propTypes = { + children: PropTypes.node, +}; + +const MenuItem = React.forwardRef(function MenuItem(props, ref) { + const { children, ...other } = props; + + const { getRootProps, itemState } = useMenuItem({ + component: 'li', + ref, + }); + + const classes = { + 'menu-item': true, + disabled: itemState?.disabled, + }; + + return ( +
  • + {children} +
  • + ); +}); + +MenuItem.propTypes = { + children: PropTypes.node, +}; + +export default function UseMenu() { + return ( + + + + Cut + Copy + Paste + + + ); +} diff --git a/docs/data/base/components/menu/UseMenu.tsx b/docs/data/base/components/menu/UseMenu.tsx new file mode 100644 index 00000000000000..b8cc0661036b27 --- /dev/null +++ b/docs/data/base/components/menu/UseMenu.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { + useMenu, + MenuUnstyledContext, + MenuUnstyledContextType, +} from '@mui/base/MenuUnstyled'; +import { useMenuItem } from '@mui/base/MenuItemUnstyled'; +import { GlobalStyles } from '@mui/system'; +import clsx from 'clsx'; + +const grey = { + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const styles = ` + .menu-root { + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 5px; + margin: 10px 0; + min-width: 200px; + background: #fff; + border: 1px solid ${grey[300]}; + border-radius: 0.75em; + color: ${grey[900]}; + overflow: auto; + outline: 0px; + } + + .mode-dark .menu-root { + background: ${grey[900]}; + border-color: ${grey[800]}; + color: ${grey[300]}; + } + + .menu-item { + list-style: none; + padding: 8px; + border-radius: 0.45em; + cursor: default; + } + + .menu-item:last-of-type { + border-bottom: none; + } + + .menu-item:focus { + background-color: ${grey[100]}; + color: ${grey[900]}; + outline: 0; + } + + .mode-dark .menu-item:focus { + background-color: ${grey[800]}; + color: ${grey[300]}; + } + + .menu-item.disabled { + color: ${grey[400]}; + } + + .mode-dark .menu-item.disabled { + color: ${grey[700]}; + } + + .menu-item:hover:not(.disabled) { + background-color: ${grey[100]}; + color: ${grey[900]}; + } + + .mode-dark .menu-item:hover:not(.disabled){ + background-color: ${grey[100]}; + color: ${grey[900]}; + } +`; + +const Menu = React.forwardRef(function Menu( + props: React.ComponentPropsWithoutRef<'ul'>, + ref: React.Ref, +) { + const { children, ...other } = props; + + const { + registerItem, + unregisterItem, + getListboxProps, + getItemProps, + getItemState, + } = useMenu({ + listboxRef: ref, + }); + + const contextValue: MenuUnstyledContextType = { + registerItem, + unregisterItem, + getItemState, + getItemProps, + open: true, + }; + + return ( +
      + + {children} + +
    + ); +}); + +const MenuItem = React.forwardRef(function MenuItem( + props: React.ComponentPropsWithoutRef<'li'>, + ref: React.Ref, +) { + const { children, ...other } = props; + + const { getRootProps, itemState } = useMenuItem({ + component: 'li', + ref, + }); + + const classes = { + 'menu-item': true, + disabled: itemState?.disabled, + }; + + return ( +
  • + {children} +
  • + ); +}); + +export default function UseMenu() { + return ( + + + + Cut + Copy + Paste + + + ); +} diff --git a/docs/data/base/components/menu/UseMenu.tsx.preview b/docs/data/base/components/menu/UseMenu.tsx.preview new file mode 100644 index 00000000000000..eaf8ac1dfb8966 --- /dev/null +++ b/docs/data/base/components/menu/UseMenu.tsx.preview @@ -0,0 +1,8 @@ + + + + Cut + Copy + Paste + + \ No newline at end of file diff --git a/docs/data/base/components/menu/WrappedMenuItems.js b/docs/data/base/components/menu/WrappedMenuItems.js new file mode 100644 index 00000000000000..7da6bee5e88c11 --- /dev/null +++ b/docs/data/base/components/menu/WrappedMenuItems.js @@ -0,0 +1,238 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import MenuUnstyled from '@mui/base/MenuUnstyled'; +import MenuItemUnstyled, { + menuItemUnstyledClasses, +} from '@mui/base/MenuItemUnstyled'; +import { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const blue = { + 100: '#DAECFF', + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 900: '#003A75', +}; + +const grey = { + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledListbox = styled('ul')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 5px; + margin: 10px 0; + min-width: 220px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + overflow: auto; + outline: 0px; + + & .helper { + padding: 10px; + } +`, +); + +const StyledMenuItem = styled(MenuItemUnstyled)( + ({ theme }) => ` + list-style: none; + padding: 8px; + border-radius: 0.45em; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${menuItemUnstyledClasses.focusVisible} { + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + outline: 0; + } + + &.${menuItemUnstyledClasses.disabled} { + color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &:hover:not(.${menuItemUnstyledClasses.disabled}) { + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + } + `, +); + +const TriggerButton = styled('button')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + &:hover { + background: ${theme.palette.mode === 'dark' ? '' : grey[100]}; + border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &.${buttonUnstyledClasses.focusVisible} { + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[100]}; + } + + &::after { + content: '▾'; + float: right; + } + `, +); + +const Popper = styled(PopperUnstyled)` + z-index: 1; +`; + +const MenuSectionRoot = styled('li')` + list-style: none; + + & > ul { + padding-left: 1em; + } +`; + +const MenuSectionLabel = styled('span')` + display: block; + padding: 10px 0 5px 10px; + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05rem; + color: ${grey[600]}; +`; + +function MenuSection({ children, label }) { + return ( + + {label} +
      {children}
    +
    + ); +} + +MenuSection.propTypes = { + children: PropTypes.node, + label: PropTypes.string.isRequired, +}; + +export default function WrappedMenuItems() { + const [anchorEl, setAnchorEl] = React.useState(null); + const isOpen = Boolean(anchorEl); + const buttonRef = React.useRef(null); + const menuActions = React.useRef(null); + + const handleButtonClick = (event) => { + if (isOpen) { + setAnchorEl(null); + } else { + setAnchorEl(event.currentTarget); + } + }; + + const handleButtonKeyDown = (event) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setAnchorEl(event.currentTarget); + if (event.key === 'ArrowUp') { + menuActions.current?.highlightLastItem(); + } + } + }; + + const close = () => { + setAnchorEl(null); + buttonRef.current.focus(); + }; + + const createHandleMenuClick = (menuItem) => { + return () => { + // eslint-disable-next-line no-console + console.log(`Clicked on ${menuItem}`); + close(); + }; + }; + + return ( +
    + + Options + + + + + Back + + + Forward + + + Refresh + + + + + Save as... + + + Print... + + + + + Zoom in + + + Zoom out + + +
  • Current zoom level: 100%
  • +
    +
    + ); +} diff --git a/docs/data/base/components/menu/WrappedMenuItems.tsx b/docs/data/base/components/menu/WrappedMenuItems.tsx new file mode 100644 index 00000000000000..cbb33a8e616c79 --- /dev/null +++ b/docs/data/base/components/menu/WrappedMenuItems.tsx @@ -0,0 +1,237 @@ +import * as React from 'react'; +import MenuUnstyled, { MenuUnstyledActions } from '@mui/base/MenuUnstyled'; +import MenuItemUnstyled, { + menuItemUnstyledClasses, +} from '@mui/base/MenuItemUnstyled'; +import { buttonUnstyledClasses } from '@mui/base/ButtonUnstyled'; +import PopperUnstyled from '@mui/base/PopperUnstyled'; +import { styled } from '@mui/system'; + +const blue = { + 100: '#DAECFF', + 200: '#99CCF3', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 900: '#003A75', +}; + +const grey = { + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledListbox = styled('ul')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + padding: 5px; + margin: 10px 0; + min-width: 220px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + overflow: auto; + outline: 0px; + + & .helper { + padding: 10px; + } +`, +); + +const StyledMenuItem = styled(MenuItemUnstyled)( + ({ theme }) => ` + list-style: none; + padding: 8px; + border-radius: 0.45em; + cursor: default; + + &:last-of-type { + border-bottom: none; + } + + &.${menuItemUnstyledClasses.focusVisible} { + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + outline: 0; + } + + &.${menuItemUnstyledClasses.disabled} { + color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &:hover:not(.${menuItemUnstyledClasses.disabled}) { + background-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + } + `, +); + +const TriggerButton = styled('button')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + min-height: calc(1.5em + 22px); + min-width: 200px; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[800] : grey[300]}; + border-radius: 0.75em; + margin: 0.5em; + padding: 10px; + text-align: left; + line-height: 1.5; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + &:hover { + background: ${theme.palette.mode === 'dark' ? '' : grey[100]}; + border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[400]}; + } + + &.${buttonUnstyledClasses.focusVisible} { + outline: 3px solid ${theme.palette.mode === 'dark' ? blue[600] : blue[100]}; + } + + &::after { + content: '▾'; + float: right; + } + `, +); + +const Popper = styled(PopperUnstyled)` + z-index: 1; +`; + +interface MenuSectionProps { + children: React.ReactNode; + label: string; +} + +const MenuSectionRoot = styled('li')` + list-style: none; + + & > ul { + padding-left: 1em; + } +`; + +const MenuSectionLabel = styled('span')` + display: block; + padding: 10px 0 5px 10px; + font-size: 0.75em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05rem; + color: ${grey[600]}; +`; + +function MenuSection({ children, label }: MenuSectionProps) { + return ( + + {label} +
      {children}
    +
    + ); +} + +export default function WrappedMenuItems() { + const [anchorEl, setAnchorEl] = React.useState(null); + const isOpen = Boolean(anchorEl); + const buttonRef = React.useRef(null); + const menuActions = React.useRef(null); + + const handleButtonClick = (event: React.MouseEvent) => { + if (isOpen) { + setAnchorEl(null); + } else { + setAnchorEl(event.currentTarget); + } + }; + + const handleButtonKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setAnchorEl(event.currentTarget); + if (event.key === 'ArrowUp') { + menuActions.current?.highlightLastItem(); + } + } + }; + + const close = () => { + setAnchorEl(null); + buttonRef.current!.focus(); + }; + + const createHandleMenuClick = (menuItem: string) => { + return () => { + // eslint-disable-next-line no-console + console.log(`Clicked on ${menuItem}`); + close(); + }; + }; + + return ( +
    + + Options + + + + + Back + + + Forward + + + Refresh + + + + + Save as... + + + Print... + + + + + Zoom in + + + Zoom out + + +
  • Current zoom level: 100%
  • +
    +
    + ); +} diff --git a/docs/data/base/components/menu/menu.md b/docs/data/base/components/menu/menu.md new file mode 100644 index 00000000000000..ccaa9ef3b36a71 --- /dev/null +++ b/docs/data/base/components/menu/menu.md @@ -0,0 +1,77 @@ +--- +product: base +title: React Menu unstyled component and hook +components: '' +githubLabel: 'component: menu' +waiAria: https://www.w3.org/TR/wai-aria-practices/#menu +--- + +# Menu + +

    Menus display a list of choices on temporary surfaces.

    + +## MenuUnstyled and MenuItemUnstyled components + +```jsx +import MenuUnstyled from '@mui/base/MenuUnstyled'; +import MenuItemUnstyled from '@mui/base/MenuItemUnstyled'; +``` + +The MenuUnstyled components can be used to create custom menus. +It renders a list of items in a popup and allows mouse and keyboard navigation through them. + +When not customized, the MenuItem renders a plain `ul` element. + +### Basic usage + +{{"demo": "MenuSimple.js"}} + +### Wrapping MenuItems + +MenuItemUnstyled components don't have to be direct descendants of the MenuUnstyled. +Developers can wrap them in arbitrary components to achieve desired appearance. + +Additionally, MenuUnstyled may contain non-interactive children (such as help text). + +{{"demo": "WrappedMenuItems.js"}} + +### Customization + +#### Slots + +The MenuUnstyled has two slots: + +- Root - represents the popup container the menu is placed in. + Can be customized by setting the `component` or `components.Root` props. + By default set to `PopperUnstyled`. +- Listbox - set to `ul` by default. + +The MenuItemUnstyled has just the root slot. +It renders `li` by default. +Similarly to MenuUnstyled, it can be customized by setting the `component` or `components.Root` props. + +#### CSS classes + +The MenuUnstyled can set the following class: + +- `Mui-expanded` - set when the menu is open. This class is set on both Root and Popper slots. + +The MenuItemUnstyled can set the following classes: + +- `Mui-disabled` - set when the MenuItem has the `disabled` prop. +- `Mui-focusVisible` - set when the MenuItem is highligthed via keyboard navigation. + This is a polyfill for the native `:focus-visible` pseudoclass as it's not available in Safari. + +## useMenu and useMenuItem hooks + +```jsx +import { useMenu } from '@mui/base/MenuUnstyled'; +import { useMenuItem } from '@mui/base/MenuItemUnstyled'; +``` + +The useMenu and useMenuItem hooks provide even greater flexibility. + +{{"demo": "UseMenu.js"}} + +It is possible to mix and match the built-in unstyled components and the ones made with hooks +(i.e. having a custom MenuItem built with useMenuItem hook inside a MenuItemUnstyled). diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts index e7bea2ca80ebc8..ee8cbea2427260 100644 --- a/docs/data/base/pages.ts +++ b/docs/data/base/pages.ts @@ -6,6 +6,18 @@ const pages = [ icon: 'DescriptionIcon', children: [{ pathname: '/base/getting-started/installation' }], }, + { + pathname: '/base/react-', + title: 'Components', + icon: 'ToggleOnIcon', + children: [ + { + pathname: '/base/components/navigation', + subheader: 'navigation', + children: [{ pathname: '/base/react-menu', title: 'Menu' }], + }, + ], + }, { title: 'Component API', pathname: '/base/api', diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js index 674b3a0742b2f0..d95818fae56f30 100644 --- a/docs/data/base/pagesApi.js +++ b/docs/data/base/pagesApi.js @@ -5,6 +5,8 @@ module.exports = [ { pathname: '/base/api/click-away-listener' }, { pathname: '/base/api/form-control-unstyled' }, { pathname: '/base/api/input-unstyled' }, + { pathname: '/base/api/menu-item-unstyled' }, + { pathname: '/base/api/menu-unstyled' }, { pathname: '/base/api/modal-unstyled' }, { pathname: '/base/api/multi-select-unstyled' }, { pathname: '/base/api/no-ssr' }, diff --git a/docs/data/material/components/menus/menus.md b/docs/data/material/components/menus/menus.md index d9c3400c86a448..2db5e1994d0afb 100644 --- a/docs/data/material/components/menus/menus.md +++ b/docs/data/material/components/menus/menus.md @@ -1,7 +1,7 @@ --- product: material-ui title: React Menu component -components: Menu, MenuItem, MenuList, ClickAwayListener, Popover, Popper +components: Menu, MenuItem, MenuList, ClickAwayListener, Popover, Popper, MenuUnstyled, MenuItemUnstyled githubLabel: 'component: menu' materialDesign: https://material.io/components/menus waiAria: https://www.w3.org/TR/wai-aria-practices/#menubutton @@ -105,6 +105,13 @@ Here is an example of a context menu. (Right click to open.) {{"demo": "ContextMenu.js"}} +## Unstyled + +The Menu also comes with an unstyled version. +It's ideal for doing heavy customizations and minimizing bundle size. + +See its docs on the [MUI Base section](/base/react-menu). + ## Complementary projects For more advanced use cases you might be able to take advantage of: diff --git a/docs/pages/api-docs/menu-item-unstyled.js b/docs/pages/api-docs/menu-item-unstyled.js new file mode 100644 index 00000000000000..810251bfa4a63b --- /dev/null +++ b/docs/pages/api-docs/menu-item-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './menu-item-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/menu-item-unstyled', + false, + /menu-item-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/menu-item-unstyled.json b/docs/pages/api-docs/menu-item-unstyled.json new file mode 100644 index 00000000000000..63c97eceea6e5c --- /dev/null +++ b/docs/pages/api-docs/menu-item-unstyled.json @@ -0,0 +1,11 @@ +{ + "props": { "disabled": { "type": { "name": "bool" } } }, + "name": "MenuItemUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLLIElement", + "filename": "/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/api-docs/menu-unstyled.js b/docs/pages/api-docs/menu-unstyled.js new file mode 100644 index 00000000000000..8b6d8ac2901057 --- /dev/null +++ b/docs/pages/api-docs/menu-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './menu-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/menu-unstyled', + false, + /menu-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/api-docs/menu-unstyled.json b/docs/pages/api-docs/menu-unstyled.json new file mode 100644 index 00000000000000..b69a7e00d86b93 --- /dev/null +++ b/docs/pages/api-docs/menu-unstyled.json @@ -0,0 +1,21 @@ +{ + "props": { + "actions": { "type": { "name": "custom", "description": "ref" } }, + "anchorEl": { + "type": { + "name": "union", + "description": "HTML element
    | object
    | func" + } + }, + "onClose": { "type": { "name": "func" } }, + "open": { "type": { "name": "bool" } } + }, + "name": "MenuUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": false, + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base/api/menu-item-unstyled.js b/docs/pages/base/api/menu-item-unstyled.js new file mode 100644 index 00000000000000..810251bfa4a63b --- /dev/null +++ b/docs/pages/base/api/menu-item-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './menu-item-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/menu-item-unstyled', + false, + /menu-item-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/base/api/menu-item-unstyled.json b/docs/pages/base/api/menu-item-unstyled.json new file mode 100644 index 00000000000000..bb95a2e41fd253 --- /dev/null +++ b/docs/pages/base/api/menu-item-unstyled.json @@ -0,0 +1,11 @@ +{ + "props": { "disabled": { "type": { "name": "bool" } } }, + "name": "MenuItemUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "forwardsRefTo": "HTMLLIElement", + "filename": "/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base/api/menu-unstyled.js b/docs/pages/base/api/menu-unstyled.js new file mode 100644 index 00000000000000..8b6d8ac2901057 --- /dev/null +++ b/docs/pages/base/api/menu-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './menu-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/menu-unstyled', + false, + /menu-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/base/api/menu-unstyled.json b/docs/pages/base/api/menu-unstyled.json new file mode 100644 index 00000000000000..c66d5c1fe00754 --- /dev/null +++ b/docs/pages/base/api/menu-unstyled.json @@ -0,0 +1,21 @@ +{ + "props": { + "actions": { "type": { "name": "custom", "description": "ref" } }, + "anchorEl": { + "type": { + "name": "union", + "description": "HTML element
    | object
    | func" + } + }, + "onClose": { "type": { "name": "func" } }, + "open": { "type": { "name": "bool" } } + }, + "name": "MenuUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": false, + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base/react-menu.js b/docs/pages/base/react-menu.js new file mode 100644 index 00000000000000..734b5e7b052455 --- /dev/null +++ b/docs/pages/base/react-menu.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { demos, docs, demoComponents } from 'docs/data/base/components/menu/menu.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/docs/src/modules/components/AppLayoutDocsFooter.js b/docs/src/modules/components/AppLayoutDocsFooter.js index 7616f86a62a5b8..f0a64fbfd7943d 100644 --- a/docs/src/modules/components/AppLayoutDocsFooter.js +++ b/docs/src/modules/components/AppLayoutDocsFooter.js @@ -164,8 +164,9 @@ function usePageNeighbours() { const { activePage, pages } = React.useContext(PageContext); const pageList = orderedPages(pages); const currentPageNum = pageList.indexOf(activePage); + if (currentPageNum === -1) { - return { prevPage: undefined, nextPage: undefined }; + return { prevPage: null, nextPage: null }; } const prevPage = pageList[currentPageNum - 1] ?? null; diff --git a/docs/src/pagesApi.js b/docs/src/pagesApi.js index 2f4500fca52a6c..7a13d77c456837 100644 --- a/docs/src/pagesApi.js +++ b/docs/src/pagesApi.js @@ -88,7 +88,9 @@ module.exports = [ { pathname: '/api-docs/masonry' }, { pathname: '/api-docs/menu' }, { pathname: '/api-docs/menu-item' }, + { pathname: '/api-docs/menu-item-unstyled' }, { pathname: '/api-docs/menu-list' }, + { pathname: '/api-docs/menu-unstyled' }, { pathname: '/api-docs/mobile-date-picker' }, { pathname: '/api-docs/mobile-date-range-picker' }, { pathname: '/api-docs/mobile-date-time-picker' }, diff --git a/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled-pt.json b/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled-pt.json new file mode 100644 index 00000000000000..4f386c5c22e49a --- /dev/null +++ b/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled-pt.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "disabled": "If true, the menu item will be disabled." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled-zh.json b/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled-zh.json new file mode 100644 index 00000000000000..4f386c5c22e49a --- /dev/null +++ b/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled-zh.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "disabled": "If true, the menu item will be disabled." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled.json b/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled.json new file mode 100644 index 00000000000000..4f386c5c22e49a --- /dev/null +++ b/docs/translations/api-docs/menu-item-unstyled/menu-item-unstyled.json @@ -0,0 +1,5 @@ +{ + "componentDescription": "", + "propDescriptions": { "disabled": "If true, the menu item will be disabled." }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/menu-unstyled/menu-unstyled-pt.json b/docs/translations/api-docs/menu-unstyled/menu-unstyled-pt.json new file mode 100644 index 00000000000000..f93d4cbd8c7985 --- /dev/null +++ b/docs/translations/api-docs/menu-unstyled/menu-unstyled-pt.json @@ -0,0 +1 @@ +{ "componentDescription": "", "propDescriptions": {}, "classDescriptions": {} } diff --git a/docs/translations/api-docs/menu-unstyled/menu-unstyled-zh.json b/docs/translations/api-docs/menu-unstyled/menu-unstyled-zh.json new file mode 100644 index 00000000000000..f93d4cbd8c7985 --- /dev/null +++ b/docs/translations/api-docs/menu-unstyled/menu-unstyled-zh.json @@ -0,0 +1 @@ +{ "componentDescription": "", "propDescriptions": {}, "classDescriptions": {} } diff --git a/docs/translations/api-docs/menu-unstyled/menu-unstyled.json b/docs/translations/api-docs/menu-unstyled/menu-unstyled.json new file mode 100644 index 00000000000000..c9561817e80b64 --- /dev/null +++ b/docs/translations/api-docs/menu-unstyled/menu-unstyled.json @@ -0,0 +1,10 @@ +{ + "componentDescription": "", + "propDescriptions": { + "actions": "A ref with imperative actions. It allows to select the first or last menu item.", + "anchorEl": "An HTML element, virtualElement, or a function that returns either. It's used to set the position of the popper.", + "onClose": "Triggered when focus leaves the menu and the menu should close.", + "open": "Controls whether the menu is displayed." + }, + "classDescriptions": {} +} diff --git a/packages/mui-base/src/ButtonUnstyled/useButton.ts b/packages/mui-base/src/ButtonUnstyled/useButton.ts index 1675cbb03c50e4..01d8da3c23092c 100644 --- a/packages/mui-base/src/ButtonUnstyled/useButton.ts +++ b/packages/mui-base/src/ButtonUnstyled/useButton.ts @@ -72,6 +72,12 @@ export default function useButton(parameters: UseButtonParameters) { ); }; + const createHandleClick = (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { + if (!disabled) { + otherHandlers.onClick?.(event); + } + }; + const createHandleMouseDown = (otherHandlers: EventHandlers) => (event: React.MouseEvent) => { if (event.target === event.currentTarget && !disabled) { setActive(true); @@ -129,6 +135,7 @@ export default function useButton(parameters: UseButtonParameters) { if ( event.target === event.currentTarget && isNonNativeButton() && + !disabled && event.key === ' ' && !event.defaultPrevented ) { @@ -186,6 +193,7 @@ export default function useButton(parameters: UseButtonParameters) { ...externalEventHandlers, ...buttonProps, onBlur: createHandleBlur(externalEventHandlers), + onClick: createHandleClick(externalEventHandlers), onFocus: createHandleFocus(externalEventHandlers), onKeyDown: createHandleKeyDown(externalEventHandlers), onKeyUp: createHandleKeyUp(externalEventHandlers), diff --git a/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts index e0f16144432204..67ed7431127409 100644 --- a/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts +++ b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.test.ts @@ -6,21 +6,13 @@ describe('useListbox defaultReducer', () => { describe('action: setControlledValue', () => { it("assigns the provided value to the state's selectedValue", () => { const state: ListboxState = { - highlightedIndex: 42, + highlightedValue: 'a', selectedValue: null, }; const action: ListboxAction = { - type: ActionTypes.setControlledValue, + type: ActionTypes.setValue, value: 'foo', - props: { - options: [], - disableListWrap: false, - disabledItemsFocusable: false, - isOptionDisabled: () => false, - optionComparer: (o, v) => o === v, - multiple: false, - }, }; const result = defaultReducer(state, action); expect(result.selectedValue).to.equal('foo'); @@ -30,7 +22,7 @@ describe('useListbox defaultReducer', () => { describe('action: blur', () => { it('resets the highlightedIndex', () => { const state: ListboxState = { - highlightedIndex: 42, + highlightedValue: 'a', selectedValue: null, }; @@ -48,14 +40,14 @@ describe('useListbox defaultReducer', () => { }; const result = defaultReducer(state, action); - expect(result.highlightedIndex).to.equal(-1); + expect(result.highlightedValue).to.equal(null); }); }); describe('action: optionClick', () => { it('sets the selectedValue to the clicked value', () => { const state: ListboxState = { - highlightedIndex: 42, + highlightedValue: 'a', selectedValue: null, }; @@ -79,7 +71,7 @@ describe('useListbox defaultReducer', () => { it('add the clicked value to the selection if selectMultiple is set', () => { const state: ListboxState = { - highlightedIndex: 42, + highlightedValue: 'one', selectedValue: ['one'], }; @@ -103,7 +95,7 @@ describe('useListbox defaultReducer', () => { it('remove the clicked value from the selection if selectMultiple is set and it was selected already', () => { const state: ListboxState = { - highlightedIndex: 42, + highlightedValue: 'three', selectedValue: ['one', 'two'], }; @@ -130,7 +122,7 @@ describe('useListbox defaultReducer', () => { describe('Home key is pressed', () => { it('highlights the first non-disabled option if the first is disabled', () => { const state: ListboxState = { - highlightedIndex: 3, + highlightedValue: null, selectedValue: null, }; @@ -150,14 +142,14 @@ describe('useListbox defaultReducer', () => { }; const result = defaultReducer(state, action); - expect(result.highlightedIndex).to.equal(1); + expect(result.highlightedValue).to.equal('two'); }); }); describe('End key is pressed', () => { it('highlights the last non-disabled option if the last is disabled', () => { const state: ListboxState = { - highlightedIndex: 0, + highlightedValue: null, selectedValue: null, }; @@ -177,14 +169,14 @@ describe('useListbox defaultReducer', () => { }; const result = defaultReducer(state, action); - expect(result.highlightedIndex).to.equal(3); + expect(result.highlightedValue).to.equal('four'); }); }); describe('ArrowUp key is pressed', () => { it('wraps the highlight around omitting disabled items', () => { const state: ListboxState = { - highlightedIndex: 1, + highlightedValue: null, selectedValue: null, }; @@ -204,14 +196,14 @@ describe('useListbox defaultReducer', () => { }; const result = defaultReducer(state, action); - expect(result.highlightedIndex).to.equal(3); + expect(result.highlightedValue).to.equal('four'); }); }); describe('ArrowDown key is pressed', () => { it('wraps the highlight around omitting disabled items', () => { const state: ListboxState = { - highlightedIndex: 3, + highlightedValue: null, selectedValue: null, }; @@ -231,12 +223,12 @@ describe('useListbox defaultReducer', () => { }; const result = defaultReducer(state, action); - expect(result.highlightedIndex).to.equal(1); + expect(result.highlightedValue).to.equal('two'); }); it('does not highlight any option if all are disabled', () => { const state: ListboxState = { - highlightedIndex: -1, + highlightedValue: null, selectedValue: null, }; @@ -256,14 +248,14 @@ describe('useListbox defaultReducer', () => { }; const result = defaultReducer(state, action); - expect(result.highlightedIndex).to.equal(-1); + expect(result.highlightedValue).to.equal(null); }); }); describe('Enter key is pressed', () => { it('selects the highlighted option', () => { const state: ListboxState = { - highlightedIndex: 1, + highlightedValue: 'two', selectedValue: null, }; @@ -288,7 +280,7 @@ describe('useListbox defaultReducer', () => { it('add the highlighted value to the selection if selectMultiple is set', () => { const state: ListboxState = { - highlightedIndex: 1, + highlightedValue: 'two', selectedValue: ['one'], }; diff --git a/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts index 054930ee5b403a..04f29870cd7366 100644 --- a/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts +++ b/packages/mui-base/src/ListboxUnstyled/defaultListboxReducer.ts @@ -47,21 +47,26 @@ function findValidOptionToHighlight( } } -function getNewHighlightedIndex( +function getNewHighlightedOption( options: TOption[], - previouslyHighlightedIndex: number, + previouslyHighlightedOption: TOption | null, diff: number | 'reset' | 'start' | 'end', lookupDirection: 'previous' | 'next', highlightDisabled: boolean, isOptionDisabled: OptionPredicate, wrapAround: boolean, -) { + optionComparer: (optionA: TOption, optionB: TOption) => boolean, +): TOption | null { const maxIndex = options.length - 1; const defaultHighlightedIndex = -1; let nextIndexCandidate: number; + const previouslyHighlightedIndex = + previouslyHighlightedOption == null + ? -1 + : options.findIndex((option) => optionComparer(option, previouslyHighlightedOption)); if (diff === 'reset') { - return defaultHighlightedIndex; + return defaultHighlightedIndex === -1 ? null : options[defaultHighlightedIndex] ?? null; } if (diff === 'start') { @@ -97,7 +102,7 @@ function getNewHighlightedIndex( wrapAround, ); - return nextIndex; + return options[nextIndex] ?? null; } function handleOptionSelection( @@ -123,7 +128,7 @@ function handleOptionSelection( return { selectedValue: newSelectedValues, - highlightedIndex: optionIndex, + highlightedValue: option, }; } @@ -133,7 +138,7 @@ function handleOptionSelection( return { selectedValue: option, - highlightedIndex: optionIndex, + highlightedValue: option, }; } @@ -142,21 +147,23 @@ function handleKeyDown( state: Readonly>, props: UseListboxStrictProps, ): ListboxState { - const { options, isOptionDisabled, disableListWrap, disabledItemsFocusable } = props; + const { options, isOptionDisabled, disableListWrap, disabledItemsFocusable, optionComparer } = + props; const moveHighlight = ( diff: number | 'reset' | 'start' | 'end', direction: 'next' | 'previous', wrapAround: boolean, ) => { - return getNewHighlightedIndex( + return getNewHighlightedOption( options, - state.highlightedIndex, + state.highlightedValue, diff, direction, disabledItemsFocusable ?? false, isOptionDisabled ?? (() => false), wrapAround, + optionComparer, ); }; @@ -164,48 +171,48 @@ function handleKeyDown( case 'Home': return { ...state, - highlightedIndex: moveHighlight('start', 'next', false), + highlightedValue: moveHighlight('start', 'next', false), }; case 'End': return { ...state, - highlightedIndex: moveHighlight('end', 'previous', false), + highlightedValue: moveHighlight('end', 'previous', false), }; case 'PageUp': return { ...state, - highlightedIndex: moveHighlight(-pageSize, 'previous', false), + highlightedValue: moveHighlight(-pageSize, 'previous', false), }; case 'PageDown': return { ...state, - highlightedIndex: moveHighlight(pageSize, 'next', false), + highlightedValue: moveHighlight(pageSize, 'next', false), }; case 'ArrowUp': // TODO: extend current selection with Shift modifier return { ...state, - highlightedIndex: moveHighlight(-1, 'previous', !(disableListWrap ?? false)), + highlightedValue: moveHighlight(-1, 'previous', !(disableListWrap ?? false)), }; case 'ArrowDown': // TODO: extend current selection with Shift modifier return { ...state, - highlightedIndex: moveHighlight(1, 'next', !(disableListWrap ?? false)), + highlightedValue: moveHighlight(1, 'next', !(disableListWrap ?? false)), }; case 'Enter': case ' ': - if (state.highlightedIndex === -1 || options[state.highlightedIndex] === undefined) { + if (state.highlightedValue === null) { return state; } - return handleOptionSelection(options[state.highlightedIndex], state, props); + return handleOptionSelection(state.highlightedValue, state, props); default: break; @@ -217,7 +224,7 @@ function handleKeyDown( function handleBlur(state: ListboxState): ListboxState { return { ...state, - highlightedIndex: -1, + highlightedValue: null, }; } @@ -229,10 +236,10 @@ function handleOptionsChange( ): ListboxState { const { multiple, optionComparer } = props; - const highlightedOption = previousOptions[state.highlightedIndex]; - const hightlightedOptionNewIndex = options.findIndex((option) => - optionComparer(option, highlightedOption), - ); + const newHighlightedOption = + state.highlightedValue == null + ? null + : options.find((option) => optionComparer(option, state.highlightedValue!)) ?? null; if (multiple) { // exclude selected values that are no longer in the options @@ -242,7 +249,7 @@ function handleOptionsChange( ); return { - highlightedIndex: hightlightedOptionNewIndex, + highlightedValue: newHighlightedOption, selectedValue: newSelectedValues, }; } @@ -251,7 +258,7 @@ function handleOptionsChange( options.find((option) => optionComparer(option, state.selectedValue as TOption)) ?? null; return { - highlightedIndex: hightlightedOptionNewIndex, + highlightedValue: newHighlightedOption, selectedValue: newSelectedValue, }; } @@ -269,11 +276,16 @@ export default function defaultListboxReducer( return handleOptionSelection(action.option, state, action.props); case ActionTypes.blur: return handleBlur(state); - case ActionTypes.setControlledValue: + case ActionTypes.setValue: return { ...state, selectedValue: action.value, }; + case ActionTypes.setHighlight: + return { + ...state, + highlightedValue: action.highlight, + }; case ActionTypes.optionsChange: return handleOptionsChange(action.options, action.previousOptions, state, action.props); default: diff --git a/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts b/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts index 9691c85129e93b..6978663e90046e 100644 --- a/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts +++ b/packages/mui-base/src/ListboxUnstyled/useControllableReducer.ts @@ -26,10 +26,14 @@ function useReducerReturnValueHandler( valueRef.current = value; const onValueChangeRef = React.useRef(onValueChange); - onValueChangeRef.current = onValueChange; + React.useEffect(() => { + onValueChangeRef.current = onValueChange; + }, [onValueChange]); const onHighlightChangeRef = React.useRef(onHighlightChange); - onHighlightChangeRef.current = onHighlightChange; + React.useEffect(() => { + onHighlightChangeRef.current = onHighlightChange; + }, [onHighlightChange]); React.useEffect(() => { if (Array.isArray(state.selectedValue)) { @@ -54,12 +58,8 @@ function useReducerReturnValueHandler( React.useEffect(() => { // Fire the highlightChange event when reducer returns changed `highlightedIndex`. - if (state.highlightedIndex === -1) { - onHighlightChangeRef.current?.(null); - } else { - onHighlightChangeRef.current?.(options[state.highlightedIndex]); - } - }, [state.highlightedIndex, options]); + onHighlightChangeRef.current?.(state.highlightedValue); + }, [state.highlightedValue]); } export default function useControllableReducer( @@ -90,7 +90,7 @@ export default function useControllableReducer( const [state, dispatch] = React.useReducer>( externalReducer ?? internalReducer, { - highlightedIndex: -1, + highlightedValue: null, selectedValue: value, } as ListboxState, ); @@ -129,9 +129,8 @@ export default function useControllableReducer( previousValueRef.current = controlledValue; dispatch({ - type: ActionTypes.setControlledValue, + type: ActionTypes.setValue, value: controlledValue, - props: propsRef.current, }); }, [controlledValue]); diff --git a/packages/mui-base/src/ListboxUnstyled/useListbox.ts b/packages/mui-base/src/ListboxUnstyled/useListbox.ts index 26f871dec2fbf9..83dbc989558587 100644 --- a/packages/mui-base/src/ListboxUnstyled/useListbox.ts +++ b/packages/mui-base/src/ListboxUnstyled/useListbox.ts @@ -14,18 +14,20 @@ import areArraysEqual from '../utils/areArraysEqual'; import { EventHandlers } from '../utils/types'; const defaultOptionComparer = (optionA: TOption, optionB: TOption) => optionA === optionB; +const defaultIsOptionDisabled = () => false; export default function useListbox(props: UseListboxParameters) { const { - disableListWrap = false, disabledItemsFocusable = false, + disableListWrap = false, + focusManagement = 'activeDescendant', id: idProp, - options, + isOptionDisabled = defaultIsOptionDisabled, + listboxRef: externalListboxRef, multiple = false, - isOptionDisabled = () => false, optionComparer = defaultOptionComparer, + options, stateReducer: externalReducer, - listboxRef: externalListboxRef, } = props; const id = useId(idProp); @@ -38,8 +40,9 @@ export default function useListbox(props: UseListboxParameters const propsWithDefaults: UseListboxStrictProps = { ...props, - disableListWrap, disabledItemsFocusable, + disableListWrap, + focusManagement, isOptionDisabled, multiple, optionComparer, @@ -48,12 +51,18 @@ export default function useListbox(props: UseListboxParameters const listboxRef = React.useRef(null); const handleRef = useForkRef(externalListboxRef, listboxRef); - const [{ highlightedIndex, selectedValue }, dispatch] = useControllableReducer( + const [{ highlightedValue, selectedValue }, dispatch] = useControllableReducer( defaultReducer, externalReducer, propsWithDefaults, ); + const highlightedIndex = React.useMemo(() => { + return highlightedValue == null + ? -1 + : options.findIndex((option) => optionComparer(option, highlightedValue)); + }, [highlightedValue, options, optionComparer]); + const previousOptions = React.useRef([]); React.useEffect(() => { @@ -74,6 +83,26 @@ export default function useListbox(props: UseListboxParameters // eslint-disable-next-line react-hooks/exhaustive-deps }, [options, optionComparer, dispatch]); + const setSelectedValue = React.useCallback( + (option: TOption | TOption[] | null) => { + dispatch({ + type: ActionTypes.setValue, + value: option, + }); + }, + [dispatch], + ); + + const setHighlightedValue = React.useCallback( + (option: TOption | null) => { + dispatch({ + type: ActionTypes.setHighlight, + highlight: option, + }); + }, + [dispatch], + ); + const createHandleOptionClick = (option: TOption, other: Record>) => (event: React.MouseEvent) => { @@ -92,6 +121,22 @@ export default function useListbox(props: UseListboxParameters }); }; + const createHandleOptionMouseOver = + (option: TOption, other: Record>) => + (event: React.MouseEvent) => { + other.onMouseOver?.(event); + if (event.defaultPrevented) { + return; + } + + dispatch({ + type: ActionTypes.optionHover, + option, + event, + props: propsWithDefaults, + }); + }; + const createHandleKeyDown = (other: Record>) => (event: React.KeyboardEvent) => { @@ -149,14 +194,14 @@ export default function useListbox(props: UseListboxParameters return { ...otherHandlers, 'aria-activedescendant': - highlightedIndex >= 0 - ? optionIdGenerator(options[highlightedIndex], highlightedIndex) + focusManagement === 'activeDescendant' && highlightedValue != null + ? optionIdGenerator(highlightedValue, highlightedIndex) : undefined, id, onBlur: createHandleBlur(otherHandlers), onKeyDown: createHandleKeyDown(otherHandlers), role: 'listbox', - tabIndex: 0, + tabIndex: focusManagement === 'DOM' ? -1 : 0, ref: handleRef, }; }; @@ -181,28 +226,53 @@ export default function useListbox(props: UseListboxParameters }; }; + const getOptionTabIndex = (optionState: OptionState) => { + if (focusManagement === 'activeDescendant') { + return undefined; + } + + if (!optionState.highlighted) { + return -1; + } + + if (optionState.disabled && !disabledItemsFocusable) { + return -1; + } + + return 0; + }; + const getOptionProps = ( option: TOption, otherHandlers: TOther = {} as TOther, ): UseListboxOptionSlotProps => { - const { selected, disabled } = getOptionState(option); + const optionState = getOptionState(option); const index = options.findIndex((opt) => optionComparer(opt, option)); return { ...otherHandlers, - 'aria-disabled': disabled || undefined, - 'aria-selected': selected, + 'aria-disabled': optionState.disabled || undefined, + 'aria-selected': optionState.selected, + tabIndex: getOptionTabIndex(optionState), id: optionIdGenerator(option, index), onClick: createHandleOptionClick(option, otherHandlers), + onMouseOver: createHandleOptionMouseOver(option, otherHandlers), role: 'option', }; }; + React.useDebugValue({ + highlightedOption: options[highlightedIndex], + selectedOption: selectedValue, + }); + return { getRootProps, getOptionProps, getOptionState, - selectedOption: selectedValue, highlightedOption: options[highlightedIndex] ?? null, + selectedOption: selectedValue, + setSelectedValue, + setHighlightedValue, }; } diff --git a/packages/mui-base/src/ListboxUnstyled/useListbox.types.ts b/packages/mui-base/src/ListboxUnstyled/useListbox.types.ts index 12a81685ea7cb9..1d8b14f4b542d3 100644 --- a/packages/mui-base/src/ListboxUnstyled/useListbox.types.ts +++ b/packages/mui-base/src/ListboxUnstyled/useListbox.types.ts @@ -13,13 +13,17 @@ export type UseListboxStrictProps = Omit< > & Required, UseListboxStrictPropsRequiredKeys>>; +export type FocusManagementType = 'DOM' | 'activeDescendant'; + enum ActionTypes { blur = 'blur', focus = 'focus', keyDown = 'keyDown', optionClick = 'optionClick', - setControlledValue = 'setControlledValue', + optionHover = 'optionHover', optionsChange = 'optionsChange', + setValue = 'setValue', + setHighlight = 'setHighlight', } // split declaration and export due to https://github.com/codesandbox/codesandbox-client/issues/6435 @@ -32,6 +36,13 @@ interface OptionClickAction { props: UseListboxStrictProps; } +interface OptionHoverAction { + type: ActionTypes.optionHover; + option: TOption; + event: React.MouseEvent; + props: UseListboxStrictProps; +} + interface FocusAction { type: ActionTypes.focus; event: React.FocusEvent; @@ -50,10 +61,14 @@ interface KeyDownAction { props: UseListboxStrictProps; } -interface SetControlledValueAction { - type: ActionTypes.setControlledValue; +interface SetValueAction { + type: ActionTypes.setValue; value: TOption | TOption[] | null; - props: UseListboxStrictProps; +} + +interface SetHighlightAction { + type: ActionTypes.setHighlight; + highlight: TOption | null; } interface OptionsChangeAction { @@ -65,14 +80,16 @@ interface OptionsChangeAction { export type ListboxAction = | OptionClickAction + | OptionHoverAction | FocusAction | BlurAction | KeyDownAction - | SetControlledValueAction + | SetHighlightAction + | SetValueAction | OptionsChangeAction; export interface ListboxState { - highlightedIndex: number; + highlightedValue: TOption | null; selectedValue: TOption | TOption[] | null; } @@ -92,10 +109,7 @@ interface UseListboxCommonProps { * @default false */ disableListWrap?: boolean; - /** - * Ref of the listbox DOM element. - */ - listboxRef?: React.Ref; + focusManagement?: FocusManagementType; /** * Id attribute of the listbox. */ @@ -105,6 +119,10 @@ interface UseListboxCommonProps { * @default () => false */ isOptionDisabled?: (option: TOption, index: number) => boolean; + /** + * Ref of the listbox DOM element. + */ + listboxRef?: React.Ref; /** * Callback fired when the highlighted option changes. */ diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx new file mode 100644 index 00000000000000..1c33316a8d454b --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { createMount, createRenderer, describeConformanceUnstyled } from 'test/utils'; +import MenuItemUnstyled, { menuItemUnstyledClasses } from '@mui/base/MenuItemUnstyled'; +import { MenuUnstyledContext } from '@mui/base/MenuUnstyled'; + +const dummyGetItemState = () => ({ + disabled: false, + highlighted: false, + selected: false, + index: 0, +}); + +const testContext = { + getItemState: dummyGetItemState, + getItemProps: () => ({}), + registerItem: () => {}, + unregisterItem: () => {}, + open: false, +}; + +describe('MenuItemUnstyled', () => { + const mount = createMount(); + const { render } = createRenderer(); + + describeConformanceUnstyled(, () => ({ + inheritComponent: 'li', + render: (node) => { + return render( + {node}, + ); + }, + mount: (node: React.ReactNode) => { + const wrapper = mount( + {node}, + ); + return wrapper.childAt(0); + }, + refInstanceof: window.HTMLLIElement, + testComponentPropWith: 'span', + muiName: 'MuiMenuItemUnstyled', + slots: { + root: { + expectedClassName: menuItemUnstyledClasses.root, + }, + }, + skip: ['reactTestRenderer'], // Need to be wrapped in MenuUnstyledContext + })); +}); diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx new file mode 100644 index 00000000000000..79026ae5bff429 --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { MenuItemOwnerState, MenuItemUnstyledProps } from './MenuItemUnstyled.types'; +import { appendOwnerState } from '../utils'; +import { getMenuItemUnstyledUtilityClass } from './menuItemUnstyledClasses'; +import useMenuItem from './useMenuItem'; +import composeClasses from '../composeClasses'; + +function getUtilityClasses(ownerState: MenuItemOwnerState) { + const { disabled, focusVisible } = ownerState; + + const slots = { + root: ['root', disabled && 'disabled', focusVisible && 'focusVisible'], + }; + + return composeClasses(slots, getMenuItemUnstyledUtilityClass, {}); +} + +/** + * + * Demos: + * + * - [Menus](https://mui.com/components/menus/) + * + * API: + * + * - [MenuItemUnstyled API](https://mui.com/api/menu-item-unstyled/) + */ +const MenuItemUnstyled = React.forwardRef(function MenuItemUnstyled( + props: MenuItemUnstyledProps & React.ComponentPropsWithoutRef<'li'>, + ref: React.Ref, +) { + const { + children, + className, + disabled = false, + component, + components = {}, + componentsProps = {}, + ...other + } = props; + + const Root = component ?? components.Root ?? 'li'; + + const { getRootProps, itemState, focusVisible } = useMenuItem({ + component: Root, + disabled, + ref, + }); + + if (itemState == null) { + return null; + } + + const ownerState: MenuItemOwnerState = { ...props, ...itemState, focusVisible }; + + const classes = getUtilityClasses(ownerState); + + const rootProps = appendOwnerState( + Root, + { + ...other, + ...componentsProps.root, + ...getRootProps(other), + className: clsx(classes.root, className, componentsProps.root?.className), + }, + ownerState, + ); + + return {children}; +}); + +MenuItemUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * @ignore + */ + component: PropTypes.elementType, + /** + * @ignore + */ + components: PropTypes.shape({ + Root: PropTypes.elementType, + }), + /** + * @ignore + */ + componentsProps: PropTypes.shape({ + root: PropTypes.object, + }), + /** + * If `true`, the menu item will be disabled. + * @default false + */ + disabled: PropTypes.bool, +} as any; + +export default MenuItemUnstyled; diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts new file mode 100644 index 00000000000000..4a25c4f0ab40cd --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; + +export interface MenuItemUnstyledComponentsPropsOverrides {} + +export interface MenuItemOwnerState extends MenuItemUnstyledProps { + disabled: boolean; + focusVisible: boolean; +} + +export interface MenuItemUnstyledProps { + children?: React.ReactNode; + className?: string; + onClick?: React.MouseEventHandler; + /** + * If `true`, the menu item will be disabled. + * @default false + */ + disabled?: boolean; + component?: React.ElementType; + components?: { + Root?: React.ElementType; + }; + componentsProps?: { + root?: React.ComponentPropsWithRef<'li'> & MenuItemUnstyledComponentsPropsOverrides; + }; +} diff --git a/packages/mui-base/src/MenuItemUnstyled/index.tsx b/packages/mui-base/src/MenuItemUnstyled/index.tsx new file mode 100644 index 00000000000000..ac5c6a8a567f61 --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/index.tsx @@ -0,0 +1,9 @@ +export { default } from './MenuItemUnstyled'; + +export * from './MenuItemUnstyled.types'; + +export { default as menuItemUnstyledClasses } from './menuItemUnstyledClasses'; +export * from './menuItemUnstyledClasses'; + +export { default as useMenuItem } from './useMenuItem'; +export * from './useMenuItem.types'; diff --git a/packages/mui-base/src/MenuItemUnstyled/menuItemUnstyledClasses.ts b/packages/mui-base/src/MenuItemUnstyled/menuItemUnstyledClasses.ts new file mode 100644 index 00000000000000..647e10ff9e9dfc --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/menuItemUnstyledClasses.ts @@ -0,0 +1,21 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface MenuItemUnstyledClasses { + root: string; + disabled: string; + focusVisible: string; +} + +export type MenuItemUnstyledClassKey = keyof MenuItemUnstyledClasses; + +export function getMenuItemUnstyledUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuItemUnstyled', slot); +} + +const menuItemUnstyledClasses: MenuItemUnstyledClasses = generateUtilityClasses( + 'MuiMenuItemUnstyled', + ['root', 'disabled', 'focusVisible'], +); + +export default menuItemUnstyledClasses; diff --git a/packages/mui-base/src/MenuItemUnstyled/useMenuItem.ts b/packages/mui-base/src/MenuItemUnstyled/useMenuItem.ts new file mode 100644 index 00000000000000..a73860f2771535 --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/useMenuItem.ts @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { unstable_useId as useId, unstable_useForkRef as useForkRef } from '@mui/utils'; +import { MenuUnstyledContext } from '../MenuUnstyled'; +import { useButton } from '../ButtonUnstyled'; +import { UseMenuItemParameters } from './useMenuItem.types'; + +export default function useMenuItem(props: UseMenuItemParameters) { + const { component, disabled = false, ref } = props; + + const id = useId(); + const menuContext = React.useContext(MenuUnstyledContext); + + const itemRef = React.useRef(null); + const handleRef = useForkRef(itemRef, ref); + + if (menuContext === null) { + throw new Error('MenuItemUnstyled must be used within a MenuUnstyled'); + } + + const { registerItem, unregisterItem, open } = menuContext; + + React.useEffect(() => { + if (id === undefined) { + return undefined; + } + + registerItem(id, { disabled, id, ref: itemRef }); + + return () => unregisterItem(id); + }, [id, registerItem, unregisterItem, disabled, ref]); + + const { getRootProps: getButtonProps, focusVisible } = useButton({ + component, + ref: handleRef, + disabled, + }); + + // Ensure the menu item is focused when highlighted + const [focusRequested, requestFocus] = React.useState(false); + + const focusIfRequested = React.useCallback(() => { + if (focusRequested && itemRef.current != null) { + itemRef.current.focus(); + requestFocus(false); + } + }, [focusRequested]); + + React.useEffect(() => { + focusIfRequested(); + }); + + React.useDebugValue({ id, disabled }); + + const itemState = menuContext.getItemState(id ?? ''); + + const { highlighted } = itemState ?? { highlighted: false }; + + React.useEffect(() => { + requestFocus(highlighted && open); + }, [highlighted, open]); + + if (id === undefined) { + return { + getRootProps: (other?: Record) => ({ + ...other, + ...getButtonProps(other), + role: 'menuitem', + }), + itemState: null, + focusVisible, + }; + } + + return { + getRootProps: (other?: Record) => { + const optionProps = menuContext.getItemProps(id, other); + + return { + ...other, + ...getButtonProps(other), + tabIndex: optionProps.tabIndex, + id: optionProps.id, + role: 'menuitem', + }; + }, + itemState, + focusVisible, + }; +} diff --git a/packages/mui-base/src/MenuItemUnstyled/useMenuItem.types.ts b/packages/mui-base/src/MenuItemUnstyled/useMenuItem.types.ts new file mode 100644 index 00000000000000..dd8e6d25039683 --- /dev/null +++ b/packages/mui-base/src/MenuItemUnstyled/useMenuItem.types.ts @@ -0,0 +1,6 @@ +export interface UseMenuItemParameters { + component: React.ElementType; + disabled?: boolean; + onClick?: React.MouseEventHandler; + ref: React.Ref; +} diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx new file mode 100644 index 00000000000000..a2c8fd7d8caac0 --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { + createMount, + createRenderer, + describeConformanceUnstyled, + fireEvent, + act, +} from 'test/utils'; +import MenuUnstyled, { menuUnstyledClasses } from '@mui/base/MenuUnstyled'; +import MenuItemUnstyled from '@mui/base/MenuItemUnstyled'; + +describe('MenuUnstyled', () => { + const mount = createMount(); + const { render } = createRenderer(); + + const defaultProps = { + anchorEl: document.createElement('div'), + open: true, + }; + + describeConformanceUnstyled(, () => ({ + inheritComponent: 'div', + render, + mount, + refInstanceof: window.HTMLDivElement, + muiName: 'MuiMenuUnstyled', + slots: { + root: { + expectedClassName: menuUnstyledClasses.root, + testWithElement: null, + }, + listbox: { + expectedClassName: menuUnstyledClasses.listbox, + }, + }, + skip: ['reactTestRenderer', 'propsSpread', 'componentProp', 'componentsProp'], + })); + + describe('keyboard navigation', () => { + it('changes the highlighted item using the arrow keys', () => { + const { getByTestId } = render( + + 1 + 2 + 3 + , + ); + + const item1 = getByTestId('item-1'); + const item2 = getByTestId('item-2'); + const item3 = getByTestId('item-3'); + + act(() => { + item1.focus(); + }); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + expect(document.activeElement).to.equal(item2); + + fireEvent.keyDown(item2, { key: 'ArrowDown' }); + expect(document.activeElement).to.equal(item3); + + fireEvent.keyDown(item3, { key: 'ArrowUp' }); + expect(document.activeElement).to.equal(item2); + }); + + it('changes the highlighted item using the Home and End keys', () => { + const { getByTestId } = render( + + 1 + 2 + 3 + , + ); + + const item1 = getByTestId('item-1'); + const item3 = getByTestId('item-3'); + + act(() => { + item1.focus(); + }); + + fireEvent.keyDown(item1, { key: 'End' }); + expect(document.activeElement).to.equal(getByTestId('item-3')); + + fireEvent.keyDown(item3, { key: 'Home' }); + expect(document.activeElement).to.equal(getByTestId('item-1')); + }); + + it('includes disabled items during keyboard navigation', () => { + const { getByTestId } = render( + + 1 + + 2 + + , + ); + + const item1 = getByTestId('item-1'); + const item2 = getByTestId('item-2'); + + act(() => { + item1.focus(); + }); + + fireEvent.keyDown(item1, { key: 'ArrowDown' }); + expect(document.activeElement).to.equal(item2); + + expect(item2).to.have.attribute('aria-disabled', 'true'); + }); + }); +}); diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx new file mode 100644 index 00000000000000..36634c39179035 --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { HTMLElementType, refType } from '@mui/utils'; +import appendOwnerState from '../utils/appendOwnerState'; +import MenuUnstyledContext, { MenuUnstyledContextType } from './MenuUnstyledContext'; +import { + MenuUnstyledListboxSlotProps, + MenuUnstyledOwnerState, + MenuUnstyledProps, + MenuUnstyledRootSlotProps, +} from './MenuUnstyled.types'; +import { getMenuUnstyledUtilityClass } from './menuUnstyledClasses'; +import useMenu from './useMenu'; +import composeClasses from '../composeClasses'; +import PopperUnstyled from '../PopperUnstyled'; +import { WithOptionalOwnerState } from '../utils'; + +function getUtilityClasses(ownerState: MenuUnstyledOwnerState) { + const { open } = ownerState; + const slots = { + root: ['root', open && 'expanded'], + listbox: ['listbox', open && 'expanded'], + }; + + return composeClasses(slots, getMenuUnstyledUtilityClass, {}); +} +/** + * + * Demos: + * + * - [Menus](https://mui.com/components/menus/) + * + * API: + * + * - [MenuUnstyled API](https://mui.com/api/menu-unstyled/) + */ +const MenuUnstyled = React.forwardRef(function MenuUnstyled( + props: MenuUnstyledProps & React.HTMLAttributes, + forwardedRef: React.Ref, +) { + const { + actions, + anchorEl, + children, + className, + component, + components = {}, + componentsProps = {}, + onClose, + open = false, + ...other + } = props; + + const { + registerItem, + unregisterItem, + getListboxProps, + getItemProps, + getItemState, + highlightFirstItem, + highlightLastItem, + } = useMenu({ + open, + onClose, + listboxRef: componentsProps.listbox?.ref, + listboxId: componentsProps.listbox?.id, + }); + + React.useImperativeHandle( + actions, + () => ({ + highlightFirstItem, + highlightLastItem, + }), + [highlightFirstItem, highlightLastItem], + ); + + const ownerState: MenuUnstyledOwnerState = { + ...props, + open, + }; + + const classes = getUtilityClasses(ownerState); + + const Popper = component ?? components.Root ?? PopperUnstyled; + const popperProps: MenuUnstyledRootSlotProps = appendOwnerState( + Popper, + { + ...other, + anchorEl, + open, + keepMounted: true, + role: undefined, + ...componentsProps.root, + className: clsx(classes.root, className, componentsProps.root?.className), + }, + ownerState, + ) as MenuUnstyledRootSlotProps; + + const Listbox = components.Listbox ?? 'ul'; + const listboxProps: WithOptionalOwnerState = appendOwnerState( + Listbox, + { + ...componentsProps.listbox, + ...getListboxProps(), + className: clsx(classes.listbox, componentsProps.listbox?.className), + }, + ownerState, + ); + + const contextValue: MenuUnstyledContextType = { + registerItem, + unregisterItem, + getItemState, + getItemProps, + open, + }; + + return ( + + + {children} + + + ); +}); + +MenuUnstyled.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * A ref with imperative actions. + * It allows to select the first or last menu item. + */ + actions: refType, + /** + * An HTML element, [virtualElement](https://popper.js.org/docs/v2/virtual-elements/), + * or a function that returns either. + * It's used to set the position of the popper. + */ + anchorEl: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.object, + PropTypes.func, + ]), + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * @ignore + */ + component: PropTypes.elementType, + /** + * @ignore + */ + components: PropTypes.shape({ + Listbox: PropTypes.elementType, + Root: PropTypes.elementType, + }), + /** + * @ignore + */ + componentsProps: PropTypes.shape({ + listbox: PropTypes.object, + root: PropTypes.object, + }), + /** + * Triggered when focus leaves the menu and the menu should close. + */ + onClose: PropTypes.func, + /** + * Controls whether the menu is displayed. + * @default false + */ + open: PropTypes.bool, +} as any; + +export default MenuUnstyled; diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts new file mode 100644 index 00000000000000..26fe6d6bae7848 --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts @@ -0,0 +1,63 @@ +import React from 'react'; +import PopperUnstyled, { PopperUnstyledProps } from '../PopperUnstyled'; +import { UseMenuListboxSlotProps } from './useMenu.types'; + +export interface MenuUnstyledComponentsPropsOverrides {} + +export interface MenuUnstyledActions { + highlightFirstItem: () => void; + highlightLastItem: () => void; +} + +export interface MenuUnstyledProps { + /** + * A ref with imperative actions. + * It allows to select the first or last menu item. + */ + actions?: React.Ref; + /** + * An HTML element, [virtualElement](https://popper.js.org/docs/v2/virtual-elements/), + * or a function that returns either. + * It's used to set the position of the popper. + */ + anchorEl?: PopperUnstyledProps['anchorEl']; + children?: React.ReactNode; + className?: string; + component?: React.ElementType; + components?: { + Root?: React.ElementType; + Listbox?: React.ElementType; + }; + componentsProps?: { + root?: Partial> & + MenuUnstyledComponentsPropsOverrides; + listbox?: React.ComponentPropsWithRef<'ul'> & MenuUnstyledComponentsPropsOverrides; + }; + /** + * Triggered when focus leaves the menu and the menu should close. + */ + onClose?: () => void; + /** + * Controls whether the menu is displayed. + * @default false + */ + open?: boolean; +} + +export interface MenuUnstyledOwnerState extends MenuUnstyledProps { + open: boolean; +} + +export type MenuUnstyledRootSlotProps = { + anchorEl: PopperUnstyledProps['anchorEl']; + children?: React.ReactNode; + className: string | undefined; + keepMounted: PopperUnstyledProps['keepMounted']; + open: boolean; + ownerState: MenuUnstyledOwnerState; +}; + +export type MenuUnstyledListboxSlotProps = UseMenuListboxSlotProps & { + className: string | undefined; + ownerState: MenuUnstyledOwnerState; +}; diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyledContext.ts b/packages/mui-base/src/MenuUnstyled/MenuUnstyledContext.ts new file mode 100644 index 00000000000000..fd541855a86802 --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyledContext.ts @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { MenuItemMetadata, MenuItemState } from './useMenu.types'; + +export interface MenuUnstyledContextType { + registerItem: (id: string, metadata: MenuItemMetadata) => void; + unregisterItem: (id: string) => void; + getItemState: (id: string) => MenuItemState | undefined; + getItemProps: ( + id: string, + otherHandlers?: Record>, + ) => Record; + open: boolean; +} + +const MenuUnstyledContext = React.createContext(null); +MenuUnstyledContext.displayName = 'MenuUnstyledContext'; + +export default MenuUnstyledContext; diff --git a/packages/mui-base/src/MenuUnstyled/index.tsx b/packages/mui-base/src/MenuUnstyled/index.tsx new file mode 100644 index 00000000000000..6e09399b560d2b --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/index.tsx @@ -0,0 +1,13 @@ +export { default } from './MenuUnstyled'; + +export { default as MenuUnstyledContext } from './MenuUnstyledContext'; +export * from './MenuUnstyledContext'; + +export { default as menuUnstyledClasses } from './menuUnstyledClasses'; +export * from './menuUnstyledClasses'; + +export * from './MenuUnstyled.types'; + +export { default as useMenu } from './useMenu'; + +export * from './useMenu.types'; diff --git a/packages/mui-base/src/MenuUnstyled/menuUnstyledClasses.ts b/packages/mui-base/src/MenuUnstyled/menuUnstyledClasses.ts new file mode 100644 index 00000000000000..cfe6be9faa8205 --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/menuUnstyledClasses.ts @@ -0,0 +1,22 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface MenuUnstyledClasses { + root: string; + listbox: string; + expanded: string; +} + +export type MenuUnstyledClassKey = keyof MenuUnstyledClasses; + +export function getMenuUnstyledUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuUnstyled', slot); +} + +const menuUnstyledClasses: MenuUnstyledClasses = generateUtilityClasses('MuiMenuUnstyled', [ + 'root', + 'listbox', + 'expanded', +]); + +export default menuUnstyledClasses; diff --git a/packages/mui-base/src/MenuUnstyled/useMenu.ts b/packages/mui-base/src/MenuUnstyled/useMenu.ts new file mode 100644 index 00000000000000..b52e3ac6a0b05c --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/useMenu.ts @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import { + defaultListboxReducer, + ListboxAction, + ListboxState, + useListbox, + ActionTypes, +} from '../ListboxUnstyled'; +import { MenuItemMetadata, MenuItemState, UseMenuParameters } from './useMenu.types'; +import { EventHandlers } from '../utils'; + +function stateReducer( + state: ListboxState, + action: ListboxAction, +): ListboxState { + if ( + action.type === ActionTypes.blur || + action.type === ActionTypes.optionHover || + action.type === ActionTypes.setValue + ) { + return state; + } + + const newState = defaultListboxReducer(state, action); + + if ( + action.type !== ActionTypes.setHighlight && + newState.highlightedValue === null && + action.props.options.length > 0 + ) { + return { + ...newState, + highlightedValue: action.props.options[0], + }; + } + + return newState; +} + +export default function useMenu(parameters: UseMenuParameters) { + const { listboxRef: listboxRefProp, open = false, onClose, listboxId } = parameters; + + const [menuItems, setMenuItems] = React.useState>({}); + + const listboxRef = React.useRef(null); + const handleRef = useForkRef(listboxRef, listboxRefProp); + + const registerItem = React.useCallback((id, metadata) => { + setMenuItems((previousState) => { + const newState = { ...previousState }; + newState[id] = metadata; + return newState; + }); + }, []); + + const unregisterItem = React.useCallback((id) => { + setMenuItems((previousState) => { + const newState = { ...previousState }; + delete newState[id]; + return newState; + }); + }, []); + + const { + getOptionState, + getOptionProps, + getRootProps, + highlightedOption, + setHighlightedValue: setListboxHighlight, + } = useListbox({ + options: Object.keys(menuItems), + isOptionDisabled: (id) => menuItems?.[id]?.disabled || false, + listboxRef: handleRef, + focusManagement: 'DOM', + id: listboxId, + stateReducer, + disabledItemsFocusable: true, + }); + + const highlightFirstItem = React.useCallback(() => { + if (Object.keys(menuItems).length > 0) { + setListboxHighlight(menuItems[Object.keys(menuItems)[0]].id); + } + }, [menuItems, setListboxHighlight]); + + const highlightLastItem = React.useCallback(() => { + if (Object.keys(menuItems).length > 0) { + setListboxHighlight(menuItems[Object.keys(menuItems)[Object.keys(menuItems).length - 1]].id); + } + }, [menuItems, setListboxHighlight]); + + React.useEffect(() => { + if (!open) { + highlightFirstItem(); + } + }, [open, highlightFirstItem]); + + const createHandleKeyDown = (otherHandlers?: EventHandlers) => (e: React.KeyboardEvent) => { + otherHandlers?.onKeyDown?.(e); + if (e.defaultPrevented) { + return; + } + + if (e.key === 'Escape' && open) { + onClose?.(); + } + }; + + const createHandleBlur = (otherHandlers?: EventHandlers) => (e: React.FocusEvent) => { + otherHandlers?.onBlur(e); + + if (!listboxRef.current?.contains(e.relatedTarget)) { + onClose?.(); + } + }; + + React.useEffect(() => { + // set focus to the highlighted item (but prevent stealing focus from other elements on the page) + if (listboxRef.current?.contains(document.activeElement) && highlightedOption !== null) { + menuItems?.[highlightedOption]?.ref.current?.focus(); + } + }, [highlightedOption, menuItems]); + + const getListboxProps = (otherHandlers?: EventHandlers) => ({ + ...otherHandlers, + ...getRootProps({ + ...otherHandlers, + onBlur: createHandleBlur(otherHandlers), + onKeyDown: createHandleKeyDown(otherHandlers), + }), + role: 'menu', + }); + + const getItemState = (id: string): MenuItemState => { + const { disabled, highlighted } = getOptionState(id); + return { disabled, highlighted }; + }; + + React.useDebugValue({ menuItems, highlightedOption }); + + return { + registerItem, + unregisterItem, + menuItems, + getListboxProps, + getItemState, + getItemProps: getOptionProps, + highlightedOption, + highlightFirstItem, + highlightLastItem, + }; +} diff --git a/packages/mui-base/src/MenuUnstyled/useMenu.types.ts b/packages/mui-base/src/MenuUnstyled/useMenu.types.ts new file mode 100644 index 00000000000000..ef2bb8e36ba96a --- /dev/null +++ b/packages/mui-base/src/MenuUnstyled/useMenu.types.ts @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { UseListboxRootSlotProps } from '../ListboxUnstyled'; + +export interface MenuItemMetadata { + id: string; + disabled: boolean; + ref: React.RefObject; +} + +export interface MenuItemState { + disabled: boolean; + highlighted: boolean; +} + +export interface UseMenuParameters { + open?: boolean; + onClose?: () => void; + listboxId?: string; + listboxRef?: React.Ref; +} + +interface UseMenuListboxSlotEventHandlers { + onBlur: React.FocusEventHandler; + onKeyDown: React.KeyboardEventHandler; +} + +export type UseMenuListboxSlotProps = UseListboxRootSlotProps< + Omit & UseMenuListboxSlotEventHandlers +> & { role: React.AriaRole }; diff --git a/packages/mui-base/src/SelectUnstyled/useSelect.ts b/packages/mui-base/src/SelectUnstyled/useSelect.ts index 2fd1d47818c3a8..3d53f86529f63d 100644 --- a/packages/mui-base/src/SelectUnstyled/useSelect.ts +++ b/packages/mui-base/src/SelectUnstyled/useSelect.ts @@ -168,26 +168,20 @@ function useSelect(props: UseSelectParameters) { !open && (action.event.key === 'ArrowUp' || action.event.key === 'ArrowDown') ) { - const optionToSelect = action.props.options[newState.highlightedIndex]; - return { ...newState, - selectedValue: optionToSelect, + selectedValue: newState.highlightedValue, }; } if ( action.type === ActionTypes.blur || - action.type === ActionTypes.setControlledValue || + action.type === ActionTypes.setValue || action.type === ActionTypes.optionsChange ) { - const selectedOptionIndex = action.props.options.findIndex((o) => - action.props.optionComparer(o, newState.selectedValue as SelectOption), - ); - return { ...newState, - highlightedIndex: selectedOptionIndex, + highlightedValue: newState.selectedValue as SelectOption, }; } @@ -250,6 +244,7 @@ function useSelect(props: UseSelectParameters) { getOptionProps: getListboxOptionProps, getOptionState, highlightedOption, + selectedOption: listboxSelectedOption, } = useListbox(useListboxParameters); const getButtonProps = ( @@ -286,7 +281,11 @@ function useSelect(props: UseSelectParameters) { }); }; - React.useDebugValue({ value, open, highlightedOption }); + React.useDebugValue({ + selectedOption: listboxSelectedOption as TValue | null, + open, + highlightedOption, + }); return { buttonActive, diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts index 0e153d58869f2d..8fb95b23cc0440 100644 --- a/packages/mui-base/src/index.d.ts +++ b/packages/mui-base/src/index.d.ts @@ -29,6 +29,14 @@ export * from './FormControlUnstyled'; export { default as InputUnstyled } from './InputUnstyled'; export * from './InputUnstyled'; +export * from './ListboxUnstyled'; + +export { default as MenuUnstyled } from './MenuUnstyled'; +export * from './MenuUnstyled'; + +export { default as MenuItemUnstyled } from './MenuItemUnstyled'; +export * from './MenuItemUnstyled'; + export { default as ModalUnstyled } from './ModalUnstyled'; export * from './ModalUnstyled'; diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js index 0e9290a77487d7..fa476c78476517 100644 --- a/packages/mui-base/src/index.js +++ b/packages/mui-base/src/index.js @@ -28,6 +28,12 @@ export * from './InputUnstyled'; export * from './ListboxUnstyled'; +export { default as MenuUnstyled } from './MenuUnstyled'; +export * from './MenuUnstyled'; + +export { default as MenuItemUnstyled } from './MenuItemUnstyled'; +export * from './MenuItemUnstyled'; + export { default as ModalUnstyled } from './ModalUnstyled'; export * from './ModalUnstyled';