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 (
+
+ );
+});
+
+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 (
+
+
+
+
+ );
+}
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 (
+
+ );
+});
+
+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 (
+
+
+
+
+ );
+}
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 @@
+
+
+
+
\ 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}
+
+
+ );
+}
+
+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}
+
+
+ );
+}
+
+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';