From 1c342d43cc0d5d9e84d80af1a762a6d15bdfe4f6 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 18 Jun 2026 04:23:47 +0800 Subject: [PATCH 01/62] menu preview --- docs/data/material/components/menus/menus.md | 2 +- docs/data/material/pagesApi.js | 16 + docs/pages/experiments/menu-preview.tsx | 371 +++++++ .../menu-preview-checkbox-item-indicator.js | 16 + .../menu-preview-checkbox-item-indicator.json | 51 + .../api/menu-preview-checkbox-item.js | 16 + .../api/menu-preview-checkbox-item.json | 39 + .../api/menu-preview-group-label.js | 16 + .../api/menu-preview-group-label.json | 27 + .../material-ui/api/menu-preview-group.js | 16 + .../material-ui/api/menu-preview-group.json | 28 + .../material-ui/api/menu-preview-item.js | 16 + .../material-ui/api/menu-preview-item.json | 36 + .../material-ui/api/menu-preview-link-item.js | 16 + .../api/menu-preview-link-item.json | 35 + .../material-ui/api/menu-preview-popup.js | 16 + .../material-ui/api/menu-preview-popup.json | 141 +++ .../api/menu-preview-radio-group.js | 16 + .../api/menu-preview-radio-group.json | 39 + .../api/menu-preview-radio-item-indicator.js | 16 + .../menu-preview-radio-item-indicator.json | 51 + .../api/menu-preview-radio-item.js | 16 + .../api/menu-preview-radio-item.json | 37 + .../material-ui/api/menu-preview-separator.js | 16 + .../api/menu-preview-separator.json | 31 + .../api/menu-preview-submenu-popup.js | 16 + .../api/menu-preview-submenu-popup.json | 141 +++ .../api/menu-preview-submenu-root.js | 16 + .../api/menu-preview-submenu-root.json | 24 + .../api/menu-preview-submenu-trigger.js | 16 + .../api/menu-preview-submenu-trigger.json | 38 + .../material-ui/api/menu-preview-trigger.js | 16 + .../material-ui/api/menu-preview-trigger.json | 45 + docs/pages/material-ui/api/menu-preview.js | 16 + docs/pages/material-ui/api/menu-preview.json | 27 + .../menu-preview-checkbox-item-indicator.json | 35 + .../menu-preview-checkbox-item.json | 44 + .../menu-preview-group-label.json | 16 + .../menu-preview-group.json | 17 + .../menu-preview-item/menu-preview-item.json | 35 + .../menu-preview-link-item.json | 32 + .../menu-preview-popup.json | 55 + .../menu-preview-radio-group.json | 31 + .../menu-preview-radio-item-indicator.json | 35 + .../menu-preview-radio-item.json | 36 + .../menu-preview-separator.json | 17 + .../menu-preview-submenu-popup.json | 55 + .../menu-preview-submenu-root.json | 24 + .../menu-preview-submenu-trigger.json | 43 + .../menu-preview-trigger.json | 40 + .../api-docs/menu-preview/menu-preview.json | 25 + .../materialUi/projectSettings.ts | 40 +- packages-internal/core-docs/package.json | 2 +- packages/mui-material/package.json | 1 + .../src/MenuPreview/MenuPreview.spec.tsx | 204 ++++ .../src/MenuPreview/MenuPreview.test.tsx | 972 ++++++++++++++++++ .../src/MenuPreview/MenuPreview.tsx | 142 +++ .../mui-material/src/MenuPreview/apiDocs.d.ts | 49 + .../mui-material/src/MenuPreview/index.d.ts | 66 ++ .../mui-material/src/MenuPreview/index.js | 66 ++ .../src/MenuPreview/menuPreviewClasses.ts | 280 +++++ .../src/MenuPreview/menuPreviewItemShared.tsx | 244 +++++ .../MenuPreview/menuPreviewPopupShared.tsx | 368 +++++++ .../MenuPreview/menuPreviewSharedStyles.ts | 172 ++++ .../src/MenuPreview/menuPreviewUtils.ts | 76 ++ .../MenuPreviewCheckboxItem.tsx | 308 ++++++ .../src/MenuPreviewCheckboxItem/index.d.ts | 10 + .../src/MenuPreviewCheckboxItem/index.js | 6 + .../MenuPreviewCheckboxItemIndicator.tsx | 224 ++++ .../index.d.ts | 10 + .../MenuPreviewCheckboxItemIndicator/index.js | 6 + .../src/MenuPreviewGroup/MenuPreviewGroup.tsx | 174 ++++ .../src/MenuPreviewGroup/index.d.ts | 10 + .../src/MenuPreviewGroup/index.js | 6 + .../MenuPreviewGroupLabel.tsx | 176 ++++ .../src/MenuPreviewGroupLabel/index.d.ts | 10 + .../src/MenuPreviewGroupLabel/index.js | 6 + .../src/MenuPreviewItem/MenuPreviewItem.tsx | 241 +++++ .../src/MenuPreviewItem/index.d.ts | 10 + .../mui-material/src/MenuPreviewItem/index.js | 6 + .../MenuPreviewLinkItem.tsx | 233 +++++ .../src/MenuPreviewLinkItem/index.d.ts | 10 + .../src/MenuPreviewLinkItem/index.js | 6 + .../src/MenuPreviewPopup/MenuPreviewPopup.tsx | 389 +++++++ .../src/MenuPreviewPopup/index.d.ts | 10 + .../src/MenuPreviewPopup/index.js | 6 + .../MenuPreviewRadioGroup.tsx | 228 ++++ .../src/MenuPreviewRadioGroup/index.d.ts | 10 + .../src/MenuPreviewRadioGroup/index.js | 6 + .../MenuPreviewRadioItem.tsx | 256 +++++ .../src/MenuPreviewRadioItem/index.d.ts | 10 + .../src/MenuPreviewRadioItem/index.js | 6 + .../MenuPreviewRadioItemIndicator.tsx | 223 ++++ .../MenuPreviewRadioItemIndicator/index.d.ts | 10 + .../MenuPreviewRadioItemIndicator/index.js | 6 + .../MenuPreviewSeparator.tsx | 184 ++++ .../src/MenuPreviewSeparator/index.d.ts | 10 + .../src/MenuPreviewSeparator/index.js | 6 + .../MenuPreviewSubmenuPopup.tsx | 389 +++++++ .../src/MenuPreviewSubmenuPopup/index.d.ts | 10 + .../src/MenuPreviewSubmenuPopup/index.js | 6 + .../MenuPreviewSubmenuRoot.tsx | 132 +++ .../src/MenuPreviewSubmenuRoot/index.d.ts | 2 + .../src/MenuPreviewSubmenuRoot/index.js | 2 + .../MenuPreviewSubmenuTrigger.tsx | 274 +++++ .../src/MenuPreviewSubmenuTrigger/index.d.ts | 10 + .../src/MenuPreviewSubmenuTrigger/index.js | 6 + .../MenuPreviewTrigger/MenuPreviewTrigger.tsx | 270 +++++ .../src/MenuPreviewTrigger/index.d.ts | 10 + .../src/MenuPreviewTrigger/index.js | 6 + .../mui-material/src/styles/components.ts | 110 ++ packages/mui-material/src/styles/overrides.ts | 30 + packages/mui-material/src/styles/props.ts | 32 + pnpm-lock.yaml | 385 ++++--- pnpm-workspace.yaml | 2 +- 115 files changed, 9012 insertions(+), 133 deletions(-) create mode 100644 docs/pages/experiments/menu-preview.tsx create mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js create mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json create mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item.js create mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item.json create mode 100644 docs/pages/material-ui/api/menu-preview-group-label.js create mode 100644 docs/pages/material-ui/api/menu-preview-group-label.json create mode 100644 docs/pages/material-ui/api/menu-preview-group.js create mode 100644 docs/pages/material-ui/api/menu-preview-group.json create mode 100644 docs/pages/material-ui/api/menu-preview-item.js create mode 100644 docs/pages/material-ui/api/menu-preview-item.json create mode 100644 docs/pages/material-ui/api/menu-preview-link-item.js create mode 100644 docs/pages/material-ui/api/menu-preview-link-item.json create mode 100644 docs/pages/material-ui/api/menu-preview-popup.js create mode 100644 docs/pages/material-ui/api/menu-preview-popup.json create mode 100644 docs/pages/material-ui/api/menu-preview-radio-group.js create mode 100644 docs/pages/material-ui/api/menu-preview-radio-group.json create mode 100644 docs/pages/material-ui/api/menu-preview-radio-item-indicator.js create mode 100644 docs/pages/material-ui/api/menu-preview-radio-item-indicator.json create mode 100644 docs/pages/material-ui/api/menu-preview-radio-item.js create mode 100644 docs/pages/material-ui/api/menu-preview-radio-item.json create mode 100644 docs/pages/material-ui/api/menu-preview-separator.js create mode 100644 docs/pages/material-ui/api/menu-preview-separator.json create mode 100644 docs/pages/material-ui/api/menu-preview-submenu-popup.js create mode 100644 docs/pages/material-ui/api/menu-preview-submenu-popup.json create mode 100644 docs/pages/material-ui/api/menu-preview-submenu-root.js create mode 100644 docs/pages/material-ui/api/menu-preview-submenu-root.json create mode 100644 docs/pages/material-ui/api/menu-preview-submenu-trigger.js create mode 100644 docs/pages/material-ui/api/menu-preview-submenu-trigger.json create mode 100644 docs/pages/material-ui/api/menu-preview-trigger.js create mode 100644 docs/pages/material-ui/api/menu-preview-trigger.json create mode 100644 docs/pages/material-ui/api/menu-preview.js create mode 100644 docs/pages/material-ui/api/menu-preview.json create mode 100644 docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json create mode 100644 docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json create mode 100644 docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json create mode 100644 docs/translations/api-docs/menu-preview-group/menu-preview-group.json create mode 100644 docs/translations/api-docs/menu-preview-item/menu-preview-item.json create mode 100644 docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json create mode 100644 docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json create mode 100644 docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json create mode 100644 docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json create mode 100644 docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json create mode 100644 docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json create mode 100644 docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json create mode 100644 docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json create mode 100644 docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json create mode 100644 docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json create mode 100644 docs/translations/api-docs/menu-preview/menu-preview.json create mode 100644 packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx create mode 100644 packages/mui-material/src/MenuPreview/MenuPreview.test.tsx create mode 100644 packages/mui-material/src/MenuPreview/MenuPreview.tsx create mode 100644 packages/mui-material/src/MenuPreview/apiDocs.d.ts create mode 100644 packages/mui-material/src/MenuPreview/index.d.ts create mode 100644 packages/mui-material/src/MenuPreview/index.js create mode 100644 packages/mui-material/src/MenuPreview/menuPreviewClasses.ts create mode 100644 packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx create mode 100644 packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx create mode 100644 packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts create mode 100644 packages/mui-material/src/MenuPreview/menuPreviewUtils.ts create mode 100644 packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx create mode 100644 packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewCheckboxItem/index.js create mode 100644 packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx create mode 100644 packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js create mode 100644 packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx create mode 100644 packages/mui-material/src/MenuPreviewGroup/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewGroup/index.js create mode 100644 packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx create mode 100644 packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewGroupLabel/index.js create mode 100644 packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx create mode 100644 packages/mui-material/src/MenuPreviewItem/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewItem/index.js create mode 100644 packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx create mode 100644 packages/mui-material/src/MenuPreviewLinkItem/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewLinkItem/index.js create mode 100644 packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx create mode 100644 packages/mui-material/src/MenuPreviewPopup/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewPopup/index.js create mode 100644 packages/mui-material/src/MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx create mode 100644 packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewRadioGroup/index.js create mode 100644 packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx create mode 100644 packages/mui-material/src/MenuPreviewRadioItem/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewRadioItem/index.js create mode 100644 packages/mui-material/src/MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx create mode 100644 packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js create mode 100644 packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx create mode 100644 packages/mui-material/src/MenuPreviewSeparator/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewSeparator/index.js create mode 100644 packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx create mode 100644 packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewSubmenuPopup/index.js create mode 100644 packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx create mode 100644 packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewSubmenuRoot/index.js create mode 100644 packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx create mode 100644 packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js create mode 100644 packages/mui-material/src/MenuPreviewTrigger/MenuPreviewTrigger.tsx create mode 100644 packages/mui-material/src/MenuPreviewTrigger/index.d.ts create mode 100644 packages/mui-material/src/MenuPreviewTrigger/index.js diff --git a/docs/data/material/components/menus/menus.md b/docs/data/material/components/menus/menus.md index 9ae8536ade0a43..a6a90edaae7be2 100644 --- a/docs/data/material/components/menus/menus.md +++ b/docs/data/material/components/menus/menus.md @@ -1,7 +1,7 @@ --- productId: material-ui title: React Menu component -components: Menu, MenuItem, MenuList, ClickAwayListener, Popover, Popper +components: Menu, MenuItem, MenuList, MenuPreview, MenuPreviewTrigger, MenuPreviewPopup, MenuPreviewSubmenuPopup, MenuPreviewItem, MenuPreviewLinkItem, MenuPreviewCheckboxItem, MenuPreviewCheckboxItemIndicator, MenuPreviewRadioGroup, MenuPreviewRadioItem, MenuPreviewRadioItemIndicator, MenuPreviewGroup, MenuPreviewGroupLabel, MenuPreviewSeparator, MenuPreviewSubmenuRoot, MenuPreviewSubmenuTrigger, ClickAwayListener, Popover, Popper githubLabel: 'scope: menu' materialDesign: https://m2.material.io/components/menus waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ diff --git a/docs/data/material/pagesApi.js b/docs/data/material/pagesApi.js index ee76034af9e54a..5ca508d6d9e9eb 100644 --- a/docs/data/material/pagesApi.js +++ b/docs/data/material/pagesApi.js @@ -73,6 +73,22 @@ export default [ { pathname: '/material-ui/api/menu' }, { pathname: '/material-ui/api/menu-item' }, { pathname: '/material-ui/api/menu-list' }, + { pathname: '/material-ui/api/menu-preview' }, + { pathname: '/material-ui/api/menu-preview-checkbox-item' }, + { pathname: '/material-ui/api/menu-preview-checkbox-item-indicator' }, + { pathname: '/material-ui/api/menu-preview-group' }, + { pathname: '/material-ui/api/menu-preview-group-label' }, + { pathname: '/material-ui/api/menu-preview-item' }, + { pathname: '/material-ui/api/menu-preview-link-item' }, + { pathname: '/material-ui/api/menu-preview-popup' }, + { pathname: '/material-ui/api/menu-preview-radio-group' }, + { pathname: '/material-ui/api/menu-preview-radio-item' }, + { pathname: '/material-ui/api/menu-preview-radio-item-indicator' }, + { pathname: '/material-ui/api/menu-preview-separator' }, + { pathname: '/material-ui/api/menu-preview-submenu-popup' }, + { pathname: '/material-ui/api/menu-preview-submenu-root' }, + { pathname: '/material-ui/api/menu-preview-submenu-trigger' }, + { pathname: '/material-ui/api/menu-preview-trigger' }, { pathname: '/material-ui/api/mobile-stepper' }, { pathname: '/material-ui/api/modal' }, { pathname: '/material-ui/api/native-select' }, diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx new file mode 100644 index 00000000000000..44756c616a4809 --- /dev/null +++ b/docs/pages/experiments/menu-preview.tsx @@ -0,0 +1,371 @@ +import * as React from 'react'; +import Container from '@mui/material/Container'; +import CssBaseline from '@mui/material/CssBaseline'; +import Stack from '@mui/material/Stack'; +import Tooltip, { type TooltipProps } from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; +import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import Menu, { + CheckboxItem, + CheckboxItemIndicator, + Group, + GroupLabel, + Item, + LinkItem, + Popup, + RadioGroup, + RadioItem, + RadioItemIndicator, + Separator, + SubmenuPopup, + SubmenuRoot, + SubmenuTrigger, + Trigger, +} from '@mui/material/MenuPreview'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +interface MenuSettings { + modal: boolean; + disabled: boolean; + openOnHover: boolean; +} + +const theme = createTheme({}); + +const defaultSettings: MenuSettings = { + modal: true, + disabled: false, + openOnHover: false, +}; + +const horizontalTooltipProps = { + placement: 'right', + slotProps: { + popper: { + popperOptions: { + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements: ['left', 'right'], + }, + }, + ], + }, + }, + }, +} satisfies Partial; + +interface MenuTooltipChildProps { + onClickCapture?: React.MouseEventHandler; +} + +function MenuTooltip(props: { + title: string; + children: React.ReactElement; +}) { + const { title, children } = props; + const [open, setOpen] = React.useState(false); + + const handleOpen = React.useCallback(() => { + setOpen(true); + }, []); + + const handleClose = React.useCallback(() => { + setOpen(false); + }, []); + + const child = React.cloneElement(children, { + onClickCapture: (event: React.MouseEvent) => { + setOpen(false); + children.props.onClickCapture?.(event); + }, + }); + + return ( + + {child} + + ); +} + +function DisabledTooltip(props: { title: string; children: React.ReactElement }) { + const { title, children } = props; + + return ( + + {/* Disabled menu items need a wrapper for pointer events. This means aria-describedby + is attached to the wrapper, not the disabled menuitem itself. */} + {children} + + ); +} + +function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { + const handleItemClick = React.useCallback((event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log(`${event.currentTarget.textContent} clicked`); + }, []); + + return ( + + } + openOnHover={settings.openOnHover} + > + File + + + New document + Open… + Template gallery + Recent documents + Docs help center + Make a copy + + + Rename document + + + Offline editing unavailable + + + + + + View options + + + + + Document display + + + + 100% + + + + Fit + + + + Page width + + + + Custom zoom unavailable + + + + + + + + Show + + + Ruler + + + + Document outline + + + + Line numbers + + + + Page breaks unavailable + + + + + + + + More tools + + + + Word count + Dictionary + Accessibility settings + + + + + + + + Download + + + + Microsoft Word (.docx) + PDF document (.pdf) + Plain text (.txt) + + + + + + Add-ons unavailable + + + + Marketplace + + + + + ); +} + +function MenuPreviewWithTooltipsDemo() { + return ( + + }> + Tools + + + + New document + + + Open recent + + + Make a copy + + + Import from Drive + + + Share with people + + + + + + + View options + + + + + + Show + + + + Comments + + + + + + Page breaks + + + + + + + + Zoom + + + + + Fit + + + + + + Custom + + + + + + + + + ); +} + +export default function MenuPreviewExperiment() { + const [settings, setSettings] = React.useState(defaultSettings); + + const handleCheckboxChange = (setting: keyof MenuSettings) => { + return (event: React.ChangeEvent) => { + setSettings((currentSettings) => ({ + ...currentSettings, + [setting]: event.target.checked, + })); + }; + }; + + return ( + + + + + + + Menu Preview + +
+ Demo controls + + + +
+
+

Fully-featured menu with submenus, links, radio groups, and checkbox items.

+ +
+
+

Material UI Tooltip integrated with every menu item.

+ +
+ Base UI Menu API +
+
+
+ ); +} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js new file mode 100644 index 00000000000000..dc73f4f5df5157 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json'; +import jsonPageContent from './menu-preview-checkbox-item-indicator.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json new file mode 100644 index 00000000000000..49c213f68f5fef --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json @@ -0,0 +1,51 @@ +{ + "props": { + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "component": { "type": { "name": "elementType" } }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewCheckboxItemIndicator", + "imports": [ + "import MenuPreviewCheckboxItemIndicator from '@mui/material/MenuPreviewCheckboxItemIndicator';" + ], + "slots": [ + { "name": "root", "description": "", "class": "MuiMenuPreviewCheckboxItemIndicator-root" } + ], + "classes": [ + { + "key": "checked", + "className": "Mui-checked", + "description": "State class applied to the root element if `checked={true}`.", + "isGlobal": true + }, + { + "key": "disabled", + "className": "Mui-disabled", + "description": "State class applied to the root element if `disabled={true}`.", + "isGlobal": true + }, + { + "key": "highlighted", + "className": "MuiMenuPreviewCheckboxItemIndicator-highlighted", + "description": "State class applied to the root element if highlighted.", + "isGlobal": false + } + ], + "muiName": "MuiMenuPreviewCheckboxItemIndicator", + "filename": "/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item.js b/docs/pages/material-ui/api/menu-preview-checkbox-item.js new file mode 100644 index 00000000000000..273dae896d9732 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json'; +import jsonPageContent from './menu-preview-checkbox-item.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item.json b/docs/pages/material-ui/api/menu-preview-checkbox-item.json new file mode 100644 index 00000000000000..e1ee7b88166402 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item.json @@ -0,0 +1,39 @@ +{ + "props": { + "checked": { "type": { "name": "bool" } }, + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "closeOnClick": { "type": { "name": "bool" }, "default": "false" }, + "component": { "type": { "name": "elementType" } }, + "defaultChecked": { "type": { "name": "bool" }, "default": "false" }, + "dense": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "disableGutters": { "type": { "name": "bool" }, "default": "false" }, + "divider": { "type": { "name": "bool" }, "default": "false" }, + "label": { "type": { "name": "string" } }, + "nativeButton": { "type": { "name": "bool" } }, + "onChange": { "type": { "name": "func" } }, + "selected": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewCheckboxItem", + "imports": ["import MenuPreviewCheckboxItem from '@mui/material/MenuPreviewCheckboxItem';"], + "slots": [{ "name": "root", "description": "", "class": null }], + "classes": [], + "muiName": "MuiMenuPreviewCheckboxItem", + "filename": "/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-group-label.js b/docs/pages/material-ui/api/menu-preview-group-label.js new file mode 100644 index 00000000000000..b27a7aea1cbebf --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-group-label.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json'; +import jsonPageContent from './menu-preview-group-label.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-group-label.json b/docs/pages/material-ui/api/menu-preview-group-label.json new file mode 100644 index 00000000000000..15ac51dd170114 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-group-label.json @@ -0,0 +1,27 @@ +{ + "props": { + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "component": { "type": { "name": "elementType" } }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewGroupLabel", + "imports": ["import MenuPreviewGroupLabel from '@mui/material/MenuPreviewGroupLabel';"], + "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewGroupLabel-root" }], + "classes": [], + "muiName": "MuiMenuPreviewGroupLabel", + "filename": "/packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-group.js b/docs/pages/material-ui/api/menu-preview-group.js new file mode 100644 index 00000000000000..dd950c267f3918 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-group.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-group/menu-preview-group.json'; +import jsonPageContent from './menu-preview-group.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-group.json b/docs/pages/material-ui/api/menu-preview-group.json new file mode 100644 index 00000000000000..0bbc5b7c39d31d --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-group.json @@ -0,0 +1,28 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "component": { "type": { "name": "elementType" } }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewGroup", + "imports": ["import MenuPreviewGroup from '@mui/material/MenuPreviewGroup';"], + "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewGroup-root" }], + "classes": [], + "muiName": "MuiMenuPreviewGroup", + "filename": "/packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-item.js b/docs/pages/material-ui/api/menu-preview-item.js new file mode 100644 index 00000000000000..874e8da3b3785d --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-item.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-item/menu-preview-item.json'; +import jsonPageContent from './menu-preview-item.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-item.json b/docs/pages/material-ui/api/menu-preview-item.json new file mode 100644 index 00000000000000..80b360babe0bcc --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-item.json @@ -0,0 +1,36 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "closeOnClick": { "type": { "name": "bool" }, "default": "true" }, + "component": { "type": { "name": "elementType" } }, + "dense": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "disableGutters": { "type": { "name": "bool" }, "default": "false" }, + "divider": { "type": { "name": "bool" }, "default": "false" }, + "label": { "type": { "name": "string" } }, + "nativeButton": { "type": { "name": "bool" } }, + "selected": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewItem", + "imports": ["import MenuPreviewItem from '@mui/material/MenuPreviewItem';"], + "slots": [{ "name": "root", "description": "", "class": null }], + "classes": [], + "muiName": "MuiMenuPreviewItem", + "filename": "/packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-link-item.js b/docs/pages/material-ui/api/menu-preview-link-item.js new file mode 100644 index 00000000000000..a4d03702e1b16c --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-link-item.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json'; +import jsonPageContent from './menu-preview-link-item.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-link-item.json b/docs/pages/material-ui/api/menu-preview-link-item.json new file mode 100644 index 00000000000000..c1a50c2e21e20b --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-link-item.json @@ -0,0 +1,35 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "closeOnClick": { "type": { "name": "bool" }, "default": "false" }, + "component": { "type": { "name": "elementType" } }, + "dense": { "type": { "name": "bool" }, "default": "false" }, + "disableGutters": { "type": { "name": "bool" }, "default": "false" }, + "divider": { "type": { "name": "bool" }, "default": "false" }, + "href": { "type": { "name": "string" } }, + "label": { "type": { "name": "string" } }, + "selected": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewLinkItem", + "imports": ["import MenuPreviewLinkItem from '@mui/material/MenuPreviewLinkItem';"], + "slots": [{ "name": "root", "description": "", "class": null }], + "classes": [], + "muiName": "MuiMenuPreviewLinkItem", + "filename": "/packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-popup.js b/docs/pages/material-ui/api/menu-preview-popup.js new file mode 100644 index 00000000000000..1a9fcae0e09e49 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-popup.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json'; +import jsonPageContent from './menu-preview-popup.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-popup.json b/docs/pages/material-ui/api/menu-preview-popup.json new file mode 100644 index 00000000000000..74825d504428e0 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-popup.json @@ -0,0 +1,141 @@ +{ + "props": { + "align": { + "type": { + "name": "enum", + "description": "'center'
| 'end'
| 'start'" + }, + "default": "'start'" + }, + "alignOffset": { + "type": { "name": "union", "description": "func
| number" }, + "default": "0" + }, + "anchor": { + "type": { + "name": "union", + "description": "HTML element
| object
| func" + } + }, + "arrowPadding": { "type": { "name": "number" }, "default": "5" }, + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "collisionAvoidance": { + "type": { + "name": "union", + "description": "{ align?: 'flip'
| 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'flip'
| 'none' }
| { align?: 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'none'
| 'shift' }" + } + }, + "collisionBoundary": { + "type": { + "name": "union", + "description": "'clipping-ancestors'
| HTML element
| Array<HTML element>
| { height: number, width: number, x: number, y: number }" + }, + "default": "'clipping-ancestors'" + }, + "collisionPadding": { + "type": { + "name": "union", + "description": "number
| { bottom?: number, left?: number, right?: number, top?: number }" + }, + "default": "5" + }, + "container": { + "type": { + "name": "union", + "description": "HTML element
| object
| func" + } + }, + "disableAnchorTracking": { "type": { "name": "bool" }, "default": "false" }, + "finalFocus": { + "type": { + "name": "union", + "description": "func
| { current?: HTML element }
| bool" + } + }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "positionMethod": { + "type": { "name": "enum", "description": "'absolute'
| 'fixed'" }, + "default": "'absolute'" + }, + "side": { + "type": { + "name": "enum", + "description": "'bottom'
| 'inline-end'
| 'inline-start'
| 'left'
| 'right'
| 'top'" + }, + "default": "'bottom'" + }, + "sideOffset": { + "type": { "name": "union", "description": "func
| number" }, + "default": "0" + }, + "slotProps": { + "type": { + "name": "shape", + "description": "{ list?: func
| object, paper?: func
| object, popup?: func
| object, portal?: func
| object, positioner?: func
| object }" + } + }, + "slots": { + "type": { + "name": "shape", + "description": "{ list?: elementType, paper?: elementType, popup?: elementType, portal?: elementType, positioner?: elementType }" + } + }, + "sticky": { "type": { "name": "bool" }, "default": "false" }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewPopup", + "imports": ["import MenuPreviewPopup from '@mui/material/MenuPreviewPopup';"], + "slots": [ + { + "name": "portal", + "description": "The component used for the portal.", + "default": "BaseMenu.Portal", + "class": null + }, + { + "name": "positioner", + "description": "The component used for the positioner.", + "default": "BaseMenu.Positioner", + "class": null + }, + { + "name": "popup", + "description": "The component rendered by the Base UI popup.", + "default": "'div'", + "class": null + }, + { + "name": "paper", + "description": "The component used for the Material surface.", + "default": "Paper", + "class": "MuiMenuPreviewPopup-paper" + }, + { + "name": "list", + "description": "The component used for the presentational list wrapper.", + "default": "List", + "class": "MuiMenuPreviewPopup-list" + } + ], + "classes": [ + { + "key": "root", + "className": "MuiMenuPreviewPopup-root", + "description": "Styles applied to the root element.", + "isGlobal": false + } + ], + "muiName": "MuiMenuPreviewPopup", + "filename": "/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-radio-group.js b/docs/pages/material-ui/api/menu-preview-radio-group.js new file mode 100644 index 00000000000000..3b9f531b0034b3 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-radio-group.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json'; +import jsonPageContent from './menu-preview-radio-group.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-radio-group.json b/docs/pages/material-ui/api/menu-preview-radio-group.json new file mode 100644 index 00000000000000..c972d5644f3576 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-radio-group.json @@ -0,0 +1,39 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "component": { "type": { "name": "elementType" } }, + "defaultValue": { "type": { "name": "any" } }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "onChange": { "type": { "name": "func" } }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + }, + "value": { "type": { "name": "any" } } + }, + "name": "MenuPreviewRadioGroup", + "imports": ["import MenuPreviewRadioGroup from '@mui/material/MenuPreviewRadioGroup';"], + "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewRadioGroup-root" }], + "classes": [ + { + "key": "disabled", + "className": "Mui-disabled", + "description": "State class applied to the root element if `disabled={true}`.", + "isGlobal": true + } + ], + "muiName": "MuiMenuPreviewRadioGroup", + "filename": "/packages/mui-material/src/MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js new file mode 100644 index 00000000000000..d44d1d01f14efc --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json'; +import jsonPageContent from './menu-preview-radio-item-indicator.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json new file mode 100644 index 00000000000000..cc0278e7e2ba7d --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json @@ -0,0 +1,51 @@ +{ + "props": { + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "component": { "type": { "name": "elementType" } }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewRadioItemIndicator", + "imports": [ + "import MenuPreviewRadioItemIndicator from '@mui/material/MenuPreviewRadioItemIndicator';" + ], + "slots": [ + { "name": "root", "description": "", "class": "MuiMenuPreviewRadioItemIndicator-root" } + ], + "classes": [ + { + "key": "checked", + "className": "Mui-checked", + "description": "State class applied to the root element if `checked={true}`.", + "isGlobal": true + }, + { + "key": "disabled", + "className": "Mui-disabled", + "description": "State class applied to the root element if `disabled={true}`.", + "isGlobal": true + }, + { + "key": "highlighted", + "className": "MuiMenuPreviewRadioItemIndicator-highlighted", + "description": "State class applied to the root element if highlighted.", + "isGlobal": false + } + ], + "muiName": "MuiMenuPreviewRadioItemIndicator", + "filename": "/packages/mui-material/src/MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item.js b/docs/pages/material-ui/api/menu-preview-radio-item.js new file mode 100644 index 00000000000000..fc06a00097e28e --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-radio-item.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json'; +import jsonPageContent from './menu-preview-radio-item.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item.json b/docs/pages/material-ui/api/menu-preview-radio-item.json new file mode 100644 index 00000000000000..bb264de79c2630 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-radio-item.json @@ -0,0 +1,37 @@ +{ + "props": { + "value": { "type": { "name": "any" }, "required": true }, + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "closeOnClick": { "type": { "name": "bool" }, "default": "false" }, + "component": { "type": { "name": "elementType" } }, + "dense": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "disableGutters": { "type": { "name": "bool" }, "default": "false" }, + "divider": { "type": { "name": "bool" }, "default": "false" }, + "label": { "type": { "name": "string" } }, + "nativeButton": { "type": { "name": "bool" } }, + "selected": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewRadioItem", + "imports": ["import MenuPreviewRadioItem from '@mui/material/MenuPreviewRadioItem';"], + "slots": [{ "name": "root", "description": "", "class": null }], + "classes": [], + "muiName": "MuiMenuPreviewRadioItem", + "filename": "/packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-separator.js b/docs/pages/material-ui/api/menu-preview-separator.js new file mode 100644 index 00000000000000..24e4dee90d1c5d --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-separator.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json'; +import jsonPageContent from './menu-preview-separator.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-separator.json b/docs/pages/material-ui/api/menu-preview-separator.json new file mode 100644 index 00000000000000..f601b79a6a6eba --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-separator.json @@ -0,0 +1,31 @@ +{ + "props": { + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "component": { "type": { "name": "elementType" } }, + "orientation": { + "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, + "default": "'horizontal'" + }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewSeparator", + "imports": ["import MenuPreviewSeparator from '@mui/material/MenuPreviewSeparator';"], + "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewSeparator-root" }], + "classes": [], + "muiName": "MuiMenuPreviewSeparator", + "filename": "/packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-popup.js b/docs/pages/material-ui/api/menu-preview-submenu-popup.js new file mode 100644 index 00000000000000..1feb03c4f823ef --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-submenu-popup.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json'; +import jsonPageContent from './menu-preview-submenu-popup.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-popup.json b/docs/pages/material-ui/api/menu-preview-submenu-popup.json new file mode 100644 index 00000000000000..edb42f9acb55e1 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-submenu-popup.json @@ -0,0 +1,141 @@ +{ + "props": { + "align": { + "type": { + "name": "enum", + "description": "'center'
| 'end'
| 'start'" + }, + "default": "'start'" + }, + "alignOffset": { + "type": { "name": "union", "description": "func
| number" }, + "default": "0" + }, + "anchor": { + "type": { + "name": "union", + "description": "HTML element
| object
| func" + } + }, + "arrowPadding": { "type": { "name": "number" }, "default": "5" }, + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "collisionAvoidance": { + "type": { + "name": "union", + "description": "{ align?: 'flip'
| 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'flip'
| 'none' }
| { align?: 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'none'
| 'shift' }" + } + }, + "collisionBoundary": { + "type": { + "name": "union", + "description": "'clipping-ancestors'
| HTML element
| Array<HTML element>
| { height: number, width: number, x: number, y: number }" + }, + "default": "'clipping-ancestors'" + }, + "collisionPadding": { + "type": { + "name": "union", + "description": "number
| { bottom?: number, left?: number, right?: number, top?: number }" + }, + "default": "5" + }, + "container": { + "type": { + "name": "union", + "description": "HTML element
| object
| func" + } + }, + "disableAnchorTracking": { "type": { "name": "bool" }, "default": "false" }, + "finalFocus": { + "type": { + "name": "union", + "description": "func
| { current?: HTML element }
| bool" + } + }, + "keepMounted": { "type": { "name": "bool" }, "default": "false" }, + "positionMethod": { + "type": { "name": "enum", "description": "'absolute'
| 'fixed'" }, + "default": "'absolute'" + }, + "side": { + "type": { + "name": "enum", + "description": "'bottom'
| 'inline-end'
| 'inline-start'
| 'left'
| 'right'
| 'top'" + }, + "default": "'inline-end'" + }, + "sideOffset": { + "type": { "name": "union", "description": "func
| number" }, + "default": "0" + }, + "slotProps": { + "type": { + "name": "shape", + "description": "{ list?: func
| object, paper?: func
| object, popup?: func
| object, portal?: func
| object, positioner?: func
| object }" + } + }, + "slots": { + "type": { + "name": "shape", + "description": "{ list?: elementType, paper?: elementType, popup?: elementType, portal?: elementType, positioner?: elementType }" + } + }, + "sticky": { "type": { "name": "bool" }, "default": "false" }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewSubmenuPopup", + "imports": ["import MenuPreviewSubmenuPopup from '@mui/material/MenuPreviewSubmenuPopup';"], + "slots": [ + { + "name": "portal", + "description": "The component used for the portal.", + "default": "BaseMenu.Portal", + "class": null + }, + { + "name": "positioner", + "description": "The component used for the positioner.", + "default": "BaseMenu.Positioner", + "class": null + }, + { + "name": "popup", + "description": "The component rendered by the Base UI popup.", + "default": "'div'", + "class": null + }, + { + "name": "paper", + "description": "The component used for the Material surface.", + "default": "Paper", + "class": "MuiMenuPreviewSubmenuPopup-paper" + }, + { + "name": "list", + "description": "The component used for the presentational list wrapper.", + "default": "List", + "class": "MuiMenuPreviewSubmenuPopup-list" + } + ], + "classes": [ + { + "key": "root", + "className": "MuiMenuPreviewSubmenuPopup-root", + "description": "Styles applied to the root element.", + "isGlobal": false + } + ], + "muiName": "MuiMenuPreviewSubmenuPopup", + "filename": "/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-root.js b/docs/pages/material-ui/api/menu-preview-submenu-root.js new file mode 100644 index 00000000000000..5f12e3a1db11de --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-submenu-root.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json'; +import jsonPageContent from './menu-preview-submenu-root.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-root.json b/docs/pages/material-ui/api/menu-preview-submenu-root.json new file mode 100644 index 00000000000000..3ec88efda62789 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-submenu-root.json @@ -0,0 +1,24 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "closeParentOnEsc": { "type": { "name": "bool" }, "default": "false" }, + "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "highlightItemOnHover": { "type": { "name": "bool" }, "default": "true" }, + "loopFocus": { "type": { "name": "bool" }, "default": "true" }, + "onOpenChange": { "type": { "name": "func" } }, + "onOpenChangeComplete": { "type": { "name": "func" } }, + "open": { "type": { "name": "bool" } }, + "orientation": { + "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, + "default": "'vertical'" + } + }, + "name": "MenuPreviewSubmenuRoot", + "imports": ["import MenuPreviewSubmenuRoot from '@mui/material/MenuPreviewSubmenuRoot';"], + "classes": [], + "muiName": "MuiMenuPreviewSubmenuRoot", + "filename": "/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-trigger.js b/docs/pages/material-ui/api/menu-preview-submenu-trigger.js new file mode 100644 index 00000000000000..4d4d3bcc553a57 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-submenu-trigger.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json'; +import jsonPageContent from './menu-preview-submenu-trigger.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-trigger.json b/docs/pages/material-ui/api/menu-preview-submenu-trigger.json new file mode 100644 index 00000000000000..387fe73eb537b9 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-submenu-trigger.json @@ -0,0 +1,38 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "closeDelay": { "type": { "name": "number" }, "default": "0" }, + "component": { "type": { "name": "elementType" } }, + "delay": { "type": { "name": "number" }, "default": "100" }, + "dense": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "disableGutters": { "type": { "name": "bool" }, "default": "false" }, + "divider": { "type": { "name": "bool" }, "default": "false" }, + "label": { "type": { "name": "string" } }, + "nativeButton": { "type": { "name": "bool" } }, + "openOnHover": { "type": { "name": "bool" } }, + "selected": { "type": { "name": "bool" }, "default": "false" }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewSubmenuTrigger", + "imports": ["import MenuPreviewSubmenuTrigger from '@mui/material/MenuPreviewSubmenuTrigger';"], + "slots": [{ "name": "root", "description": "", "class": null }], + "classes": [], + "muiName": "MuiMenuPreviewSubmenuTrigger", + "filename": "/packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview-trigger.js b/docs/pages/material-ui/api/menu-preview-trigger.js new file mode 100644 index 00000000000000..623e31aedb5914 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-trigger.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json'; +import jsonPageContent from './menu-preview-trigger.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview-trigger.json b/docs/pages/material-ui/api/menu-preview-trigger.json new file mode 100644 index 00000000000000..96aca0e828262b --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview-trigger.json @@ -0,0 +1,45 @@ +{ + "props": { + "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, + "className": { "type": { "name": "string" } }, + "closeDelay": { "type": { "name": "number" }, "default": "0" }, + "component": { "type": { "name": "elementType" } }, + "delay": { "type": { "name": "number" }, "default": "100" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "nativeButton": { "type": { "name": "bool" } }, + "openOnHover": { "type": { "name": "bool" } }, + "slotProps": { + "type": { "name": "shape", "description": "{ root?: func
| object }" } + }, + "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, + "style": { "type": { "name": "object" } }, + "sx": { + "type": { + "name": "union", + "description": "Array<func
| object
| bool>
| func
| object" + }, + "additionalInfo": { "sx": true } + } + }, + "name": "MenuPreviewTrigger", + "imports": ["import MenuPreviewTrigger from '@mui/material/MenuPreviewTrigger';"], + "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewTrigger-root" }], + "classes": [ + { + "key": "disabled", + "className": "Mui-disabled", + "description": "State class applied to the root element if `disabled={true}`.", + "isGlobal": true + }, + { + "key": "open", + "className": "Mui-open", + "description": "State class applied to the root element if the menu is open.", + "isGlobal": true + } + ], + "muiName": "MuiMenuPreviewTrigger", + "filename": "/packages/mui-material/src/MenuPreviewTrigger/MenuPreviewTrigger.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/pages/material-ui/api/menu-preview.js b/docs/pages/material-ui/api/menu-preview.js new file mode 100644 index 00000000000000..3cb1b61dd994ee --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { ApiPage } from '@mui/internal-core-docs/ApiPage'; +import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; +import translation from 'docs/translations/api-docs/menu-preview/menu-preview.json'; +import jsonPageContent from './menu-preview.json'; + +export default function Page(props) { + const { descriptions } = props; + return ; +} + +export async function getStaticProps() { + const descriptions = mapApiPageTranslation(translation); + + return { props: { descriptions } }; +} diff --git a/docs/pages/material-ui/api/menu-preview.json b/docs/pages/material-ui/api/menu-preview.json new file mode 100644 index 00000000000000..084edc4242f118 --- /dev/null +++ b/docs/pages/material-ui/api/menu-preview.json @@ -0,0 +1,27 @@ +{ + "props": { + "children": { "type": { "name": "node" } }, + "closeParentOnEsc": { "type": { "name": "bool" }, "default": "false" }, + "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, + "disabled": { "type": { "name": "bool" }, "default": "false" }, + "highlightItemOnHover": { "type": { "name": "bool" }, "default": "true" }, + "loopFocus": { "type": { "name": "bool" }, "default": "true" }, + "modal": { "type": { "name": "bool" }, "default": "true" }, + "onOpenChange": { "type": { "name": "func" } }, + "onOpenChangeComplete": { "type": { "name": "func" } }, + "open": { "type": { "name": "bool" } }, + "orientation": { + "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, + "default": "'vertical'" + } + }, + "name": "MenuPreview", + "imports": ["import MenuPreview from '@mui/material/MenuPreview';"], + "classes": [], + "spread": true, + "themeDefaultProps": null, + "muiName": "MuiMenuPreview", + "filename": "/packages/mui-material/src/MenuPreview/MenuPreview.tsx", + "inheritance": null, + "demos": "" +} diff --git a/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json b/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json new file mode 100644 index 00000000000000..2849161fdc3460 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json @@ -0,0 +1,35 @@ +{ + "componentDescription": "", + "propDescriptions": { + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "component": { "description": "The component used for the root node." }, + "keepMounted": { + "description": "Whether to keep the HTML element in the DOM when the checkbox item is not checked." + }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": { + "checked": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "checked={true}" + }, + "disabled": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "disabled={true}" + }, + "highlighted": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "highlighted" + } + }, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json b/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json new file mode 100644 index 00000000000000..fc921efed518e8 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json @@ -0,0 +1,44 @@ +{ + "componentDescription": "", + "propDescriptions": { + "checked": { + "description": "Whether the checkbox item is currently ticked.
To render an uncontrolled checkbox item, use the defaultChecked prop instead." + }, + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, + "component": { "description": "The component used for the root node." }, + "defaultChecked": { + "description": "Whether the checkbox item is initially ticked.
To render a controlled checkbox item, use the checked prop instead." + }, + "dense": { + "description": "If true, compact vertical padding designed for keyboard and mouse input is used." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "disableGutters": { + "description": "If true, the left and right padding is removed." + }, + "divider": { + "description": "If true, a 1px light border is added to the bottom of the menu item." + }, + "label": { + "description": "Overrides the text label to use when the item is matched during keyboard text navigation." + }, + "nativeButton": { + "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." + }, + "onChange": { + "description": "Event handler called when the checkbox item is ticked or unticked." + }, + "selected": { "description": "If true, the component is selected." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json b/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json new file mode 100644 index 00000000000000..8c92b392b4a3d8 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json @@ -0,0 +1,16 @@ +{ + "componentDescription": "", + "propDescriptions": { + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "component": { "description": "The component used for the root node." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-group/menu-preview-group.json b/docs/translations/api-docs/menu-preview-group/menu-preview-group.json new file mode 100644 index 00000000000000..8743d2e26e1f58 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-group/menu-preview-group.json @@ -0,0 +1,17 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "component": { "description": "The component used for the root node." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-item/menu-preview-item.json b/docs/translations/api-docs/menu-preview-item/menu-preview-item.json new file mode 100644 index 00000000000000..552a6e719d95ae --- /dev/null +++ b/docs/translations/api-docs/menu-preview-item/menu-preview-item.json @@ -0,0 +1,35 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, + "component": { "description": "The component used for the root node." }, + "dense": { + "description": "If true, compact vertical padding designed for keyboard and mouse input is used." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "disableGutters": { + "description": "If true, the left and right padding is removed." + }, + "divider": { + "description": "If true, a 1px light border is added to the bottom of the menu item." + }, + "label": { + "description": "Overrides the text label to use when the item is matched during keyboard text navigation." + }, + "nativeButton": { + "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." + }, + "selected": { "description": "If true, the component is selected." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json b/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json new file mode 100644 index 00000000000000..79bd5fbfce859a --- /dev/null +++ b/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json @@ -0,0 +1,32 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, + "component": { "description": "The component used for the root node." }, + "dense": { + "description": "If true, compact vertical padding designed for keyboard and mouse input is used." + }, + "disableGutters": { + "description": "If true, the left and right padding is removed." + }, + "divider": { + "description": "If true, a 1px light border is added to the bottom of the menu item." + }, + "href": { "description": "The URL that the link item points to." }, + "label": { + "description": "Overrides the text label to use when the item is matched during keyboard text navigation." + }, + "selected": { "description": "If true, the component is selected." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json b/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json new file mode 100644 index 00000000000000..3c92b01c458026 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json @@ -0,0 +1,55 @@ +{ + "componentDescription": "", + "propDescriptions": { + "align": { "description": "How to align the popup relative to the specified side." }, + "alignOffset": { "description": "Additional offset along the alignment axis in pixels." }, + "anchor": { + "description": "An element to position the popup against.
By default, the popup is positioned against the trigger." + }, + "arrowPadding": { + "description": "Minimum distance to maintain between the arrow and the edges of the popup." + }, + "children": { "description": "The menu items." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the Base UI popup element." }, + "collisionAvoidance": { + "description": "Determines how to handle collisions when positioning the popup." + }, + "collisionBoundary": { + "description": "An element or a rectangle that delimits the area that the popup is confined to." + }, + "collisionPadding": { + "description": "Additional space to maintain from the edge of the collision boundary." + }, + "container": { "description": "The container element to portal the popup into." }, + "disableAnchorTracking": { + "description": "Whether to disable the popup from tracking layout shifts of its positioning anchor." + }, + "finalFocus": { "description": "Determines the element to focus when the menu is closed." }, + "keepMounted": { + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + }, + "positionMethod": { + "description": "Determines which CSS position property to use." + }, + "side": { "description": "Which side of the anchor element to align the popup against." }, + "sideOffset": { "description": "Distance between the anchor and the popup in pixels." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "sticky": { + "description": "Whether to maintain the popup in the viewport after the anchor element was scrolled out of view." + }, + "style": { "description": "Styles applied to the Base UI popup element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": { "root": { "description": "Styles applied to the root element." } }, + "slotDescriptions": { + "list": "The component used for the presentational list wrapper.", + "paper": "The component used for the Material surface.", + "popup": "The component rendered by the Base UI popup.", + "portal": "The component used for the portal.", + "positioner": "The component used for the positioner." + } +} diff --git a/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json b/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json new file mode 100644 index 00000000000000..8d6137b9a1f39a --- /dev/null +++ b/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json @@ -0,0 +1,31 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "component": { "description": "The component used for the root node." }, + "defaultValue": { + "description": "The uncontrolled value of the radio item that should be initially selected." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "onChange": { "description": "Function called when the selected value changes." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + }, + "value": { + "description": "The controlled value of the radio item that should be currently selected." + } + }, + "classDescriptions": { + "disabled": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "disabled={true}" + } + }, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json b/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json new file mode 100644 index 00000000000000..faa8d5c3697420 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json @@ -0,0 +1,35 @@ +{ + "componentDescription": "", + "propDescriptions": { + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "component": { "description": "The component used for the root node." }, + "keepMounted": { + "description": "Whether to keep the HTML element in the DOM when the radio item is inactive." + }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": { + "checked": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "checked={true}" + }, + "disabled": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "disabled={true}" + }, + "highlighted": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "highlighted" + } + }, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json b/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json new file mode 100644 index 00000000000000..03866a7e3ba528 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json @@ -0,0 +1,36 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, + "component": { "description": "The component used for the root node." }, + "dense": { + "description": "If true, compact vertical padding designed for keyboard and mouse input is used." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "disableGutters": { + "description": "If true, the left and right padding is removed." + }, + "divider": { + "description": "If true, a 1px light border is added to the bottom of the menu item." + }, + "label": { + "description": "Overrides the text label to use when the item is matched during keyboard text navigation." + }, + "nativeButton": { + "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." + }, + "selected": { "description": "If true, the component is selected." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + }, + "value": { "description": "Value of the radio item." } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json b/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json new file mode 100644 index 00000000000000..39ea1cd2fc4e6b --- /dev/null +++ b/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json @@ -0,0 +1,17 @@ +{ + "componentDescription": "", + "propDescriptions": { + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "component": { "description": "The component used for the root node." }, + "orientation": { "description": "The orientation of the separator." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json b/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json new file mode 100644 index 00000000000000..24661184a10744 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json @@ -0,0 +1,55 @@ +{ + "componentDescription": "", + "propDescriptions": { + "align": { "description": "How to align the popup relative to the specified side." }, + "alignOffset": { "description": "Additional offset along the alignment axis in pixels." }, + "anchor": { + "description": "An element to position the popup against.
By default, the popup is positioned against the submenu trigger." + }, + "arrowPadding": { + "description": "Minimum distance to maintain between the arrow and the edges of the popup." + }, + "children": { "description": "The submenu items." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the Base UI popup element." }, + "collisionAvoidance": { + "description": "Determines how to handle collisions when positioning the popup." + }, + "collisionBoundary": { + "description": "An element or a rectangle that delimits the area that the popup is confined to." + }, + "collisionPadding": { + "description": "Additional space to maintain from the edge of the collision boundary." + }, + "container": { "description": "The container element to portal the popup into." }, + "disableAnchorTracking": { + "description": "Whether to disable the popup from tracking layout shifts of its positioning anchor." + }, + "finalFocus": { "description": "Determines the element to focus when the menu is closed." }, + "keepMounted": { + "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." + }, + "positionMethod": { + "description": "Determines which CSS position property to use." + }, + "side": { "description": "Which side of the anchor element to align the popup against." }, + "sideOffset": { "description": "Distance between the anchor and the popup in pixels." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "sticky": { + "description": "Whether to maintain the popup in the viewport after the anchor element was scrolled out of view." + }, + "style": { "description": "Styles applied to the Base UI popup element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": { "root": { "description": "Styles applied to the root element." } }, + "slotDescriptions": { + "list": "The component used for the presentational list wrapper.", + "paper": "The component used for the Material surface.", + "popup": "The component rendered by the Base UI popup.", + "portal": "The component used for the portal.", + "positioner": "The component used for the positioner." + } +} diff --git a/docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json b/docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json new file mode 100644 index 00000000000000..ff6614738e49c7 --- /dev/null +++ b/docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json @@ -0,0 +1,24 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the submenu." }, + "closeParentOnEsc": { + "description": "When in a submenu, determines whether pressing the Escape key closes the entire menu." + }, + "defaultOpen": { + "description": "Whether the submenu is initially open.
To render a controlled submenu, use the open prop instead." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "highlightItemOnHover": { + "description": "Whether moving the pointer over items should highlight them." + }, + "loopFocus": { "description": "Whether to loop keyboard focus back to the first item." }, + "onOpenChange": { "description": "Event handler called when the submenu is opened or closed." }, + "onOpenChangeComplete": { + "description": "Event handler called after any animations complete when the submenu is opened or closed." + }, + "open": { "description": "Whether the submenu is currently open." }, + "orientation": { "description": "The visual orientation of the submenu." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json b/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json new file mode 100644 index 00000000000000..e577f74db637bf --- /dev/null +++ b/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json @@ -0,0 +1,43 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the component." }, + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "closeDelay": { + "description": "How long to wait before closing the submenu that was opened on hover, in milliseconds.
Requires the openOnHover prop." + }, + "component": { "description": "The component used for the root node." }, + "delay": { + "description": "How long to wait before the submenu may be opened on hover, in milliseconds.
Requires the openOnHover prop." + }, + "dense": { + "description": "If true, compact vertical padding designed for keyboard and mouse input is used." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "disableGutters": { + "description": "If true, the left and right padding is removed." + }, + "divider": { + "description": "If true, a 1px light border is added to the bottom of the menu item." + }, + "label": { + "description": "Overrides the text label to use when the item is matched during keyboard text navigation." + }, + "nativeButton": { + "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." + }, + "openOnHover": { + "description": "Whether the submenu should also open when the trigger is hovered." + }, + "selected": { "description": "If true, the component is selected." }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": {}, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json b/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json new file mode 100644 index 00000000000000..1b289124ab482c --- /dev/null +++ b/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json @@ -0,0 +1,40 @@ +{ + "componentDescription": "", + "propDescriptions": { + "classes": { "description": "Override or extend the styles applied to the component." }, + "className": { "description": "CSS class applied to the element." }, + "closeDelay": { + "description": "How long to wait before closing the menu that was opened on hover, in milliseconds.
Requires the openOnHover prop." + }, + "component": { "description": "The component used for the root node." }, + "delay": { + "description": "How long to wait before the menu may be opened on hover, in milliseconds.
Requires the openOnHover prop." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "nativeButton": { + "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." + }, + "openOnHover": { + "description": "Whether the menu should also open when the trigger is hovered." + }, + "slotProps": { "description": "The props used for each slot inside." }, + "slots": { "description": "The components used for each slot inside." }, + "style": { "description": "Styles applied to the root element." }, + "sx": { + "description": "The system prop that allows defining system overrides as well as additional CSS styles." + } + }, + "classDescriptions": { + "disabled": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "disabled={true}" + }, + "open": { + "description": "State class applied to {{nodeName}} if {{conditions}}.", + "nodeName": "the root element", + "conditions": "the menu is open" + } + }, + "slotDescriptions": { "root": "" } +} diff --git a/docs/translations/api-docs/menu-preview/menu-preview.json b/docs/translations/api-docs/menu-preview/menu-preview.json new file mode 100644 index 00000000000000..31e7124fe6bf7f --- /dev/null +++ b/docs/translations/api-docs/menu-preview/menu-preview.json @@ -0,0 +1,25 @@ +{ + "componentDescription": "", + "propDescriptions": { + "children": { "description": "The content of the menu." }, + "closeParentOnEsc": { + "description": "When in a submenu, determines whether pressing the Escape key closes the entire menu." + }, + "defaultOpen": { + "description": "Whether the menu is initially open.
To render a controlled menu, use the open prop instead." + }, + "disabled": { "description": "Whether the component should ignore user interaction." }, + "highlightItemOnHover": { + "description": "Whether moving the pointer over items should highlight them." + }, + "loopFocus": { "description": "Whether to loop keyboard focus back to the first item." }, + "modal": { "description": "Determines if the menu enters a modal state when open." }, + "onOpenChange": { "description": "Event handler called when the menu is opened or closed." }, + "onOpenChangeComplete": { + "description": "Event handler called after any animations complete when the menu is opened or closed." + }, + "open": { "description": "Whether the menu is currently open." }, + "orientation": { "description": "The visual orientation of the menu." } + }, + "classDescriptions": {} +} diff --git a/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts b/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts index a52477fe5e64cd..e1a85bdc147ba5 100644 --- a/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts +++ b/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts @@ -1,6 +1,6 @@ import path from 'path'; import { LANGUAGES } from '@mui/internal-core-docs/constants'; -import { ProjectSettings, findApiPages } from '@mui/internal-api-docs-builder'; +import { ProjectSettings, findApiPages, toGitHubPath } from '@mui/internal-api-docs-builder'; import generateUtilityClass, { isGlobalState } from '@mui/utils/generateUtilityClass'; import { getMaterialUiComponentInfo } from './getMaterialUiComponentInfo'; @@ -13,6 +13,42 @@ const generateClassName = (componentName: string, slot: string, globalStatePrefi return generateUtilityClass(componentName, slot, globalStatePrefix); }; +const getComponentImports = (name: string, filename: string) => { + const githubPath = toGitHubPath(filename); + const directory = githubPath.match(/\/packages\/mui-material\/src\/([^/]+)\//)?.[1]; + + if (directory?.startsWith('MenuPreview')) { + return [`import ${name} from '@mui/material/${directory}';`]; + } + + const rootImportPath = githubPath.replace( + /\/packages\/mui(?:-(.+?))?\/src\/.*/, + (match, pkg) => `@mui/${pkg}`, + ); + + const subdirectoryImportPath = githubPath.replace( + /\/packages\/mui(?:-(.+?))?\/src\/([^\\/]+)\/.*/, + (match, pkg, subdirectory) => `@mui/${pkg}/${subdirectory}`, + ); + + let namedImportName = name; + const defaultImportName = name; + + if (githubPath.includes('Unstable_')) { + namedImportName = `Unstable_${name} as ${name}`; + } + + const useNamedImports = rootImportPath === '@mui/base'; + + const subpathImport = useNamedImports + ? `import { ${namedImportName} } from '${subdirectoryImportPath}';` + : `import ${defaultImportName} from '${subdirectoryImportPath}';`; + + const rootImport = `import { ${namedImportName} } from '${rootImportPath}';`; + + return [subpathImport, rootImport]; +}; + export const projectSettings: ProjectSettings = { output: { apiManifestPath: path.join(process.cwd(), 'docs/data/material/pagesApi.js'), @@ -23,6 +59,7 @@ export const projectSettings: ProjectSettings = { rootPath: path.join(process.cwd(), 'packages/mui-material'), entryPointPath: [ 'src/index.d.ts', + 'src/MenuPreview/apiDocs.d.ts', 'src/PigmentStack/PigmentStack.tsx', 'src/PigmentContainer/PigmentContainer.tsx', 'src/PigmentGrid/PigmentGrid.tsx', @@ -41,6 +78,7 @@ export const projectSettings: ProjectSettings = { return filename.match(/(ThemeProvider|CssVarsProvider|DefaultPropsProvider)/) !== null; }, translationPagesDirectory: 'docs/translations/api-docs', + getComponentImports, generateClassName, isGlobalClassName: isGlobalState, // #host-reference diff --git a/packages-internal/core-docs/package.json b/packages-internal/core-docs/package.json index 5efc02b42742bf..009ba5bff1d84d 100644 --- a/packages-internal/core-docs/package.json +++ b/packages-internal/core-docs/package.json @@ -53,7 +53,7 @@ "next": "15.5.18" }, "peerDependencies": { - "@base-ui/react": "^1", + "@base-ui/react": "^1.5.0", "@docsearch/react": "catalog:docs", "@emotion/cache": "catalog:docs", "@emotion/react": "catalog:docs", diff --git a/packages/mui-material/package.json b/packages/mui-material/package.json index cb64fd02e9c477..0c7f5375c5ef03 100644 --- a/packages/mui-material/package.json +++ b/packages/mui-material/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@babel/runtime": "^7.29.2", + "@base-ui/react": "^1.5.0", "@mui/core-downloads-tracker": "workspace:^", "@mui/system": "workspace:^", "@mui/types": "workspace:^", diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx new file mode 100644 index 00000000000000..70b8079302e562 --- /dev/null +++ b/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx @@ -0,0 +1,204 @@ +import * as React from 'react'; +import { expectType } from '@mui/types'; +import Menu, { + CheckboxItem, + CheckboxItemIndicator, + Group, + GroupLabel, + Item, + LinkItem, + Popup, + RadioGroup, + RadioItem, + RadioItemIndicator, + Separator, + SubmenuPopup, + SubmenuRoot, + SubmenuTrigger, + Trigger, +} from '@mui/material/MenuPreview'; +import { createTheme } from '@mui/material/styles'; +// @ts-expect-error MenuPreview is intentionally not exported from the root barrel for this POC. +import { MenuPreview as RootBarrelMenuPreview } from '@mui/material'; + +function MenuPreviewComposition() { + return ( + { + expectType(open); + eventDetails.cancel(); + eventDetails.preventUnmountOnClose(); + }} + > + + Options + + + + Group + + Item + + Profile + { + expectType(event); + expectType(checked); + eventDetails.cancel(); + }} + > + + Checkbox + + { + expectType(event); + expectType(value); + eventDetails.cancel(); + }} + > + + + One + + + + { + expectType(open); + eventDetails.cancel(); + }} + > + + More + + + Nested + + + + + + ); +} + +createTheme({ + components: { + MuiMenuPreview: { + defaultProps: { + modal: false, + }, + }, + MuiMenuPreviewSubmenuRoot: { + defaultProps: { + defaultOpen: false, + }, + }, + MuiMenuPreviewItem: { + defaultProps: { + dense: true, + }, + styleOverrides: { + root: {}, + highlighted: {}, + }, + variants: [ + { + props: { selected: true }, + style: {}, + }, + ], + }, + MuiMenuPreviewPopup: { + defaultProps: { + align: 'start', + }, + styleOverrides: { + root: {}, + paper: {}, + list: {}, + }, + variants: [ + { + props: { align: 'start' }, + style: {}, + }, + ], + }, + MuiMenuPreviewRadioItem: { + variants: [ + { + props: { value: 'small' }, + style: {}, + }, + ], + }, + MuiMenuPreviewLinkItem: { + variants: [ + { + props: { href: '/profile' }, + style: {}, + }, + ], + }, + }, +}); + +; + +; + +} +> + Options +; + + + Options +; + +, + }, + }} +/>; diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx new file mode 100644 index 00000000000000..7b7155e31b7ec4 --- /dev/null +++ b/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx @@ -0,0 +1,972 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; +import Tooltip from '@mui/material/Tooltip'; +import MenuPreview, { + MenuPreviewCheckboxItem, + MenuPreviewCheckboxItemIndicator, + MenuPreviewGroup, + MenuPreviewGroupLabel, + MenuPreviewItem, + MenuPreviewLinkItem, + MenuPreviewPopup, + MenuPreviewRadioGroup, + MenuPreviewRadioItem, + MenuPreviewRadioItemIndicator, + MenuPreviewSeparator, + MenuPreviewSubmenuPopup, + MenuPreviewSubmenuRoot, + MenuPreviewSubmenuTrigger, + MenuPreviewTrigger, + menuPreviewCheckboxItemClasses, + menuPreviewItemClasses, + menuPreviewPopupClasses, + menuPreviewTriggerClasses, +} from '@mui/material/MenuPreview'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +describe('', () => { + const { render } = createRenderer(); + type User = ReturnType['user']; + + async function expectTooltipOnHover(user: User, element: Element, title: string) { + await user.hover(element); + + expect(await screen.findByRole('tooltip')).to.have.text(title); + + await user.unhover(element); + + await waitFor(() => { + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + } + + it('opens from the trigger and keeps Menu.Popup as the semantic menu root', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger).to.have.class(menuPreviewTriggerClasses.root); + + await user.click(trigger); + + const menu = await screen.findByRole('menu'); + expect(menu).to.have.class(menuPreviewPopupClasses.root); + expect(screen.getByTestId('paper')).to.have.class(menuPreviewPopupClasses.paper); + + const list = screen.getByTestId('paper').querySelector(`.${menuPreviewPopupClasses.list}`); + expect(list).not.to.equal(null); + expect(list!.tagName).to.equal('DIV'); + + expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( + menuPreviewItemClasses.root, + ); + }); + + it('does not render the trigger as a link when href is passed by a JS caller', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger.tagName).to.equal('BUTTON'); + expect(trigger).not.to.have.attribute('href'); + + await user.click(trigger); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it('supports component props, slotProps, classes, styleOverrides, and variants', async () => { + const theme = createTheme({ + components: { + MuiMenuPreviewTrigger: { + defaultProps: { + variant: 'outlined', + }, + }, + MuiMenuPreviewPopup: { + styleOverrides: { + paper: { + minWidth: 128, + }, + }, + variants: [ + { + props: { align: 'start' }, + style: { + '--MenuPreviewPopup-variant': '"applied"', + }, + }, + ], + }, + MuiMenuPreviewItem: { + variants: [ + { + props: { selected: true }, + style: { + fontWeight: 700, + }, + }, + { + props: { disabled: true }, + style: { + '--MenuPreviewItem-disabledVariant': '"applied"', + }, + }, + ], + }, + MuiMenuPreviewCheckboxItem: { + variants: [ + { + props: { checked: true }, + style: { + '--MenuPreviewCheckboxItem-checkedVariant': '"applied"', + }, + }, + ], + }, + MuiMenuPreviewRadioItem: { + variants: [ + { + props: { value: 'small' }, + style: { + '--MenuPreviewRadioItem-valueVariant': '"applied"', + }, + }, + ], + }, + MuiMenuPreviewLinkItem: { + variants: [ + { + props: { href: '/profile' }, + style: { + '--MenuPreviewLinkItem-hrefVariant': '"applied"', + }, + }, + ], + }, + }, + }); + + const { user } = render( + + + Options + + + Profile + + Disabled profile + Checked profile + + Small + + Link profile + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(screen.getByRole('button', { name: 'Options' })).to.have.class('custom-trigger'); + expect(await screen.findByTestId('list')).to.have.class('custom-list'); + expect( + window + .getComputedStyle(screen.getByRole('menu')) + .getPropertyValue('--MenuPreviewPopup-variant'), + ).to.equal('"applied"'); + expect(await screen.findByRole('menuitem', { name: 'Profile' })).to.have.class('custom-item'); + expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( + menuPreviewItemClasses.selected, + ); + expect(screen.getByRole('menuitem', { name: 'Disabled profile' })).to.have.class( + menuPreviewItemClasses.disabled, + ); + expect( + window + .getComputedStyle(screen.getByRole('menuitem', { name: 'Disabled profile' })) + .getPropertyValue('--MenuPreviewItem-disabledVariant'), + ).to.equal('"applied"'); + expect(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })).to.have.class( + menuPreviewCheckboxItemClasses.checked, + ); + expect( + window + .getComputedStyle(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })) + .getPropertyValue('--MenuPreviewCheckboxItem-checkedVariant'), + ).to.equal('"applied"'); + expect( + window + .getComputedStyle(screen.getByRole('menuitemradio', { name: 'Small' })) + .getPropertyValue('--MenuPreviewRadioItem-valueVariant'), + ).to.equal('"applied"'); + expect( + window + .getComputedStyle(screen.getByRole('menuitem', { name: 'Link profile' })) + .getPropertyValue('--MenuPreviewLinkItem-hrefVariant'), + ).to.equal('"applied"'); + }); + + it('composes popup class names', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const menu = await screen.findByRole('menu'); + expect(menu).to.have.class('popup-open'); + expect(menu).to.have.class('popup-side-bottom'); + expect(menu).to.have.class(menuPreviewPopupClasses.root); + }); + + it('does not pass ownerState to host popup slots', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByTestId('popup')).not.to.have.attribute('ownerState'); + }); + + it('derives native button behavior from host root slots', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { user } = render( + + Options + + + Native item + + + Native checkbox + + + + Native radio + + + + + Native submenu trigger + + + Nested + + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger.tagName).to.equal('DIV'); + + trigger.focus(); + await user.keyboard('[Enter]'); + + expect(await screen.findByRole('menuitem', { name: 'Native item' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect(screen.getByRole('menuitemcheckbox', { name: 'Native checkbox' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect(screen.getByRole('menuitemradio', { name: 'Native radio' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect(screen.getByRole('menuitem', { name: 'Native submenu trigger' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect( + error.mock.calls.some(([message]) => String(message).includes('nativeButton')), + ).to.equal(false); + } finally { + error.mockRestore(); + } + }); + + it('allows nativeButton to override root slot inference for custom slots', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const CustomDivRoot = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { ownerState?: unknown } + >(function CustomDivRoot({ ownerState: _ownerState, ...props }, ref) { + return
; + }); + const CustomButtonRoot = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<'button'> & { ownerState?: unknown } + >(function CustomButtonRoot({ ownerState: _ownerState, ...props }, ref) { + return + + Options + + Profile + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + await user.click(await screen.findByRole('menuitem', { name: 'Profile' })); + + await waitFor(() => { + expect(document.activeElement).to.equal(finalFocusRef.current); + }); + }); + + it('returns focus to the trigger on Escape and closes on outside press', async () => { + const { user } = render( + + + + Options + + Profile + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + await user.click(trigger); + await screen.findByRole('menu'); + + await user.keyboard('[Escape]'); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + expect(document.activeElement).to.equal(trigger); + + await user.click(trigger); + await screen.findByRole('menu'); + await user.click(screen.getByRole('button', { name: 'Outside' })); + + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + }); + + it('supports touch trigger interactions', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.pointer({ + keys: '[TouchA]', + target: screen.getByRole('button', { name: 'Options' }), + }); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it('supports modal backdrop behavior', async () => { + const { user } = render( + + + Modal menu + + Profile + + + + Non-modal menu + + Settings + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Modal menu' })); + await screen.findByRole('menu'); + expect(screen.getByTestId('modal-positioner').previousElementSibling).to.have.attribute( + 'role', + 'presentation', + ); + + await user.keyboard('[Escape]'); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + await user.click(screen.getByRole('button', { name: 'Non-modal menu' })); + await screen.findByRole('menu'); + expect(screen.getByTestId('non-modal-positioner').previousElementSibling).to.equal(null); + }); + + it('opens in an RTL tree', async () => { + const { user } = render( +
+ + Options + + Profile + + +
, + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it.skipIf(isJsdom())('applies Base UI positioning attributes in the browser', async () => { + const { user } = render( +
+ + Options + + Profile + + +
, + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const positioner = await screen.findByTestId('positioner'); + expect(positioner).to.have.attribute('data-side', 'bottom'); + expect(positioner).to.have.attribute('data-align', 'start'); + expect(positioner.style.transform).not.to.equal(''); + }); + + it('supports checkbox and radio item state', async () => { + const handleCheckboxChange = spy((event: Event, checked: boolean, eventDetails: any) => { + expect(event).to.be.instanceOf(Event); + expect(checked).to.equal(true); + expect(eventDetails.reason).to.equal('item-press'); + }); + const handleRadioChange = spy((event: Event, value: string, eventDetails: any) => { + expect(event).to.be.instanceOf(Event); + expect(value).to.equal('large'); + expect(eventDetails.reason).to.equal('item-press'); + }); + + const { user } = render( + + Options + + + + Show hidden files + + + + + Small + + + + Large + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const checkbox = await screen.findByRole('menuitemcheckbox', { name: /show hidden files/i }); + expect(checkbox).to.have.attribute('aria-checked', 'false'); + + await user.click(checkbox); + + expect(checkbox).to.have.attribute('aria-checked', 'true'); + expect(checkbox).to.have.class(menuPreviewCheckboxItemClasses.checked); + expect(handleCheckboxChange.callCount).to.equal(1); + + expect(screen.getByRole('menuitemradio', { name: /small/i })).to.have.attribute( + 'aria-checked', + 'true', + ); + expect(screen.getByRole('menuitemradio', { name: /large/i })).to.have.attribute( + 'aria-checked', + 'false', + ); + + await user.click(screen.getByRole('menuitemradio', { name: /large/i })); + + expect(screen.getByRole('menuitemradio', { name: /large/i })).to.have.attribute( + 'aria-checked', + 'true', + ); + expect(handleRadioChange.callCount).to.equal(1); + }); + + it('keeps mounted unchecked indicator marks hidden', () => { + render( + + Options + + + + Show hidden files + + + + + Small + + + + Large + + + + , + ); + + const checkboxIndicator = screen.getByTestId('checkbox-indicator'); + const checkboxIcon = checkboxIndicator.querySelector('[data-mui-menu-preview-indicator-icon]'); + const checkboxMark = checkboxIndicator.querySelector('[data-mui-menu-preview-indicator-mark]'); + expect(checkboxIndicator).to.have.attribute('data-unchecked', ''); + expect(window.getComputedStyle(checkboxIndicator).visibility).to.equal('visible'); + expect(checkboxIcon).not.to.equal(null); + expect(window.getComputedStyle(checkboxIcon!).visibility).to.equal('visible'); + expect(checkboxMark).not.to.equal(null); + expect(window.getComputedStyle(checkboxMark!).visibility).to.equal('hidden'); + + const checkedRadioIndicator = screen.getByTestId('checked-radio-indicator'); + const checkedRadioIcon = checkedRadioIndicator.querySelector( + '[data-mui-menu-preview-indicator-icon]', + ); + const checkedRadioMark = checkedRadioIndicator.querySelector( + '[data-mui-menu-preview-indicator-mark]', + ); + expect(checkedRadioIndicator).to.have.attribute('data-checked', ''); + expect(checkedRadioIcon).not.to.equal(null); + expect(window.getComputedStyle(checkedRadioIcon!).visibility).to.equal('visible'); + expect(checkedRadioMark).not.to.equal(null); + expect(window.getComputedStyle(checkedRadioMark!).visibility).to.equal('visible'); + + const uncheckedRadioIndicator = screen.getByTestId('unchecked-radio-indicator'); + const uncheckedRadioIcon = uncheckedRadioIndicator.querySelector( + '[data-mui-menu-preview-indicator-icon]', + ); + const uncheckedRadioMark = uncheckedRadioIndicator.querySelector( + '[data-mui-menu-preview-indicator-mark]', + ); + expect(uncheckedRadioIndicator).to.have.attribute('data-unchecked', ''); + expect(window.getComputedStyle(uncheckedRadioIndicator).visibility).to.equal('visible'); + expect(uncheckedRadioIcon).not.to.equal(null); + expect(window.getComputedStyle(uncheckedRadioIcon!).visibility).to.equal('visible'); + expect(uncheckedRadioMark).not.to.equal(null); + expect(window.getComputedStyle(uncheckedRadioMark!).visibility).to.equal('hidden'); + }); + + it('supports groups, labels, separators, link items, and submenus', async () => { + const { user } = render( + + Options + + + Account + Profile + + + + More + + Archive + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByText('Account')).not.to.equal(null); + expect(screen.getByRole('separator')).not.to.equal(null); + expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.attribute('href', '/profile'); + expect(screen.getByRole('menuitem', { name: 'More' })).to.not.equal(null); + expect(screen.getByRole('menuitem', { name: 'Archive' })).to.not.equal(null); + }); + + it('supports Material UI Tooltip on enabled item flavors', async () => { + const { user } = render( + + Options + + + New document + + + + + Comments + + + + + + + Fit + + + + + , + ); + + await expectTooltipOnHover( + user, + screen.getByRole('menuitem', { name: 'New document' }), + 'Create a blank document', + ); + await expectTooltipOnHover( + user, + screen.getByRole('menuitemcheckbox', { name: 'Comments' }), + 'Toggle comments', + ); + await expectTooltipOnHover( + user, + screen.getByRole('menuitemradio', { name: 'Fit' }), + 'Fit to viewport', + ); + }); + + it('can close a controlled Material UI Tooltip when a submenu trigger opens', async () => { + interface TooltipChildProps { + onClickCapture?: React.MouseEventHandler; + } + + function ClickClosingTooltip(props: { + title: string; + children: React.ReactElement; + }) { + const { title, children } = props; + const [open, setOpen] = React.useState(false); + + const child = React.cloneElement(children, { + onClickCapture: (event: React.MouseEvent) => { + setOpen(false); + children.props.onClickCapture?.(event); + }, + }); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + > + {child} + + ); + } + + const { user } = render( + + Options + + + + + View options + + + + Comments + + + + , + ); + + const submenuTrigger = screen.getByRole('menuitem', { name: 'View options' }); + + await user.hover(submenuTrigger); + expect(await screen.findByRole('tooltip')).to.have.text('Open view settings'); + + await user.click(submenuTrigger); + + expect(await screen.findByRole('menuitem', { name: 'Comments' })).not.to.equal(null); + await waitFor(() => { + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + }); + + it('supports Material UI Tooltip on disabled items through a non-disabled wrapper', async () => { + const { user } = render( + + Options + + + + Import from Drive + + + + , + ); + + expect(screen.getByRole('menuitem', { name: 'Import from Drive' })).to.have.attribute( + 'aria-disabled', + 'true', + ); + await expectTooltipOnHover( + user, + screen.getByTestId('disabled-item-tooltip-target'), + 'Unavailable while offline', + ); + }); + + it('does not warn when a submenu trigger is disabled', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const { user } = render( + + Options + + + Add-ons unavailable + + Marketplace + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const submenuTrigger = await screen.findByRole('menuitem', { + name: 'Add-ons unavailable', + }); + expect(submenuTrigger).to.have.attribute('aria-disabled', 'true'); + expect( + warn.mock.calls.some(([message]) => + String(message).includes('A disabled element was detected on '), + ), + ).to.equal(false); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.tsx new file mode 100644 index 00000000000000..6ea6ec15b4d7fd --- /dev/null +++ b/packages/mui-material/src/MenuPreview/MenuPreview.tsx @@ -0,0 +1,142 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { useDefaultProps } from '../DefaultPropsProvider'; + +export interface MenuPreviewProps { + /** + * The content of the menu. + */ + children?: React.ReactNode; + /** + * Whether the menu is initially open. + * + * To render a controlled menu, use the `open` prop instead. + * @default false + */ + defaultOpen?: boolean | undefined; + /** + * Whether the menu is currently open. + */ + open?: boolean | undefined; + /** + * Event handler called when the menu is opened or closed. + */ + onOpenChange?: BaseMenu.Root.Props['onOpenChange']; + /** + * Event handler called after any animations complete when the menu is opened or closed. + */ + onOpenChangeComplete?: BaseMenu.Root.Props['onOpenChangeComplete']; + /** + * Determines if the menu enters a modal state when open. + * @default true + */ + modal?: boolean | undefined; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Whether to loop keyboard focus back to the first item. + * @default true + */ + loopFocus?: boolean | undefined; + /** + * Whether moving the pointer over items should highlight them. + * @default true + */ + highlightItemOnHover?: boolean | undefined; + /** + * The visual orientation of the menu. + * @default 'vertical' + */ + orientation?: 'horizontal' | 'vertical' | undefined; + /** + * When in a submenu, determines whether pressing the Escape key closes the entire menu. + * @default false + */ + closeParentOnEsc?: boolean | undefined; +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreview API](https://mui.com/material-ui/api/menu-preview/) + */ +function MenuPreview(props: MenuPreviewProps): React.JSX.Element { + const themedProps = useDefaultProps({ + props, + name: 'MuiMenuPreview', + }); + + return ; +} + +MenuPreview.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the menu. + */ + children: PropTypes.node, + /** + * When in a submenu, determines whether pressing the Escape key closes the entire menu. + * @default false + */ + closeParentOnEsc: PropTypes.bool, + /** + * Whether the menu is initially open. + * + * To render a controlled menu, use the `open` prop instead. + * @default false + */ + defaultOpen: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * Whether moving the pointer over items should highlight them. + * @default true + */ + highlightItemOnHover: PropTypes.bool, + /** + * Whether to loop keyboard focus back to the first item. + * @default true + */ + loopFocus: PropTypes.bool, + /** + * Determines if the menu enters a modal state when open. + * @default true + */ + modal: PropTypes.bool, + /** + * Event handler called when the menu is opened or closed. + */ + onOpenChange: PropTypes.func, + /** + * Event handler called after any animations complete when the menu is opened or closed. + */ + onOpenChangeComplete: PropTypes.func, + /** + * Whether the menu is currently open. + */ + open: PropTypes.bool, + /** + * The visual orientation of the menu. + * @default 'vertical' + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), +} as any; + +export default MenuPreview; diff --git a/packages/mui-material/src/MenuPreview/apiDocs.d.ts b/packages/mui-material/src/MenuPreview/apiDocs.d.ts new file mode 100644 index 00000000000000..6c939d59992308 --- /dev/null +++ b/packages/mui-material/src/MenuPreview/apiDocs.d.ts @@ -0,0 +1,49 @@ +export { default as MenuPreview } from './MenuPreview'; +export * from './MenuPreview'; + +export { default as MenuPreviewTrigger } from '../MenuPreviewTrigger'; +export * from '../MenuPreviewTrigger'; + +export { default as MenuPreviewPopup } from '../MenuPreviewPopup'; +export * from '../MenuPreviewPopup'; + +export { default as MenuPreviewSubmenuPopup } from '../MenuPreviewSubmenuPopup'; +export * from '../MenuPreviewSubmenuPopup'; + +export { default as MenuPreviewItem } from '../MenuPreviewItem'; +export * from '../MenuPreviewItem'; + +export { default as MenuPreviewLinkItem } from '../MenuPreviewLinkItem'; +export * from '../MenuPreviewLinkItem'; + +export { default as MenuPreviewCheckboxItem } from '../MenuPreviewCheckboxItem'; +export * from '../MenuPreviewCheckboxItem'; + +export { default as MenuPreviewCheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; +export * from '../MenuPreviewCheckboxItemIndicator'; + +export { default as MenuPreviewRadioGroup } from '../MenuPreviewRadioGroup'; +export * from '../MenuPreviewRadioGroup'; + +export { default as MenuPreviewRadioItem } from '../MenuPreviewRadioItem'; +export * from '../MenuPreviewRadioItem'; + +export { default as MenuPreviewRadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; +export * from '../MenuPreviewRadioItemIndicator'; + +export { default as MenuPreviewGroup } from '../MenuPreviewGroup'; +export * from '../MenuPreviewGroup'; + +export { default as MenuPreviewGroupLabel } from '../MenuPreviewGroupLabel'; +export * from '../MenuPreviewGroupLabel'; + +export { default as MenuPreviewSeparator } from '../MenuPreviewSeparator'; +export * from '../MenuPreviewSeparator'; + +export { default as MenuPreviewSubmenuRoot } from '../MenuPreviewSubmenuRoot'; +export * from '../MenuPreviewSubmenuRoot'; + +export { default as MenuPreviewSubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; +export * from '../MenuPreviewSubmenuTrigger'; + +export * from './menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreview/index.d.ts b/packages/mui-material/src/MenuPreview/index.d.ts new file mode 100644 index 00000000000000..43bb7637dc085b --- /dev/null +++ b/packages/mui-material/src/MenuPreview/index.d.ts @@ -0,0 +1,66 @@ +export { default } from './MenuPreview'; +export { default as MenuPreview } from './MenuPreview'; +export { default as Root } from './MenuPreview'; +export * from './MenuPreview'; + +export { default as Trigger } from '../MenuPreviewTrigger'; +export { default as MenuPreviewTrigger } from '../MenuPreviewTrigger'; +export * from '../MenuPreviewTrigger'; + +export { default as Popup } from '../MenuPreviewPopup'; +export { default as MenuPreviewPopup } from '../MenuPreviewPopup'; +export * from '../MenuPreviewPopup'; + +export { default as SubmenuPopup } from '../MenuPreviewSubmenuPopup'; +export { default as MenuPreviewSubmenuPopup } from '../MenuPreviewSubmenuPopup'; +export * from '../MenuPreviewSubmenuPopup'; + +export { default as Item } from '../MenuPreviewItem'; +export { default as MenuPreviewItem } from '../MenuPreviewItem'; +export * from '../MenuPreviewItem'; + +export { default as LinkItem } from '../MenuPreviewLinkItem'; +export { default as MenuPreviewLinkItem } from '../MenuPreviewLinkItem'; +export * from '../MenuPreviewLinkItem'; + +export { default as CheckboxItem } from '../MenuPreviewCheckboxItem'; +export { default as MenuPreviewCheckboxItem } from '../MenuPreviewCheckboxItem'; +export * from '../MenuPreviewCheckboxItem'; + +export { default as CheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; +export { default as MenuPreviewCheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; +export * from '../MenuPreviewCheckboxItemIndicator'; + +export { default as RadioGroup } from '../MenuPreviewRadioGroup'; +export { default as MenuPreviewRadioGroup } from '../MenuPreviewRadioGroup'; +export * from '../MenuPreviewRadioGroup'; + +export { default as RadioItem } from '../MenuPreviewRadioItem'; +export { default as MenuPreviewRadioItem } from '../MenuPreviewRadioItem'; +export * from '../MenuPreviewRadioItem'; + +export { default as RadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; +export { default as MenuPreviewRadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; +export * from '../MenuPreviewRadioItemIndicator'; + +export { default as Group } from '../MenuPreviewGroup'; +export { default as MenuPreviewGroup } from '../MenuPreviewGroup'; +export * from '../MenuPreviewGroup'; + +export { default as GroupLabel } from '../MenuPreviewGroupLabel'; +export { default as MenuPreviewGroupLabel } from '../MenuPreviewGroupLabel'; +export * from '../MenuPreviewGroupLabel'; + +export { default as Separator } from '../MenuPreviewSeparator'; +export { default as MenuPreviewSeparator } from '../MenuPreviewSeparator'; +export * from '../MenuPreviewSeparator'; + +export { default as SubmenuRoot } from '../MenuPreviewSubmenuRoot'; +export { default as MenuPreviewSubmenuRoot } from '../MenuPreviewSubmenuRoot'; +export * from '../MenuPreviewSubmenuRoot'; + +export { default as SubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; +export { default as MenuPreviewSubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; +export * from '../MenuPreviewSubmenuTrigger'; + +export * from './menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreview/index.js b/packages/mui-material/src/MenuPreview/index.js new file mode 100644 index 00000000000000..43bb7637dc085b --- /dev/null +++ b/packages/mui-material/src/MenuPreview/index.js @@ -0,0 +1,66 @@ +export { default } from './MenuPreview'; +export { default as MenuPreview } from './MenuPreview'; +export { default as Root } from './MenuPreview'; +export * from './MenuPreview'; + +export { default as Trigger } from '../MenuPreviewTrigger'; +export { default as MenuPreviewTrigger } from '../MenuPreviewTrigger'; +export * from '../MenuPreviewTrigger'; + +export { default as Popup } from '../MenuPreviewPopup'; +export { default as MenuPreviewPopup } from '../MenuPreviewPopup'; +export * from '../MenuPreviewPopup'; + +export { default as SubmenuPopup } from '../MenuPreviewSubmenuPopup'; +export { default as MenuPreviewSubmenuPopup } from '../MenuPreviewSubmenuPopup'; +export * from '../MenuPreviewSubmenuPopup'; + +export { default as Item } from '../MenuPreviewItem'; +export { default as MenuPreviewItem } from '../MenuPreviewItem'; +export * from '../MenuPreviewItem'; + +export { default as LinkItem } from '../MenuPreviewLinkItem'; +export { default as MenuPreviewLinkItem } from '../MenuPreviewLinkItem'; +export * from '../MenuPreviewLinkItem'; + +export { default as CheckboxItem } from '../MenuPreviewCheckboxItem'; +export { default as MenuPreviewCheckboxItem } from '../MenuPreviewCheckboxItem'; +export * from '../MenuPreviewCheckboxItem'; + +export { default as CheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; +export { default as MenuPreviewCheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; +export * from '../MenuPreviewCheckboxItemIndicator'; + +export { default as RadioGroup } from '../MenuPreviewRadioGroup'; +export { default as MenuPreviewRadioGroup } from '../MenuPreviewRadioGroup'; +export * from '../MenuPreviewRadioGroup'; + +export { default as RadioItem } from '../MenuPreviewRadioItem'; +export { default as MenuPreviewRadioItem } from '../MenuPreviewRadioItem'; +export * from '../MenuPreviewRadioItem'; + +export { default as RadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; +export { default as MenuPreviewRadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; +export * from '../MenuPreviewRadioItemIndicator'; + +export { default as Group } from '../MenuPreviewGroup'; +export { default as MenuPreviewGroup } from '../MenuPreviewGroup'; +export * from '../MenuPreviewGroup'; + +export { default as GroupLabel } from '../MenuPreviewGroupLabel'; +export { default as MenuPreviewGroupLabel } from '../MenuPreviewGroupLabel'; +export * from '../MenuPreviewGroupLabel'; + +export { default as Separator } from '../MenuPreviewSeparator'; +export { default as MenuPreviewSeparator } from '../MenuPreviewSeparator'; +export * from '../MenuPreviewSeparator'; + +export { default as SubmenuRoot } from '../MenuPreviewSubmenuRoot'; +export { default as MenuPreviewSubmenuRoot } from '../MenuPreviewSubmenuRoot'; +export * from '../MenuPreviewSubmenuRoot'; + +export { default as SubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; +export { default as MenuPreviewSubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; +export * from '../MenuPreviewSubmenuTrigger'; + +export * from './menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreview/menuPreviewClasses.ts b/packages/mui-material/src/MenuPreview/menuPreviewClasses.ts new file mode 100644 index 00000000000000..5c32722a12a4d0 --- /dev/null +++ b/packages/mui-material/src/MenuPreview/menuPreviewClasses.ts @@ -0,0 +1,280 @@ +import generateUtilityClass from '@mui/utils/generateUtilityClass'; +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; + +export interface MenuPreviewTriggerClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if the menu is open. */ + open: string; +} + +export type MenuPreviewTriggerClassKey = keyof MenuPreviewTriggerClasses; + +export function getMenuPreviewTriggerUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewTrigger', slot); +} + +export const menuPreviewTriggerClasses: MenuPreviewTriggerClasses = generateUtilityClasses( + 'MuiMenuPreviewTrigger', + ['root', 'disabled', 'open'], +); + +export interface MenuPreviewPopupClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the Material Paper element. */ + paper: string; + /** Styles applied to the Material List element. */ + list: string; +} + +export type MenuPreviewPopupClassKey = keyof MenuPreviewPopupClasses; + +export function getMenuPreviewPopupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewPopup', slot); +} + +export const menuPreviewPopupClasses: MenuPreviewPopupClasses = generateUtilityClasses( + 'MuiMenuPreviewPopup', + ['root', 'paper', 'list'], +); + +export interface MenuPreviewSubmenuPopupClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the Material Paper element. */ + paper: string; + /** Styles applied to the Material List element. */ + list: string; +} + +export type MenuPreviewSubmenuPopupClassKey = keyof MenuPreviewSubmenuPopupClasses; + +export function getMenuPreviewSubmenuPopupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewSubmenuPopup', slot); +} + +export const menuPreviewSubmenuPopupClasses: MenuPreviewSubmenuPopupClasses = + generateUtilityClasses('MuiMenuPreviewSubmenuPopup', ['root', 'paper', 'list']); + +export interface MenuPreviewItemClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** Styles applied to the root element if `dense={true}`. */ + dense: string; + /** Styles applied to the root element if `divider={true}`. */ + divider: string; + /** Styles applied to the root element unless `disableGutters={true}`. */ + gutters: string; + /** State class applied to the root element if `selected={true}`. */ + selected: string; +} + +export type MenuPreviewItemClassKey = keyof MenuPreviewItemClasses; + +export function getMenuPreviewItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewItem', slot); +} + +export const menuPreviewItemClasses: MenuPreviewItemClasses = generateUtilityClasses( + 'MuiMenuPreviewItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected'], +); + +export interface MenuPreviewLinkItemClasses extends MenuPreviewItemClasses {} + +export type MenuPreviewLinkItemClassKey = keyof MenuPreviewLinkItemClasses; + +export function getMenuPreviewLinkItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewLinkItem', slot); +} + +export const menuPreviewLinkItemClasses: MenuPreviewLinkItemClasses = generateUtilityClasses( + 'MuiMenuPreviewLinkItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected'], +); + +export interface MenuPreviewCheckboxItemClasses extends MenuPreviewItemClasses { + /** State class applied to the root element if `checked={true}`. */ + checked: string; +} + +export type MenuPreviewCheckboxItemClassKey = keyof MenuPreviewCheckboxItemClasses; + +export function getMenuPreviewCheckboxItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewCheckboxItem', slot); +} + +export const menuPreviewCheckboxItemClasses: MenuPreviewCheckboxItemClasses = + generateUtilityClasses('MuiMenuPreviewCheckboxItem', [ + 'root', + 'highlighted', + 'disabled', + 'dense', + 'divider', + 'gutters', + 'selected', + 'checked', + ]); + +export interface MenuPreviewCheckboxItemIndicatorClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; +} + +export type MenuPreviewCheckboxItemIndicatorClassKey = + keyof MenuPreviewCheckboxItemIndicatorClasses; + +export function getMenuPreviewCheckboxItemIndicatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewCheckboxItemIndicator', slot); +} + +export const menuPreviewCheckboxItemIndicatorClasses: MenuPreviewCheckboxItemIndicatorClasses = + generateUtilityClasses('MuiMenuPreviewCheckboxItemIndicator', [ + 'root', + 'checked', + 'disabled', + 'highlighted', + ]); + +export interface MenuPreviewRadioGroupClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; +} + +export type MenuPreviewRadioGroupClassKey = keyof MenuPreviewRadioGroupClasses; + +export function getMenuPreviewRadioGroupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewRadioGroup', slot); +} + +export const menuPreviewRadioGroupClasses: MenuPreviewRadioGroupClasses = generateUtilityClasses( + 'MuiMenuPreviewRadioGroup', + ['root', 'disabled'], +); + +export interface MenuPreviewRadioItemClasses extends MenuPreviewItemClasses { + /** State class applied to the root element if `checked={true}`. */ + checked: string; +} + +export type MenuPreviewRadioItemClassKey = keyof MenuPreviewRadioItemClasses; + +export function getMenuPreviewRadioItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewRadioItem', slot); +} + +export const menuPreviewRadioItemClasses: MenuPreviewRadioItemClasses = generateUtilityClasses( + 'MuiMenuPreviewRadioItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'checked'], +); + +export interface MenuPreviewRadioItemIndicatorClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; +} + +export type MenuPreviewRadioItemIndicatorClassKey = keyof MenuPreviewRadioItemIndicatorClasses; + +export function getMenuPreviewRadioItemIndicatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewRadioItemIndicator', slot); +} + +export const menuPreviewRadioItemIndicatorClasses: MenuPreviewRadioItemIndicatorClasses = + generateUtilityClasses('MuiMenuPreviewRadioItemIndicator', [ + 'root', + 'checked', + 'disabled', + 'highlighted', + ]); + +export interface MenuPreviewGroupClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type MenuPreviewGroupClassKey = keyof MenuPreviewGroupClasses; + +export function getMenuPreviewGroupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewGroup', slot); +} + +export const menuPreviewGroupClasses: MenuPreviewGroupClasses = generateUtilityClasses( + 'MuiMenuPreviewGroup', + ['root'], +); + +export interface MenuPreviewGroupLabelClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type MenuPreviewGroupLabelClassKey = keyof MenuPreviewGroupLabelClasses; + +export function getMenuPreviewGroupLabelUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewGroupLabel', slot); +} + +export const menuPreviewGroupLabelClasses: MenuPreviewGroupLabelClasses = generateUtilityClasses( + 'MuiMenuPreviewGroupLabel', + ['root'], +); + +export interface MenuPreviewSeparatorClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type MenuPreviewSeparatorClassKey = keyof MenuPreviewSeparatorClasses; + +export function getMenuPreviewSeparatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewSeparator', slot); +} + +export const menuPreviewSeparatorClasses: MenuPreviewSeparatorClasses = generateUtilityClasses( + 'MuiMenuPreviewSeparator', + ['root'], +); + +export interface MenuPreviewSubmenuTriggerClasses extends MenuPreviewItemClasses { + /** State class applied to the root element if the submenu is open. */ + open: string; +} + +export type MenuPreviewSubmenuTriggerClassKey = keyof MenuPreviewSubmenuTriggerClasses; + +export function getMenuPreviewSubmenuTriggerUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenuPreviewSubmenuTrigger', slot); +} + +export const menuPreviewSubmenuTriggerClasses: MenuPreviewSubmenuTriggerClasses = + generateUtilityClasses('MuiMenuPreviewSubmenuTrigger', [ + 'root', + 'highlighted', + 'disabled', + 'dense', + 'divider', + 'gutters', + 'selected', + 'open', + ]); diff --git a/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx b/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx new file mode 100644 index 00000000000000..904641b370bfbb --- /dev/null +++ b/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx @@ -0,0 +1,244 @@ +'use client'; +import * as React from 'react'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { CSSInterpolation, SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + StateClassName, + mergeStateClassName, +} from './menuPreviewUtils'; + +export interface MenuPreviewItemOwnerState { + checked?: boolean | undefined; + dense: boolean; + disabled: boolean; + divider: boolean; + disableGutters: boolean; + selected: boolean; +} + +export interface MenuPreviewItemVisualProps< + Classes, + Slots = MenuPreviewRootSlots, + SlotProps = MenuPreviewRootSlotProps, +> { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * The components used for each slot inside. + */ + slots?: Slots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: SlotProps | undefined; + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense?: boolean | undefined; + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters?: boolean | undefined; + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider?: boolean | undefined; + /** + * If `true`, the component is selected. + * @default false + */ + selected?: boolean | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +export interface MenuPreviewItemBaseProps { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * Whether to close the menu when the item is clicked. + * @default true + */ + closeOnClick?: boolean | undefined; +} + +export interface MenuPreviewLinkItemBaseProps { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * The URL that the link item points to. + */ + href?: string | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick?: boolean | undefined; +} + +export interface MenuPreviewSubmenuTriggerBaseProps { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * How long to wait before the submenu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay?: number | undefined; + /** + * How long to wait before closing the submenu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay?: number | undefined; + /** + * Whether the submenu should also open when the trigger is hovered. + */ + openOnHover?: boolean | undefined; +} + +export interface MenuPreviewBaseItemState { + disabled?: boolean; + highlighted?: boolean; +} + +export function menuPreviewItemOverridesResolver( + props: { ownerState: MenuPreviewItemOwnerState }, + styles: Record, +) { + const { ownerState } = props; + + return [ + styles.root, + ownerState.dense && styles.dense, + ownerState.divider && styles.divider, + !ownerState.disableGutters && styles.gutters, + ] as CSSInterpolation; +} + +export function getMenuPreviewItemOwnerState( + props: MenuPreviewItemVisualProps & { + checked?: boolean | undefined; + disabled?: boolean | undefined; + }, +): MenuPreviewItemOwnerState { + return { + checked: props.checked, + dense: props.dense ?? false, + disabled: props.disabled ?? false, + divider: props.divider ?? false, + disableGutters: props.disableGutters ?? false, + selected: props.selected ?? false, + }; +} + +export function useMenuPreviewItemUtilityClasses( + ownerState: MenuPreviewItemOwnerState & { + classes?: Partial; + checked?: boolean; + open?: boolean; + }, + getUtilityClass: (slot: string) => string, +) { + const { dense, disabled, divider, disableGutters, selected, checked, open, classes } = ownerState; + const slots = { + root: [ + 'root', + dense && 'dense', + disabled && 'disabled', + !disableGutters && 'gutters', + divider && 'divider', + selected && 'selected', + checked && 'checked', + open && 'open', + ], + highlighted: ['highlighted'], + disabled: ['disabled'], + checked: ['checked'], + open: ['open'], + }; + + return { + ...classes, + ...composeClasses(slots, getUtilityClass, classes as Record | undefined), + } as Classes; +} + +export function getMenuPreviewItemClassName( + classes: Partial>, + ownerState: MenuPreviewItemOwnerState, + state: State, +) { + return clsx( + classes.root, + state.highlighted && classes.highlighted, + state.disabled && !ownerState.disabled && classes.disabled, + ); +} + +export function mergeMenuPreviewItemClassName( + className: StateClassName, + classes: Partial>, + ownerState: MenuPreviewItemOwnerState, +) { + return mergeStateClassName(className, (state) => + getMenuPreviewItemClassName(classes, ownerState, state), + ); +} diff --git a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx new file mode 100644 index 00000000000000..2e5bc2a049171e --- /dev/null +++ b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx @@ -0,0 +1,368 @@ +'use client'; +import * as React from 'react'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import appendOwnerState from '@mui/utils/appendOwnerState'; +import isHostComponent from '@mui/utils/isHostComponent'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { PaperProps } from '../Paper'; +import { ListProps } from '../List'; +import { resolveSlotProps, SlotProps } from './menuPreviewUtils'; + +type ExternalSlotProps = Omit, 'className' | 'render' | 'style'> & { + className?: string | undefined; + render?: never; + style?: React.CSSProperties | undefined; +} & Record; + +function mergeSx(...sx: Array | undefined>) { + return sx.flatMap((style) => (Array.isArray(style) ? style : [style])).filter(Boolean); +} + +function setDefinedProp(props: Record, key: string, value: unknown) { + if (value !== undefined) { + props[key] = value; + } +} + +function omitProps | undefined>( + props: Props, + keys: readonly string[], +): Props { + if (props == null) { + return props; + } + + const result = { ...props }; + keys.forEach((key) => { + delete result[key]; + }); + + return result as Props; +} + +function getSlotProps>( + Slot: ElementType, + props: Props, + hostOmittedProps: readonly string[], +) { + return isHostComponent(Slot) ? omitProps(props, hostOmittedProps) : props; +} + +const portalHostOmittedProps = ['container', 'keepMounted'] as const; +const positionerHostOmittedProps = [ + 'align', + 'alignOffset', + 'anchor', + 'arrowPadding', + 'collisionAvoidance', + 'collisionBoundary', + 'collisionPadding', + 'disableAnchorTracking', + 'positionMethod', + 'render', + 'side', + 'sideOffset', + 'sticky', +] as const; +const paperHostOmittedProps = [ + 'classes', + 'component', + 'elevation', + 'square', + 'sx', + 'variant', +] as const; +const listHostOmittedProps = [ + 'classes', + 'component', + 'dense', + 'disablePadding', + 'subheader', + 'sx', +] as const; + +export interface MenuPreviewPopupSharedSlots { + /** + * The component used for the portal. + * @default BaseMenu.Portal + */ + portal?: React.ElementType; + /** + * The component used for the positioner. + * @default BaseMenu.Positioner + */ + positioner?: React.ElementType; + /** + * The component rendered by the Base UI popup. + * @default 'div' + */ + popup?: React.ElementType; + /** + * The component used for the Material surface. + * @default Paper + */ + paper?: React.ElementType; + /** + * The component used for the presentational list wrapper. + * @default List + */ + list?: React.ElementType; +} + +export interface MenuPreviewPopupSharedSlotProps { + portal?: SlotProps, OwnerState>; + positioner?: SlotProps, OwnerState>; + popup?: SlotProps, OwnerState>; + paper?: SlotProps, OwnerState>; + list?: SlotProps, OwnerState>; +} + +type MenuPreviewPositionerProps = BaseMenu.Positioner.Props; +type MenuPreviewPortalProps = BaseMenu.Portal.Props; + +export type MenuPreviewPopupState = BaseMenu.Popup.State; +export type MenuPreviewPopupSide = NonNullable; +export type MenuPreviewPopupAlign = NonNullable; +export type MenuPreviewPopupOffset = NonNullable; +export type MenuPreviewPopupAnchor = MenuPreviewPositionerProps['anchor']; +export type MenuPreviewPopupPositionMethod = MenuPreviewPositionerProps['positionMethod']; +export type MenuPreviewPopupCollisionBoundary = MenuPreviewPositionerProps['collisionBoundary']; +export type MenuPreviewPopupCollisionPadding = MenuPreviewPositionerProps['collisionPadding']; +export type MenuPreviewPopupCollisionAvoidance = MenuPreviewPositionerProps['collisionAvoidance']; +export type MenuPreviewPopupContainer = MenuPreviewPortalProps['container']; +export type MenuPreviewPopupFinalFocus = BaseMenu.Popup.Props['finalFocus']; + +export interface MenuPreviewPopupPublicProps { + /** + * The menu items. + */ + children?: React.ReactNode; + /** + * CSS class applied to the Base UI popup element. + */ + className?: string | undefined; + /** + * Styles applied to the Base UI popup element. + */ + style?: React.CSSProperties | undefined; + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the trigger. + */ + anchor?: MenuPreviewPopupAnchor; + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod?: MenuPreviewPopupPositionMethod; + /** + * Which side of the anchor element to align the popup against. + * @default 'bottom' + */ + side?: MenuPreviewPopupSide | undefined; + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset?: MenuPreviewPopupOffset | undefined; + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align?: MenuPreviewPopupAlign | undefined; + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset?: MenuPreviewPopupOffset | undefined; + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary?: MenuPreviewPopupCollisionBoundary; + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding?: MenuPreviewPopupCollisionPadding; + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding?: MenuPreviewPositionerProps['arrowPadding']; + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky?: MenuPreviewPositionerProps['sticky']; + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking?: MenuPreviewPositionerProps['disableAnchorTracking']; + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance?: MenuPreviewPopupCollisionAvoidance; + /** + * The container element to portal the popup into. + */ + container?: MenuPreviewPopupContainer; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: MenuPreviewPortalProps['keepMounted']; + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus?: MenuPreviewPopupFinalFocus; +} + +export interface MenuPreviewPopupSharedProps + extends + Omit, + MenuPreviewPopupPublicProps { + classes?: Partial>; + ownerState: OwnerState; + slots?: MenuPreviewPopupSharedSlots; + slotProps?: MenuPreviewPopupSharedSlotProps; + defaultSlots: { + popup: React.ElementType; + paper: React.ElementType; + list: React.ElementType; + }; + defaultPositionerProps?: Partial; + sx?: SxProps; +} + +export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBase( + props: MenuPreviewPopupSharedProps, + ref: React.ForwardedRef, +) { + const { + children, + className, + classes, + ownerState, + slots, + slotProps, + defaultSlots, + defaultPositionerProps, + sx, + container, + keepMounted, + anchor, + positionMethod, + side, + sideOffset, + align, + alignOffset, + collisionBoundary, + collisionPadding, + arrowPadding, + sticky, + disableAnchorTracking, + collisionAvoidance, + id, + finalFocus, + style, + ...other + } = props; + + const PortalSlot = slots?.portal ?? BaseMenu.Portal; + const PositionerSlot = slots?.positioner ?? BaseMenu.Positioner; + const PopupSlot = slots?.popup ?? defaultSlots.popup; + const PaperSlot = slots?.paper ?? defaultSlots.paper; + const ListSlot = slots?.list ?? defaultSlots.list; + + const resolvedPortalProps = resolveSlotProps(slotProps?.portal, ownerState); + const resolvedPositionerProps = resolveSlotProps(slotProps?.positioner, ownerState); + const resolvedPopupProps = resolveSlotProps(slotProps?.popup, ownerState); + const resolvedPaperProps = resolveSlotProps(slotProps?.paper, ownerState); + const resolvedListProps = resolveSlotProps(slotProps?.list, ownerState); + const { className: resolvedPopupClassName, ...resolvedPopupOtherProps } = + resolvedPopupProps ?? {}; + const positionerProps = { + ...defaultPositionerProps, + }; + + setDefinedProp(positionerProps, 'anchor', anchor); + setDefinedProp(positionerProps, 'positionMethod', positionMethod); + setDefinedProp(positionerProps, 'side', side); + setDefinedProp(positionerProps, 'sideOffset', sideOffset); + setDefinedProp(positionerProps, 'align', align); + setDefinedProp(positionerProps, 'alignOffset', alignOffset); + setDefinedProp(positionerProps, 'collisionBoundary', collisionBoundary); + setDefinedProp(positionerProps, 'collisionPadding', collisionPadding); + setDefinedProp(positionerProps, 'arrowPadding', arrowPadding); + setDefinedProp(positionerProps, 'sticky', sticky); + setDefinedProp(positionerProps, 'disableAnchorTracking', disableAnchorTracking); + setDefinedProp(positionerProps, 'collisionAvoidance', collisionAvoidance); + + const popupClassName = clsx(classes?.root, className, resolvedPopupClassName); + const popupRender = ; + const portalSlotProps = getSlotProps( + PortalSlot, + { + container, + keepMounted, + ...resolvedPortalProps, + }, + portalHostOmittedProps, + ); + const positionerSlotProps = getSlotProps( + PositionerSlot, + { + ...positionerProps, + ...resolvedPositionerProps, + }, + positionerHostOmittedProps, + ); + const paperSlotProps = getSlotProps( + PaperSlot, + { + elevation: 8, + ...resolvedPaperProps, + className: clsx(classes?.paper, resolvedPaperProps?.className), + sx: mergeSx(sx, resolvedPaperProps?.sx), + }, + paperHostOmittedProps, + ); + const listSlotProps = getSlotProps( + ListSlot, + { + component: 'div', + disablePadding: true, + ...resolvedListProps, + className: clsx(classes?.list, resolvedListProps?.className), + }, + listHostOmittedProps, + ); + + return ( + + + + + {children} + + + + + ); +}) as ( + props: MenuPreviewPopupSharedProps & React.RefAttributes, +) => React.JSX.Element; diff --git a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts b/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts new file mode 100644 index 00000000000000..75295e4ea2b1fb --- /dev/null +++ b/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts @@ -0,0 +1,172 @@ +import { CSSInterpolation } from '@mui/system'; +import { dividerClasses } from '../Divider'; +import { listItemIconClasses } from '../ListItemIcon'; +import { listItemTextClasses } from '../ListItemText'; +import memoTheme from '../utils/memoTheme'; +import { Theme } from '../styles'; + +export interface SharedMenuPreviewItemClasses { + highlighted: string; + disabled: string; + dense: string; + divider: string; + gutters: string; + selected: string; +} + +interface MenuPreviewItemVariantOwnerState { + dense: boolean; + divider: boolean; + disableGutters: boolean; +} + +export function getMenuPreviewItemStyles( + theme: Theme, + classes: SharedMenuPreviewItemClasses, +): CSSInterpolation { + return { + ...theme.typography.body1, + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + position: 'relative', + textDecoration: 'none', + minHeight: 48, + paddingTop: 6, + paddingBottom: 6, + boxSizing: 'border-box', + whiteSpace: 'nowrap', + cursor: 'default', + userSelect: 'none', + outline: 0, + '&:hover': { + textDecoration: 'none', + backgroundColor: (theme.vars || theme).palette.action.hover, + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + [`&.${classes.selected}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + (theme.vars || theme).palette.action.selectedOpacity, + ), + [`&.${classes.highlighted}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, + ), + }, + }, + [`&.${classes.selected}:hover`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, + ), + '@media (hover: none)': { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + (theme.vars || theme).palette.action.selectedOpacity, + ), + }, + }, + [`&.${classes.highlighted}`]: { + backgroundColor: (theme.vars || theme).palette.action.focus, + }, + [`&.${classes.disabled}`]: { + opacity: (theme.vars || theme).palette.action.disabledOpacity, + pointerEvents: 'none', + }, + [`& + .${dividerClasses.root}`]: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + [`& + .${dividerClasses.inset}`]: { + marginLeft: 52, + }, + [`& .${listItemTextClasses.root}`]: { + marginTop: 0, + marginBottom: 0, + }, + [`& .${listItemTextClasses.inset}`]: { + paddingLeft: 36, + }, + [`& .${listItemIconClasses.root}`]: { + minWidth: 36, + }, + variants: [ + { + props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => + !ownerState.disableGutters, + style: { + paddingLeft: 16, + paddingRight: 16, + }, + }, + { + props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => + ownerState.divider, + style: { + borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, + backgroundClip: 'padding-box', + }, + }, + { + props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => + !ownerState.dense, + style: { + [theme.breakpoints.up('sm')]: { + minHeight: 'auto', + }, + }, + }, + { + props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => + ownerState.dense, + style: { + minHeight: 32, + paddingTop: 4, + paddingBottom: 4, + ...theme.typography.body2, + [`& .${listItemIconClasses.root} svg`]: { + fontSize: '1.25rem', + }, + }, + }, + ], + }; +} + +export const menuPreviewPopupPaperStyles: CSSInterpolation = { + maxHeight: 'calc(100% - 96px)', + WebkitOverflowScrolling: 'touch', +}; + +export const menuPreviewPopupListStyles: CSSInterpolation = { + outline: 0, +}; + +export const menuPreviewIndicatorStyles = memoTheme(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 36, + color: (theme.vars || theme).palette.action.active, + '& [data-mui-menu-preview-indicator-icon]': { + display: 'inline-block', + flexShrink: 0, + width: '1.25rem', + height: '1.25rem', + fill: 'currentColor', + }, + '& [data-mui-menu-preview-checkbox-checkmark]': { + fill: (theme.vars || theme).palette.background.paper, + }, + '&[data-unchecked] [data-mui-menu-preview-indicator-mark]': { + visibility: 'hidden', + }, +})); diff --git a/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts b/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts new file mode 100644 index 00000000000000..bfb185ae77ba5e --- /dev/null +++ b/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts @@ -0,0 +1,76 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import appendOwnerState from '@mui/utils/appendOwnerState'; +import isHostComponent from '@mui/utils/isHostComponent'; + +export type StateClassName = string | ((state: State) => string | undefined) | undefined; + +export function resolveStateClassName( + className: StateClassName, + state: State, +): string | undefined { + return typeof className === 'function' ? className(state) : className; +} + +export function mergeStateClassName( + className: StateClassName, + getClassName: (state: State) => string | undefined, +) { + return (state: State) => clsx(getClassName(state), resolveStateClassName(className, state)); +} + +export type SlotProps = + | SlotPropsValue + | ((ownerState: OwnerState) => SlotPropsValue) + | undefined; + +export function resolveSlotProps( + slotProps: SlotProps, + ownerState: OwnerState, +): SlotPropsValue | undefined { + return typeof slotProps === 'function' + ? (slotProps as (ownerState: OwnerState) => SlotPropsValue)(ownerState) + : slotProps; +} + +export interface MenuPreviewRootSlots { + root?: React.ElementType; +} + +export interface MenuPreviewRootSlotProps { + root?: SlotProps, OwnerState>; +} + +export function getMenuPreviewRootRender( + RootSlot: React.ElementType, + ownerState: OwnerState, + props?: Record, +) { + if (isHostComponent(RootSlot)) { + const hostProps = { ...(props ?? {}) }; + delete hostProps.as; + delete hostProps.component; + delete hostProps.ownerState; + delete hostProps.sx; + + return React.createElement(RootSlot, hostProps); + } + + return React.createElement(RootSlot, appendOwnerState(RootSlot, props ?? {}, ownerState)); +} + +export function isMenuPreviewRootNativeButton( + RootSlot: React.ElementType, + component: React.ElementType | undefined, + defaultNativeButton = false, +) { + if (isHostComponent(RootSlot)) { + return RootSlot === 'button'; + } + + if (component != null) { + return component === 'button'; + } + + return defaultNativeButton; +} diff --git a/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx b/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx new file mode 100644 index 00000000000000..bbfad0b75225b9 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx @@ -0,0 +1,308 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenuPreviewItemStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewRootRender, + isMenuPreviewRootNativeButton, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewItemClassName, + getMenuPreviewItemOwnerState, + MenuPreviewItemBaseProps, + MenuPreviewItemOwnerState, + MenuPreviewItemVisualProps, + menuPreviewItemOverridesResolver, + useMenuPreviewItemUtilityClasses, +} from '../MenuPreview/menuPreviewItemShared'; +import { + getMenuPreviewCheckboxItemUtilityClass, + menuPreviewCheckboxItemClasses, + MenuPreviewCheckboxItemClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewCheckboxItemSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewCheckboxItemSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewCheckboxItemProps + extends + Omit< + BaseMenu.CheckboxItem.Props, + 'className' | 'nativeButton' | 'onChange' | 'onCheckedChange' | 'render' | 'style' + >, + MenuPreviewItemBaseProps, + MenuPreviewItemVisualProps< + MenuPreviewCheckboxItemClasses, + MenuPreviewCheckboxItemSlots, + MenuPreviewCheckboxItemSlotProps + > { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Whether the checkbox item is currently ticked. + * + * To render an uncontrolled checkbox item, use the `defaultChecked` prop instead. + */ + checked?: boolean | undefined; + /** + * Whether the checkbox item is initially ticked. + * + * To render a controlled checkbox item, use the `checked` prop instead. + * @default false + */ + defaultChecked?: boolean | undefined; + /** + * Event handler called when the checkbox item is ticked or unticked. + */ + onChange?: + | (( + event: Event, + checked: boolean, + eventDetails: BaseMenu.CheckboxItem.ChangeEventDetails, + ) => void) + | undefined; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick?: boolean | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const MenuPreviewCheckboxItemRoot = styled('div', { + name: 'MuiMenuPreviewCheckboxItem', + slot: 'Root', + overridesResolver: menuPreviewItemOverridesResolver, +})<{ ownerState: MenuPreviewItemOwnerState }>( + memoTheme(({ theme }) => getMenuPreviewItemStyles(theme, menuPreviewCheckboxItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewCheckboxItem API](https://mui.com/material-ui/api/menu-preview-checkbox-item/) + */ +const MenuPreviewCheckboxItem = React.forwardRef(function MenuPreviewCheckboxItem( + inProps: MenuPreviewCheckboxItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewCheckboxItem', + }); + + const { + checked, + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + onChange, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenuPreviewItemOwnerState({ + checked, + dense, + disabled, + disableGutters, + divider, + selected, + }), + classes: classesProp, + }; + const classes = useMenuPreviewItemUtilityClasses( + ownerState, + getMenuPreviewCheckboxItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const handleCheckedChange = React.useCallback( + (newChecked: boolean, eventDetails: BaseMenu.CheckboxItem.ChangeEventDetails) => { + onChange?.(eventDetails.event, newChecked, eventDetails); + }, + [onChange], + ); + const RootSlot = slots?.root ?? MenuPreviewCheckboxItemRoot; + + return ( + + + clsx( + className, + getMenuPreviewItemClassName(classes, ownerState, state), + state.checked && classes.checked, + ) + } + checked={checked} + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenuPreviewRootNativeButton(RootSlot, component)} + onCheckedChange={handleCheckedChange} + style={style} + {...other} + /> + + ); +}); + +MenuPreviewCheckboxItem.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * Whether the checkbox item is currently ticked. + * + * To render an uncontrolled checkbox item, use the `defaultChecked` prop instead. + */ + checked: PropTypes.bool, + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick: PropTypes.bool, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * Whether the checkbox item is initially ticked. + * + * To render a controlled checkbox item, use the `checked` prop instead. + * @default false + */ + defaultChecked: PropTypes.bool, + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters: PropTypes.bool, + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * Event handler called when the checkbox item is ticked or unticked. + */ + onChange: PropTypes.func, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewCheckboxItem; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts b/packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts new file mode 100644 index 00000000000000..4372d2838e90bb --- /dev/null +++ b/packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewCheckboxItem'; +export * from './MenuPreviewCheckboxItem'; +export { + menuPreviewCheckboxItemClasses, + getMenuPreviewCheckboxItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewCheckboxItemClasses, + MenuPreviewCheckboxItemClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItem/index.js b/packages/mui-material/src/MenuPreviewCheckboxItem/index.js new file mode 100644 index 00000000000000..d453dbcbd0a5ab --- /dev/null +++ b/packages/mui-material/src/MenuPreviewCheckboxItem/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewCheckboxItem'; +export * from './MenuPreviewCheckboxItem'; +export { + menuPreviewCheckboxItemClasses, + getMenuPreviewCheckboxItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx new file mode 100644 index 00000000000000..f133331c8c69d6 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx @@ -0,0 +1,224 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menuPreviewIndicatorStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewCheckboxItemIndicatorUtilityClass, + MenuPreviewCheckboxItemIndicatorClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewCheckboxItemIndicatorSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewCheckboxItemIndicatorSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewCheckboxItemIndicatorProps extends Omit< + BaseMenu.CheckboxItemIndicator.Props, + 'className' | 'render' | 'style' +> { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Whether to keep the HTML element in the DOM when the checkbox item is not checked. + * @default false + */ + keepMounted?: boolean | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewCheckboxItemIndicatorSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewCheckboxItemIndicatorSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +const useUtilityClasses = (ownerState: MenuPreviewCheckboxItemIndicatorProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + checked: ['checked'], + disabled: ['disabled'], + highlighted: ['highlighted'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenuPreviewCheckboxItemIndicatorUtilityClass, classes), + }; +}; + +const MenuPreviewCheckboxItemIndicatorRoot = styled('span', { + name: 'MuiMenuPreviewCheckboxItemIndicator', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})(menuPreviewIndicatorStyles) as any; + +function DefaultCheckboxIndicatorIcon() { + return ( + + ); +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewCheckboxItemIndicator API](https://mui.com/material-ui/api/menu-preview-checkbox-item-indicator/) + */ +const MenuPreviewCheckboxItemIndicator = React.forwardRef(function MenuPreviewCheckboxItemIndicator( + inProps: MenuPreviewCheckboxItemIndicatorProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewCheckboxItemIndicator', + }); + + const { + children, + className, + classes: classesProp, + component, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + clsx( + className, + classes.root, + state.checked && classes.checked, + state.disabled && classes.disabled, + state.highlighted && classes.highlighted, + ) + } + style={style} + {...other} + > + {children ?? } + + ); +}); + +MenuPreviewCheckboxItemIndicator.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * Whether to keep the HTML element in the DOM when the checkbox item is not checked. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewCheckboxItemIndicator; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts new file mode 100644 index 00000000000000..4e0c311cd37242 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewCheckboxItemIndicator'; +export * from './MenuPreviewCheckboxItemIndicator'; +export { + menuPreviewCheckboxItemIndicatorClasses, + getMenuPreviewCheckboxItemIndicatorUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewCheckboxItemIndicatorClasses, + MenuPreviewCheckboxItemIndicatorClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js new file mode 100644 index 00000000000000..fb24b5bf809efc --- /dev/null +++ b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewCheckboxItemIndicator'; +export * from './MenuPreviewCheckboxItemIndicator'; +export { + menuPreviewCheckboxItemIndicatorClasses, + getMenuPreviewCheckboxItemIndicatorUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx b/packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx new file mode 100644 index 00000000000000..863f2309232f1b --- /dev/null +++ b/packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx @@ -0,0 +1,174 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewGroupUtilityClass, + MenuPreviewGroupClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewGroupSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewGroupSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewGroupProps extends Omit< + BaseMenu.Group.Props, + 'className' | 'render' | 'style' +> { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewGroupSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewGroupSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +const useUtilityClasses = (ownerState: MenuPreviewGroupProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getMenuPreviewGroupUtilityClass, classes); +}; + +const MenuPreviewGroupRoot = styled('div', { + name: 'MuiMenuPreviewGroup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewGroup API](https://mui.com/material-ui/api/menu-preview-group/) + */ +const MenuPreviewGroup = React.forwardRef(function MenuPreviewGroup( + inProps: MenuPreviewGroupProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewGroup', + }); + + const { + className, + classes: classesProp, + component, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +MenuPreviewGroup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewGroup; diff --git a/packages/mui-material/src/MenuPreviewGroup/index.d.ts b/packages/mui-material/src/MenuPreviewGroup/index.d.ts new file mode 100644 index 00000000000000..73a72486bb167f --- /dev/null +++ b/packages/mui-material/src/MenuPreviewGroup/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewGroup'; +export * from './MenuPreviewGroup'; +export { + menuPreviewGroupClasses, + getMenuPreviewGroupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewGroupClasses, + MenuPreviewGroupClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroup/index.js b/packages/mui-material/src/MenuPreviewGroup/index.js new file mode 100644 index 00000000000000..49e6742bc562e4 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewGroup/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewGroup'; +export * from './MenuPreviewGroup'; +export { + menuPreviewGroupClasses, + getMenuPreviewGroupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx b/packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx new file mode 100644 index 00000000000000..c640acd889bd07 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx @@ -0,0 +1,176 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import ListSubheader from '../ListSubheader'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewGroupLabelUtilityClass, + MenuPreviewGroupLabelClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewGroupLabelSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewGroupLabelSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewGroupLabelProps extends Omit< + BaseMenu.GroupLabel.Props, + 'className' | 'render' | 'style' +> { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewGroupLabelSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewGroupLabelSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +const useUtilityClasses = (ownerState: MenuPreviewGroupLabelProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getMenuPreviewGroupLabelUtilityClass, classes); +}; + +const MenuPreviewGroupLabelRoot = styled(ListSubheader, { + name: 'MuiMenuPreviewGroupLabel', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewGroupLabel API](https://mui.com/material-ui/api/menu-preview-group-label/) + */ +const MenuPreviewGroupLabel = React.forwardRef(function MenuPreviewGroupLabel( + inProps: MenuPreviewGroupLabelProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewGroupLabel', + }); + + const { + className, + classes: classesProp, + component, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +MenuPreviewGroupLabel.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewGroupLabel; diff --git a/packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts b/packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts new file mode 100644 index 00000000000000..6b9ddf97eea6cf --- /dev/null +++ b/packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewGroupLabel'; +export * from './MenuPreviewGroupLabel'; +export { + menuPreviewGroupLabelClasses, + getMenuPreviewGroupLabelUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewGroupLabelClasses, + MenuPreviewGroupLabelClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroupLabel/index.js b/packages/mui-material/src/MenuPreviewGroupLabel/index.js new file mode 100644 index 00000000000000..30bf6f4677fed1 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewGroupLabel/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewGroupLabel'; +export * from './MenuPreviewGroupLabel'; +export { + menuPreviewGroupLabelClasses, + getMenuPreviewGroupLabelUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx b/packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx new file mode 100644 index 00000000000000..6571355efac4e7 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx @@ -0,0 +1,241 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenuPreviewItemStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewItemOwnerState, + MenuPreviewItemBaseProps, + MenuPreviewItemOwnerState, + MenuPreviewItemVisualProps, + menuPreviewItemOverridesResolver, + mergeMenuPreviewItemClassName, + useMenuPreviewItemUtilityClasses, +} from '../MenuPreview/menuPreviewItemShared'; +import { + getMenuPreviewRootRender, + isMenuPreviewRootNativeButton, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewItemUtilityClass, + menuPreviewItemClasses, + MenuPreviewItemClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewItemSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewItemSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewItemProps + extends + Omit, + MenuPreviewItemBaseProps, + MenuPreviewItemVisualProps< + MenuPreviewItemClasses, + MenuPreviewItemSlots, + MenuPreviewItemSlotProps + > { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * Whether to close the menu when the item is clicked. + * @default true + */ + closeOnClick?: boolean | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const MenuPreviewItemRoot = styled('div', { + name: 'MuiMenuPreviewItem', + slot: 'Root', + overridesResolver: menuPreviewItemOverridesResolver, +})<{ ownerState: MenuPreviewItemOwnerState }>( + memoTheme(({ theme }) => getMenuPreviewItemStyles(theme, menuPreviewItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewItem API](https://mui.com/material-ui/api/menu-preview-item/) + */ +const MenuPreviewItem = React.forwardRef(function MenuPreviewItem( + inProps: MenuPreviewItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewItem', + }); + + const { + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenuPreviewItemOwnerState({ dense, disabled, disableGutters, divider, selected }), + classes: classesProp, + }; + const classes = useMenuPreviewItemUtilityClasses( + ownerState, + getMenuPreviewItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const RootSlot = slots?.root ?? MenuPreviewItemRoot; + + return ( + + + + ); +}); + +MenuPreviewItem.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * Whether to close the menu when the item is clicked. + * @default true + */ + closeOnClick: PropTypes.bool, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters: PropTypes.bool, + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewItem; diff --git a/packages/mui-material/src/MenuPreviewItem/index.d.ts b/packages/mui-material/src/MenuPreviewItem/index.d.ts new file mode 100644 index 00000000000000..febb5776db5b46 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewItem/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewItem'; +export * from './MenuPreviewItem'; +export { + menuPreviewItemClasses, + getMenuPreviewItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewItemClasses, + MenuPreviewItemClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewItem/index.js b/packages/mui-material/src/MenuPreviewItem/index.js new file mode 100644 index 00000000000000..f2296b0ea1f44f --- /dev/null +++ b/packages/mui-material/src/MenuPreviewItem/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewItem'; +export * from './MenuPreviewItem'; +export { + menuPreviewItemClasses, + getMenuPreviewItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx b/packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx new file mode 100644 index 00000000000000..9f8e8978ff8b2e --- /dev/null +++ b/packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx @@ -0,0 +1,233 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenuPreviewItemStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewItemOwnerState, + MenuPreviewLinkItemBaseProps, + MenuPreviewItemOwnerState, + MenuPreviewItemVisualProps, + menuPreviewItemOverridesResolver, + mergeMenuPreviewItemClassName, + useMenuPreviewItemUtilityClasses, +} from '../MenuPreview/menuPreviewItemShared'; +import { + getMenuPreviewLinkItemUtilityClass, + menuPreviewLinkItemClasses, + MenuPreviewLinkItemClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewLinkItemSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewLinkItemSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewLinkItemProps + extends + Omit, + MenuPreviewLinkItemBaseProps, + MenuPreviewItemVisualProps< + MenuPreviewLinkItemClasses, + MenuPreviewLinkItemSlots, + MenuPreviewLinkItemSlotProps + > { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * The URL that the link item points to. + */ + href?: string | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick?: boolean | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const MenuPreviewLinkItemRoot = styled('a', { + name: 'MuiMenuPreviewLinkItem', + slot: 'Root', + overridesResolver: menuPreviewItemOverridesResolver, +})<{ ownerState: MenuPreviewItemOwnerState }>( + memoTheme(({ theme }) => getMenuPreviewItemStyles(theme, menuPreviewLinkItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewLinkItem API](https://mui.com/material-ui/api/menu-preview-link-item/) + */ +const MenuPreviewLinkItem = React.forwardRef(function MenuPreviewLinkItem( + inProps: MenuPreviewLinkItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewLinkItem', + }); + + const { + className, + classes: classesProp, + component, + dense = false, + disableGutters = false, + divider = false, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenuPreviewItemOwnerState({ + dense, + disabled: false, + disableGutters, + divider, + selected, + }), + classes: classesProp, + }; + const classes = useMenuPreviewItemUtilityClasses( + ownerState, + getMenuPreviewLinkItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + + return ( + + + + ); +}); + +MenuPreviewLinkItem.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick: PropTypes.bool, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense: PropTypes.bool, + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters: PropTypes.bool, + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * The URL that the link item points to. + */ + href: PropTypes.string, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewLinkItem; diff --git a/packages/mui-material/src/MenuPreviewLinkItem/index.d.ts b/packages/mui-material/src/MenuPreviewLinkItem/index.d.ts new file mode 100644 index 00000000000000..d67220bdee8596 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewLinkItem/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewLinkItem'; +export * from './MenuPreviewLinkItem'; +export { + menuPreviewLinkItemClasses, + getMenuPreviewLinkItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewLinkItemClasses, + MenuPreviewLinkItemClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewLinkItem/index.js b/packages/mui-material/src/MenuPreviewLinkItem/index.js new file mode 100644 index 00000000000000..7a69a871b02fdc --- /dev/null +++ b/packages/mui-material/src/MenuPreviewLinkItem/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewLinkItem'; +export * from './MenuPreviewLinkItem'; +export { + menuPreviewLinkItemClasses, + getMenuPreviewLinkItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx b/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx new file mode 100644 index 00000000000000..cafc2212940014 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx @@ -0,0 +1,389 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import composeClasses from '@mui/utils/composeClasses'; +import HTMLElementType from '@mui/utils/HTMLElementType'; +import { SxProps } from '@mui/system'; +import Paper from '../Paper'; +import List from '../List'; +import { styled } from '../zero-styled'; +import { Theme } from '../styles'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + MenuPreviewPopupBase, + MenuPreviewPopupPublicProps, + MenuPreviewPopupSharedProps, + MenuPreviewPopupSharedSlotProps, + MenuPreviewPopupSharedSlots, +} from '../MenuPreview/menuPreviewPopupShared'; +import { + menuPreviewPopupListStyles, + menuPreviewPopupPaperStyles, +} from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewPopupUtilityClass, + MenuPreviewPopupClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewPopupProps extends Omit< + MenuPreviewPopupSharedProps, + | 'classes' + | 'defaultPositionerProps' + | 'defaultSlots' + | 'ownerState' + | keyof MenuPreviewPopupPublicProps +> { + /** + * The menu items. + */ + children?: React.ReactNode; + /** + * CSS class applied to the Base UI popup element. + */ + className?: MenuPreviewPopupPublicProps['className']; + /** + * Styles applied to the Base UI popup element. + */ + style?: MenuPreviewPopupPublicProps['style']; + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the trigger. + */ + anchor?: MenuPreviewPopupPublicProps['anchor']; + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod?: MenuPreviewPopupPublicProps['positionMethod']; + /** + * Which side of the anchor element to align the popup against. + * @default 'bottom' + */ + side?: MenuPreviewPopupPublicProps['side']; + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset?: MenuPreviewPopupPublicProps['sideOffset']; + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align?: MenuPreviewPopupPublicProps['align']; + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset?: MenuPreviewPopupPublicProps['alignOffset']; + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary?: MenuPreviewPopupPublicProps['collisionBoundary']; + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding?: MenuPreviewPopupPublicProps['collisionPadding']; + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding?: MenuPreviewPopupPublicProps['arrowPadding']; + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky?: MenuPreviewPopupPublicProps['sticky']; + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking?: MenuPreviewPopupPublicProps['disableAnchorTracking']; + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance?: MenuPreviewPopupPublicProps['collisionAvoidance']; + /** + * The container element to portal the popup into. + */ + container?: MenuPreviewPopupPublicProps['container']; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: MenuPreviewPopupPublicProps['keepMounted']; + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus?: MenuPreviewPopupPublicProps['finalFocus']; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewPopupSlotProps | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewPopupSlots | undefined; +} + +export interface MenuPreviewPopupOwnerState extends MenuPreviewPopupProps {} + +export interface MenuPreviewPopupSlots extends MenuPreviewPopupSharedSlots {} + +export interface MenuPreviewPopupSlotProps extends MenuPreviewPopupSharedSlotProps {} + +const useUtilityClasses = (ownerState: MenuPreviewPopupOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + paper: ['paper'], + list: ['list'], + }; + + return composeClasses(slots, getMenuPreviewPopupUtilityClass, classes); +}; + +const MenuPreviewPopupRoot = styled('div', { + name: 'MuiMenuPreviewPopup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ + outline: 0, +}); + +const MenuPreviewPopupPaper = styled(Paper, { + name: 'MuiMenuPreviewPopup', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})(menuPreviewPopupPaperStyles); + +const MenuPreviewPopupList = styled(List, { + name: 'MuiMenuPreviewPopup', + slot: 'List', + overridesResolver: (props, styles) => styles.list, +})(menuPreviewPopupListStyles); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewPopup API](https://mui.com/material-ui/api/menu-preview-popup/) + */ +const MenuPreviewPopup = React.forwardRef(function MenuPreviewPopup( + inProps: MenuPreviewPopupProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewPopup', + }); + + const ownerState: MenuPreviewPopupOwnerState = { + side: 'bottom', + align: 'start', + ...props, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +MenuPreviewPopup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align: PropTypes.oneOf(['center', 'end', 'start']), + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the trigger. + */ + anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.object, + PropTypes.func, + ]), + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding: PropTypes.number, + /** + * The menu items. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the Base UI popup element. + */ + className: PropTypes.string, + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance: PropTypes.oneOfType([ + PropTypes.shape({ + align: PropTypes.oneOf(['flip', 'none', 'shift']), + fallbackAxisSide: PropTypes.oneOf(['end', 'none', 'start']), + side: PropTypes.oneOf(['flip', 'none']), + }), + PropTypes.shape({ + align: PropTypes.oneOf(['none', 'shift']), + fallbackAxisSide: PropTypes.oneOf(['end', 'none', 'start']), + side: PropTypes.oneOf(['none', 'shift']), + }), + ]), + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['clipping-ancestors']), + HTMLElementType, + PropTypes.arrayOf(HTMLElementType), + PropTypes.shape({ + height: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }), + ]), + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + top: PropTypes.number, + }), + ]), + /** + * The container element to portal the popup into. + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.object, + PropTypes.func, + ]), + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking: PropTypes.bool, + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.func, + PropTypes.shape({ + current: HTMLElementType, + }), + PropTypes.bool, + ]), + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod: PropTypes.oneOf(['absolute', 'fixed']), + /** + * Which side of the anchor element to align the popup against. + * @default 'bottom' + */ + side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + list: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + popup: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + portal: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + positioner: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + list: PropTypes.elementType, + paper: PropTypes.elementType, + popup: PropTypes.elementType, + portal: PropTypes.elementType, + positioner: PropTypes.elementType, + }), + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky: PropTypes.bool, + /** + * Styles applied to the Base UI popup element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewPopup; diff --git a/packages/mui-material/src/MenuPreviewPopup/index.d.ts b/packages/mui-material/src/MenuPreviewPopup/index.d.ts new file mode 100644 index 00000000000000..82ce5ac8e7311f --- /dev/null +++ b/packages/mui-material/src/MenuPreviewPopup/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewPopup'; +export * from './MenuPreviewPopup'; +export { + menuPreviewPopupClasses, + getMenuPreviewPopupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewPopupClasses, + MenuPreviewPopupClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewPopup/index.js b/packages/mui-material/src/MenuPreviewPopup/index.js new file mode 100644 index 00000000000000..25a0b859273605 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewPopup/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewPopup'; +export * from './MenuPreviewPopup'; +export { + menuPreviewPopupClasses, + getMenuPreviewPopupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx b/packages/mui-material/src/MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx new file mode 100644 index 00000000000000..2ccf9ab360f89b --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx @@ -0,0 +1,228 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewRadioGroupUtilityClass, + MenuPreviewRadioGroupClasses, +} from '../MenuPreview/menuPreviewClasses'; + +interface MenuPreviewRadioGroupOwnerState extends MenuPreviewRadioGroupProps {} + +export interface MenuPreviewRadioGroupSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewRadioGroupSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewRadioGroupProps extends Omit< + BaseMenu.RadioGroup.Props, + 'className' | 'onChange' | 'onValueChange' | 'render' | 'style' +> { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * The controlled value of the radio item that should be currently selected. + */ + value?: any; + /** + * The uncontrolled value of the radio item that should be initially selected. + */ + defaultValue?: any; + /** + * Function called when the selected value changes. + */ + onChange?: + | ((event: Event, value: any, eventDetails: BaseMenu.RadioGroup.ChangeEventDetails) => void) + | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewRadioGroupSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewRadioGroupSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +const useUtilityClasses = (ownerState: MenuPreviewRadioGroupOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + disabled: ['disabled'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenuPreviewRadioGroupUtilityClass, classes), + }; +}; + +const MenuPreviewRadioGroupRoot = styled('div', { + name: 'MuiMenuPreviewRadioGroup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewRadioGroup API](https://mui.com/material-ui/api/menu-preview-radio-group/) + */ +const MenuPreviewRadioGroup = React.forwardRef(function MenuPreviewRadioGroup( + inProps: MenuPreviewRadioGroupProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewRadioGroup', + }); + + const { + className, + classes: classesProp, + component, + onChange, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + const handleValueChange = React.useCallback( + (newValue: any, eventDetails: BaseMenu.RadioGroup.ChangeEventDetails) => { + onChange?.(eventDetails.event, newValue, eventDetails); + }, + [onChange], + ); + + return ( + clsx(className, classes.root, state.disabled && classes.disabled)} + onValueChange={handleValueChange} + style={style} + {...other} + /> + ); +}); + +MenuPreviewRadioGroup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * The uncontrolled value of the radio item that should be initially selected. + */ + defaultValue: PropTypes.any, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * Function called when the selected value changes. + */ + onChange: PropTypes.func, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), + /** + * The controlled value of the radio item that should be currently selected. + */ + value: PropTypes.any, +} as any; + +export default MenuPreviewRadioGroup; diff --git a/packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts b/packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts new file mode 100644 index 00000000000000..f4e14f49d9bc87 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewRadioGroup'; +export * from './MenuPreviewRadioGroup'; +export { + menuPreviewRadioGroupClasses, + getMenuPreviewRadioGroupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewRadioGroupClasses, + MenuPreviewRadioGroupClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioGroup/index.js b/packages/mui-material/src/MenuPreviewRadioGroup/index.js new file mode 100644 index 00000000000000..a5e718613154f1 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioGroup/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewRadioGroup'; +export * from './MenuPreviewRadioGroup'; +export { + menuPreviewRadioGroupClasses, + getMenuPreviewRadioGroupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx b/packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx new file mode 100644 index 00000000000000..5a76496c40712d --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx @@ -0,0 +1,256 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenuPreviewItemStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewRootRender, + isMenuPreviewRootNativeButton, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewItemClassName, + getMenuPreviewItemOwnerState, + MenuPreviewItemBaseProps, + MenuPreviewItemOwnerState, + MenuPreviewItemVisualProps, + menuPreviewItemOverridesResolver, + useMenuPreviewItemUtilityClasses, +} from '../MenuPreview/menuPreviewItemShared'; +import { + getMenuPreviewRadioItemUtilityClass, + menuPreviewRadioItemClasses, + MenuPreviewRadioItemClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewRadioItemSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewRadioItemSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewRadioItemProps + extends + Omit, + MenuPreviewItemBaseProps, + MenuPreviewItemVisualProps< + MenuPreviewRadioItemClasses, + MenuPreviewRadioItemSlots, + MenuPreviewRadioItemSlotProps + > { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Value of the radio item. + */ + value: any; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick?: boolean | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const MenuPreviewRadioItemRoot = styled('div', { + name: 'MuiMenuPreviewRadioItem', + slot: 'Root', + overridesResolver: menuPreviewItemOverridesResolver, +})<{ ownerState: MenuPreviewItemOwnerState }>( + memoTheme(({ theme }) => getMenuPreviewItemStyles(theme, menuPreviewRadioItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewRadioItem API](https://mui.com/material-ui/api/menu-preview-radio-item/) + */ +const MenuPreviewRadioItem = React.forwardRef(function MenuPreviewRadioItem( + inProps: MenuPreviewRadioItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewRadioItem', + }); + + const { + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenuPreviewItemOwnerState({ dense, disabled, disableGutters, divider, selected }), + classes: classesProp, + }; + const classes = useMenuPreviewItemUtilityClasses( + ownerState, + getMenuPreviewRadioItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const RootSlot = slots?.root ?? MenuPreviewRadioItemRoot; + + return ( + + + clsx( + className, + getMenuPreviewItemClassName(classes, ownerState, state), + state.checked && classes.checked, + ) + } + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenuPreviewRootNativeButton(RootSlot, component)} + style={style} + {...other} + /> + + ); +}); + +MenuPreviewRadioItem.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * Whether to close the menu when the item is clicked. + * @default false + */ + closeOnClick: PropTypes.bool, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters: PropTypes.bool, + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), + /** + * Value of the radio item. + */ + value: PropTypes.any.isRequired, +} as any; + +export default MenuPreviewRadioItem; diff --git a/packages/mui-material/src/MenuPreviewRadioItem/index.d.ts b/packages/mui-material/src/MenuPreviewRadioItem/index.d.ts new file mode 100644 index 00000000000000..05366886438934 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioItem/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewRadioItem'; +export * from './MenuPreviewRadioItem'; +export { + menuPreviewRadioItemClasses, + getMenuPreviewRadioItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewRadioItemClasses, + MenuPreviewRadioItemClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItem/index.js b/packages/mui-material/src/MenuPreviewRadioItem/index.js new file mode 100644 index 00000000000000..fa1950c32d8e36 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioItem/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewRadioItem'; +export * from './MenuPreviewRadioItem'; +export { + menuPreviewRadioItemClasses, + getMenuPreviewRadioItemUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx b/packages/mui-material/src/MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx new file mode 100644 index 00000000000000..09c7affeb4424d --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx @@ -0,0 +1,223 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menuPreviewIndicatorStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewRadioItemIndicatorUtilityClass, + MenuPreviewRadioItemIndicatorClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewRadioItemIndicatorSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewRadioItemIndicatorSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewRadioItemIndicatorProps extends Omit< + BaseMenu.RadioItemIndicator.Props, + 'className' | 'render' | 'style' +> { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Whether to keep the HTML element in the DOM when the radio item is inactive. + * @default false + */ + keepMounted?: boolean | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewRadioItemIndicatorSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewRadioItemIndicatorSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +const useUtilityClasses = (ownerState: MenuPreviewRadioItemIndicatorProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + checked: ['checked'], + disabled: ['disabled'], + highlighted: ['highlighted'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenuPreviewRadioItemIndicatorUtilityClass, classes), + }; +}; + +const MenuPreviewRadioItemIndicatorRoot = styled('span', { + name: 'MuiMenuPreviewRadioItemIndicator', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})(menuPreviewIndicatorStyles) as any; + +function DefaultRadioIndicatorIcon() { + return ( + + ); +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewRadioItemIndicator API](https://mui.com/material-ui/api/menu-preview-radio-item-indicator/) + */ +const MenuPreviewRadioItemIndicator = React.forwardRef(function MenuPreviewRadioItemIndicator( + inProps: MenuPreviewRadioItemIndicatorProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewRadioItemIndicator', + }); + + const { + children, + className, + classes: classesProp, + component, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + clsx( + className, + classes.root, + state.checked && classes.checked, + state.disabled && classes.disabled, + state.highlighted && classes.highlighted, + ) + } + style={style} + {...other} + > + {children ?? } + + ); +}); + +MenuPreviewRadioItemIndicator.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * Whether to keep the HTML element in the DOM when the radio item is inactive. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewRadioItemIndicator; diff --git a/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts b/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts new file mode 100644 index 00000000000000..4fa829e8dedd1c --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewRadioItemIndicator'; +export * from './MenuPreviewRadioItemIndicator'; +export { + menuPreviewRadioItemIndicatorClasses, + getMenuPreviewRadioItemIndicatorUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewRadioItemIndicatorClasses, + MenuPreviewRadioItemIndicatorClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js b/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js new file mode 100644 index 00000000000000..32b270dcd36da5 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewRadioItemIndicator'; +export * from './MenuPreviewRadioItemIndicator'; +export { + menuPreviewRadioItemIndicatorClasses, + getMenuPreviewRadioItemIndicatorUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx b/packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx new file mode 100644 index 00000000000000..6a01bce0a07c73 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx @@ -0,0 +1,184 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Separator as BaseSeparator } from '@base-ui/react/separator'; +import { SxProps } from '@mui/system'; +import Divider from '../Divider'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenuPreviewRootRender, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewSeparatorUtilityClass, + MenuPreviewSeparatorClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewSeparatorSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewSeparatorSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewSeparatorProps extends Omit< + BaseSeparator.Props, + 'className' | 'render' | 'style' +> { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewSeparatorSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewSeparatorSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +const useUtilityClasses = (ownerState: MenuPreviewSeparatorProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getMenuPreviewSeparatorUtilityClass, classes); +}; + +const MenuPreviewSeparatorRoot = styled(Divider, { + name: 'MuiMenuPreviewSeparator', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewSeparator API](https://mui.com/material-ui/api/menu-preview-separator/) + */ +const MenuPreviewSeparator = React.forwardRef(function MenuPreviewSeparator( + inProps: MenuPreviewSeparatorProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewSeparator', + }); + + const { + className, + classes: classesProp, + component, + orientation = 'horizontal', + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + orientation, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +MenuPreviewSeparator.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * The orientation of the separator. + * @default 'horizontal' + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewSeparator; diff --git a/packages/mui-material/src/MenuPreviewSeparator/index.d.ts b/packages/mui-material/src/MenuPreviewSeparator/index.d.ts new file mode 100644 index 00000000000000..d39696b5ea6141 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSeparator/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewSeparator'; +export * from './MenuPreviewSeparator'; +export { + menuPreviewSeparatorClasses, + getMenuPreviewSeparatorUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewSeparatorClasses, + MenuPreviewSeparatorClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSeparator/index.js b/packages/mui-material/src/MenuPreviewSeparator/index.js new file mode 100644 index 00000000000000..b610767aee6389 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSeparator/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewSeparator'; +export * from './MenuPreviewSeparator'; +export { + menuPreviewSeparatorClasses, + getMenuPreviewSeparatorUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx b/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx new file mode 100644 index 00000000000000..752bd5347ad8d7 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx @@ -0,0 +1,389 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import composeClasses from '@mui/utils/composeClasses'; +import HTMLElementType from '@mui/utils/HTMLElementType'; +import { SxProps } from '@mui/system'; +import Paper from '../Paper'; +import List from '../List'; +import { styled } from '../zero-styled'; +import { Theme } from '../styles'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + MenuPreviewPopupBase, + MenuPreviewPopupPublicProps, + MenuPreviewPopupSharedProps, + MenuPreviewPopupSharedSlotProps, + MenuPreviewPopupSharedSlots, +} from '../MenuPreview/menuPreviewPopupShared'; +import { + menuPreviewPopupListStyles, + menuPreviewPopupPaperStyles, +} from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewSubmenuPopupUtilityClass, + MenuPreviewSubmenuPopupClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewSubmenuPopupProps extends Omit< + MenuPreviewPopupSharedProps, + | 'classes' + | 'defaultPositionerProps' + | 'defaultSlots' + | 'ownerState' + | keyof MenuPreviewPopupPublicProps +> { + /** + * The submenu items. + */ + children?: React.ReactNode; + /** + * CSS class applied to the Base UI popup element. + */ + className?: MenuPreviewPopupPublicProps['className']; + /** + * Styles applied to the Base UI popup element. + */ + style?: MenuPreviewPopupPublicProps['style']; + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the submenu trigger. + */ + anchor?: MenuPreviewPopupPublicProps['anchor']; + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod?: MenuPreviewPopupPublicProps['positionMethod']; + /** + * Which side of the anchor element to align the popup against. + * @default 'inline-end' + */ + side?: MenuPreviewPopupPublicProps['side']; + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset?: MenuPreviewPopupPublicProps['sideOffset']; + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align?: MenuPreviewPopupPublicProps['align']; + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset?: MenuPreviewPopupPublicProps['alignOffset']; + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary?: MenuPreviewPopupPublicProps['collisionBoundary']; + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding?: MenuPreviewPopupPublicProps['collisionPadding']; + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding?: MenuPreviewPopupPublicProps['arrowPadding']; + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky?: MenuPreviewPopupPublicProps['sticky']; + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking?: MenuPreviewPopupPublicProps['disableAnchorTracking']; + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance?: MenuPreviewPopupPublicProps['collisionAvoidance']; + /** + * The container element to portal the popup into. + */ + container?: MenuPreviewPopupPublicProps['container']; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: MenuPreviewPopupPublicProps['keepMounted']; + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus?: MenuPreviewPopupPublicProps['finalFocus']; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewSubmenuPopupSlotProps | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewSubmenuPopupSlots | undefined; +} + +export interface MenuPreviewSubmenuPopupOwnerState extends MenuPreviewSubmenuPopupProps {} + +export interface MenuPreviewSubmenuPopupSlots extends MenuPreviewPopupSharedSlots {} + +export interface MenuPreviewSubmenuPopupSlotProps extends MenuPreviewPopupSharedSlotProps {} + +const useUtilityClasses = (ownerState: MenuPreviewSubmenuPopupOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + paper: ['paper'], + list: ['list'], + }; + + return composeClasses(slots, getMenuPreviewSubmenuPopupUtilityClass, classes); +}; + +const MenuPreviewSubmenuPopupRoot = styled('div', { + name: 'MuiMenuPreviewSubmenuPopup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ + outline: 0, +}); + +const MenuPreviewSubmenuPopupPaper = styled(Paper, { + name: 'MuiMenuPreviewSubmenuPopup', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})(menuPreviewPopupPaperStyles); + +const MenuPreviewSubmenuPopupList = styled(List, { + name: 'MuiMenuPreviewSubmenuPopup', + slot: 'List', + overridesResolver: (props, styles) => styles.list, +})(menuPreviewPopupListStyles); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewSubmenuPopup API](https://mui.com/material-ui/api/menu-preview-submenu-popup/) + */ +const MenuPreviewSubmenuPopup = React.forwardRef(function MenuPreviewSubmenuPopup( + inProps: MenuPreviewSubmenuPopupProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewSubmenuPopup', + }); + + const ownerState: MenuPreviewSubmenuPopupOwnerState = { + side: 'inline-end', + align: 'start', + ...props, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +MenuPreviewSubmenuPopup.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align: PropTypes.oneOf(['center', 'end', 'start']), + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the submenu trigger. + */ + anchor: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.object, + PropTypes.func, + ]), + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding: PropTypes.number, + /** + * The submenu items. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the Base UI popup element. + */ + className: PropTypes.string, + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance: PropTypes.oneOfType([ + PropTypes.shape({ + align: PropTypes.oneOf(['flip', 'none', 'shift']), + fallbackAxisSide: PropTypes.oneOf(['end', 'none', 'start']), + side: PropTypes.oneOf(['flip', 'none']), + }), + PropTypes.shape({ + align: PropTypes.oneOf(['none', 'shift']), + fallbackAxisSide: PropTypes.oneOf(['end', 'none', 'start']), + side: PropTypes.oneOf(['none', 'shift']), + }), + ]), + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.oneOf(['clipping-ancestors']), + HTMLElementType, + PropTypes.arrayOf(HTMLElementType), + PropTypes.shape({ + height: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }), + ]), + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.shape({ + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + top: PropTypes.number, + }), + ]), + /** + * The container element to portal the popup into. + */ + container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + HTMLElementType, + PropTypes.object, + PropTypes.func, + ]), + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking: PropTypes.bool, + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([ + PropTypes.func, + PropTypes.shape({ + current: HTMLElementType, + }), + PropTypes.bool, + ]), + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted: PropTypes.bool, + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod: PropTypes.oneOf(['absolute', 'fixed']), + /** + * Which side of the anchor element to align the popup against. + * @default 'inline-end' + */ + side: PropTypes.oneOf(['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top']), + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset: PropTypes.oneOfType([PropTypes.func, PropTypes.number]), + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + list: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + popup: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + portal: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + positioner: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + list: PropTypes.elementType, + paper: PropTypes.elementType, + popup: PropTypes.elementType, + portal: PropTypes.elementType, + positioner: PropTypes.elementType, + }), + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky: PropTypes.bool, + /** + * Styles applied to the Base UI popup element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewSubmenuPopup; diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts b/packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts new file mode 100644 index 00000000000000..ee1f99c4e671e0 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewSubmenuPopup'; +export * from './MenuPreviewSubmenuPopup'; +export { + menuPreviewSubmenuPopupClasses, + getMenuPreviewSubmenuPopupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewSubmenuPopupClasses, + MenuPreviewSubmenuPopupClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/index.js b/packages/mui-material/src/MenuPreviewSubmenuPopup/index.js new file mode 100644 index 00000000000000..0941a947811103 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuPopup/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewSubmenuPopup'; +export * from './MenuPreviewSubmenuPopup'; +export { + menuPreviewSubmenuPopupClasses, + getMenuPreviewSubmenuPopupUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx b/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx new file mode 100644 index 00000000000000..11ee567dbc9571 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx @@ -0,0 +1,132 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { useDefaultProps } from '../DefaultPropsProvider'; + +export interface MenuPreviewSubmenuRootProps { + /** + * The content of the submenu. + */ + children?: React.ReactNode; + /** + * Whether the submenu is initially open. + * + * To render a controlled submenu, use the `open` prop instead. + * @default false + */ + defaultOpen?: boolean | undefined; + /** + * Whether the submenu is currently open. + */ + open?: boolean | undefined; + /** + * Event handler called when the submenu is opened or closed. + */ + onOpenChange?: BaseMenu.SubmenuRoot.Props['onOpenChange']; + /** + * Event handler called after any animations complete when the submenu is opened or closed. + */ + onOpenChangeComplete?: BaseMenu.SubmenuRoot.Props['onOpenChangeComplete']; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Whether to loop keyboard focus back to the first item. + * @default true + */ + loopFocus?: boolean | undefined; + /** + * Whether moving the pointer over items should highlight them. + * @default true + */ + highlightItemOnHover?: boolean | undefined; + /** + * The visual orientation of the submenu. + * @default 'vertical' + */ + orientation?: 'horizontal' | 'vertical' | undefined; + /** + * When in a submenu, determines whether pressing the Escape key closes the entire menu. + * @default false + */ + closeParentOnEsc?: boolean | undefined; +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewSubmenuRoot API](https://mui.com/material-ui/api/menu-preview-submenu-root/) + */ +function MenuPreviewSubmenuRoot(props: MenuPreviewSubmenuRootProps): React.JSX.Element { + const themedProps = useDefaultProps({ + props, + name: 'MuiMenuPreviewSubmenuRoot', + }); + + return ; +} + +MenuPreviewSubmenuRoot.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the submenu. + */ + children: PropTypes.node, + /** + * When in a submenu, determines whether pressing the Escape key closes the entire menu. + * @default false + */ + closeParentOnEsc: PropTypes.bool, + /** + * Whether the submenu is initially open. + * + * To render a controlled submenu, use the `open` prop instead. + * @default false + */ + defaultOpen: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * Whether moving the pointer over items should highlight them. + * @default true + */ + highlightItemOnHover: PropTypes.bool, + /** + * Whether to loop keyboard focus back to the first item. + * @default true + */ + loopFocus: PropTypes.bool, + /** + * Event handler called when the submenu is opened or closed. + */ + onOpenChange: PropTypes.func, + /** + * Event handler called after any animations complete when the submenu is opened or closed. + */ + onOpenChangeComplete: PropTypes.func, + /** + * Whether the submenu is currently open. + */ + open: PropTypes.bool, + /** + * The visual orientation of the submenu. + * @default 'vertical' + */ + orientation: PropTypes.oneOf(['horizontal', 'vertical']), +} as any; + +export default MenuPreviewSubmenuRoot; diff --git a/packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts b/packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts new file mode 100644 index 00000000000000..df51cc07534051 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts @@ -0,0 +1,2 @@ +export { default } from './MenuPreviewSubmenuRoot'; +export * from './MenuPreviewSubmenuRoot'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuRoot/index.js b/packages/mui-material/src/MenuPreviewSubmenuRoot/index.js new file mode 100644 index 00000000000000..df51cc07534051 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuRoot/index.js @@ -0,0 +1,2 @@ +export { default } from './MenuPreviewSubmenuRoot'; +export * from './MenuPreviewSubmenuRoot'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx b/packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx new file mode 100644 index 00000000000000..66c56f538bc352 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx @@ -0,0 +1,274 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenuPreviewItemStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { + getMenuPreviewRootRender, + isMenuPreviewRootNativeButton, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewItemClassName, + getMenuPreviewItemOwnerState, + MenuPreviewItemOwnerState, + MenuPreviewItemVisualProps, + MenuPreviewSubmenuTriggerBaseProps, + menuPreviewItemOverridesResolver, + useMenuPreviewItemUtilityClasses, +} from '../MenuPreview/menuPreviewItemShared'; +import { + getMenuPreviewSubmenuTriggerUtilityClass, + menuPreviewSubmenuTriggerClasses, + MenuPreviewSubmenuTriggerClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewSubmenuTriggerSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewSubmenuTriggerSlotProps extends MenuPreviewRootSlotProps {} + +export interface MenuPreviewSubmenuTriggerProps + extends + Omit, + MenuPreviewSubmenuTriggerBaseProps, + MenuPreviewItemVisualProps< + MenuPreviewSubmenuTriggerClasses, + MenuPreviewSubmenuTriggerSlots, + MenuPreviewSubmenuTriggerSlotProps + > { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * How long to wait before the submenu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay?: number | undefined; + /** + * How long to wait before closing the submenu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay?: number | undefined; + /** + * Whether the submenu should also open when the trigger is hovered. + */ + openOnHover?: boolean | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const MenuPreviewSubmenuTriggerRoot = styled('div', { + name: 'MuiMenuPreviewSubmenuTrigger', + slot: 'Root', + overridesResolver: menuPreviewItemOverridesResolver, +})<{ ownerState: MenuPreviewItemOwnerState }>( + memoTheme(({ theme }) => getMenuPreviewItemStyles(theme, menuPreviewSubmenuTriggerClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewSubmenuTrigger API](https://mui.com/material-ui/api/menu-preview-submenu-trigger/) + */ +const MenuPreviewSubmenuTrigger = React.forwardRef(function MenuPreviewSubmenuTrigger( + inProps: MenuPreviewSubmenuTriggerProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewSubmenuTrigger', + }); + + const { + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenuPreviewItemOwnerState({ dense, disabled, disableGutters, divider, selected }), + classes: classesProp, + }; + const classes = useMenuPreviewItemUtilityClasses( + ownerState, + getMenuPreviewSubmenuTriggerUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const RootSlot = slots?.root ?? MenuPreviewSubmenuTriggerRoot; + + return ( + + + clsx( + className, + getMenuPreviewItemClassName(classes, ownerState, state), + state.open && classes.open, + ) + } + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenuPreviewRootNativeButton(RootSlot, component)} + style={style} + {...other} + /> + + ); +}); + +MenuPreviewSubmenuTrigger.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * How long to wait before closing the submenu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay: PropTypes.number, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * How long to wait before the submenu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay: PropTypes.number, + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters: PropTypes.bool, + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * Whether the submenu should also open when the trigger is hovered. + */ + openOnHover: PropTypes.bool, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewSubmenuTrigger; diff --git a/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts b/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts new file mode 100644 index 00000000000000..c635b6f0a1818e --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewSubmenuTrigger'; +export * from './MenuPreviewSubmenuTrigger'; +export { + menuPreviewSubmenuTriggerClasses, + getMenuPreviewSubmenuTriggerUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewSubmenuTriggerClasses, + MenuPreviewSubmenuTriggerClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js b/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js new file mode 100644 index 00000000000000..21cf2aafd9dc18 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewSubmenuTrigger'; +export * from './MenuPreviewSubmenuTrigger'; +export { + menuPreviewSubmenuTriggerClasses, + getMenuPreviewSubmenuTriggerUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewTrigger/MenuPreviewTrigger.tsx b/packages/mui-material/src/MenuPreviewTrigger/MenuPreviewTrigger.tsx new file mode 100644 index 00000000000000..309c8a58b37bb6 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewTrigger/MenuPreviewTrigger.tsx @@ -0,0 +1,270 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import Button, { ButtonProps } from '../Button'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenuPreviewRootRender, + isMenuPreviewRootNativeButton, + MenuPreviewRootSlotProps, + MenuPreviewRootSlots, + resolveSlotProps, +} from '../MenuPreview/menuPreviewUtils'; +import { + getMenuPreviewTriggerUtilityClass, + MenuPreviewTriggerClasses, +} from '../MenuPreview/menuPreviewClasses'; + +export interface MenuPreviewTriggerSlots extends MenuPreviewRootSlots {} + +export interface MenuPreviewTriggerProps + extends + Omit< + BaseMenu.Trigger.Props, + 'className' | 'handle' | 'nativeButton' | 'payload' | 'render' | 'style' + >, + Omit< + ButtonProps, + keyof BaseMenu.Trigger.Props | 'classes' | 'component' | 'disabled' | 'href' | 'style' + > { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton?: boolean | undefined; + /** + * How long to wait before the menu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay?: number | undefined; + /** + * How long to wait before closing the menu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay?: number | undefined; + /** + * Whether the menu should also open when the trigger is hovered. + */ + openOnHover?: boolean | undefined; + /** + * The components used for each slot inside. + */ + slots?: MenuPreviewTriggerSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: MenuPreviewTriggerSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +interface MenuPreviewTriggerOwnerState extends MenuPreviewTriggerProps { + disabled: boolean; +} + +export interface MenuPreviewTriggerSlotProps extends MenuPreviewRootSlotProps {} + +const useUtilityClasses = (ownerState: MenuPreviewTriggerOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + disabled: ['disabled'], + open: ['open'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenuPreviewTriggerUtilityClass, classes), + }; +}; + +const MenuPreviewTriggerRoot = styled(Button, { + name: 'MuiMenuPreviewTrigger', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +const BaseMenuTrigger = BaseMenu.Trigger as any; +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + * + * API: + * + * - [MenuPreviewTrigger API](https://mui.com/material-ui/api/menu-preview-trigger/) + */ +const MenuPreviewTrigger = React.forwardRef(function MenuPreviewTrigger( + inProps: MenuPreviewTriggerProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenuPreviewTrigger', + }); + + const { href: ignoredHref, ...propsWithoutHref } = props as MenuPreviewTriggerProps & { + href?: unknown; + }; + void ignoredHref; + + const { + className, + classes: classesProp, + component, + disabled = false, + nativeButton: nativeButtonProp, + slotProps, + slots, + sx, + style, + ...other + } = propsWithoutHref; + const ownerState = { + ...propsWithoutHref, + classes: classesProp, + disabled, + }; + const classes = useUtilityClasses(ownerState); + const RootSlot = slots?.root ?? MenuPreviewTriggerRoot; + + return ( + + clsx( + className, + classes.root, + state.open && classes.open, + state.disabled && classes.disabled, + ) + } + nativeButton={nativeButtonProp ?? isMenuPreviewRootNativeButton(RootSlot, component, true)} + style={style} + {...other} + /> + ); +}) as ((props: MenuPreviewTriggerProps & React.RefAttributes) => React.JSX.Element) & { + propTypes?: any; +}; + +MenuPreviewTrigger.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * How long to wait before closing the menu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay: PropTypes.number, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * How long to wait before the menu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay: PropTypes.number, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * Whether the menu should also open when the trigger is hovered. + */ + openOnHover: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default MenuPreviewTrigger; diff --git a/packages/mui-material/src/MenuPreviewTrigger/index.d.ts b/packages/mui-material/src/MenuPreviewTrigger/index.d.ts new file mode 100644 index 00000000000000..ea8d0c4a9ab413 --- /dev/null +++ b/packages/mui-material/src/MenuPreviewTrigger/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './MenuPreviewTrigger'; +export * from './MenuPreviewTrigger'; +export { + menuPreviewTriggerClasses, + getMenuPreviewTriggerUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; +export type { + MenuPreviewTriggerClasses, + MenuPreviewTriggerClassKey, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewTrigger/index.js b/packages/mui-material/src/MenuPreviewTrigger/index.js new file mode 100644 index 00000000000000..0b40dbfdcb13dd --- /dev/null +++ b/packages/mui-material/src/MenuPreviewTrigger/index.js @@ -0,0 +1,6 @@ +export { default } from './MenuPreviewTrigger'; +export * from './MenuPreviewTrigger'; +export { + menuPreviewTriggerClasses, + getMenuPreviewTriggerUtilityClass, +} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/styles/components.ts b/packages/mui-material/src/styles/components.ts index 3404eaee7c94f0..3e859ef603969e 100644 --- a/packages/mui-material/src/styles/components.ts +++ b/packages/mui-material/src/styles/components.ts @@ -477,6 +477,116 @@ export interface Components { variants?: ComponentsVariants['MuiMenuList'] | undefined; } | undefined; + MuiMenuPreview?: + | { + defaultProps?: ComponentsProps['MuiMenuPreview'] | undefined; + } + | undefined; + MuiMenuPreviewCheckboxItem?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewCheckboxItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewCheckboxItem'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewCheckboxItem'] | undefined; + } + | undefined; + MuiMenuPreviewCheckboxItemIndicator?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewCheckboxItemIndicator'] | undefined; + styleOverrides?: + | ComponentsOverrides['MuiMenuPreviewCheckboxItemIndicator'] + | undefined; + variants?: ComponentsVariants['MuiMenuPreviewCheckboxItemIndicator'] | undefined; + } + | undefined; + MuiMenuPreviewGroup?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewGroup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewGroup'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewGroup'] | undefined; + } + | undefined; + MuiMenuPreviewGroupLabel?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewGroupLabel'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewGroupLabel'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewGroupLabel'] | undefined; + } + | undefined; + MuiMenuPreviewItem?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewItem'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewItem'] | undefined; + } + | undefined; + MuiMenuPreviewLinkItem?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewLinkItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewLinkItem'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewLinkItem'] | undefined; + } + | undefined; + MuiMenuPreviewPopup?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewPopup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewPopup'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewPopup'] | undefined; + } + | undefined; + MuiMenuPreviewRadioGroup?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewRadioGroup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewRadioGroup'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewRadioGroup'] | undefined; + } + | undefined; + MuiMenuPreviewRadioItem?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewRadioItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewRadioItem'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewRadioItem'] | undefined; + } + | undefined; + MuiMenuPreviewRadioItemIndicator?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewRadioItemIndicator'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewRadioItemIndicator'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewRadioItemIndicator'] | undefined; + } + | undefined; + MuiMenuPreviewSeparator?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewSeparator'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewSeparator'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewSeparator'] | undefined; + } + | undefined; + MuiMenuPreviewSubmenuPopup?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewSubmenuPopup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewSubmenuPopup'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewSubmenuPopup'] | undefined; + } + | undefined; + MuiMenuPreviewSubmenuRoot?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewSubmenuRoot'] | undefined; + } + | undefined; + MuiMenuPreviewSubmenuTrigger?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewSubmenuTrigger'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewSubmenuTrigger'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewSubmenuTrigger'] | undefined; + } + | undefined; + MuiMenuPreviewTrigger?: + | { + defaultProps?: ComponentsProps['MuiMenuPreviewTrigger'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenuPreviewTrigger'] | undefined; + variants?: ComponentsVariants['MuiMenuPreviewTrigger'] | undefined; + } + | undefined; MuiMobileStepper?: | { defaultProps?: ComponentsProps['MuiMobileStepper'] | undefined; diff --git a/packages/mui-material/src/styles/overrides.ts b/packages/mui-material/src/styles/overrides.ts index fa9e23fd15ab1a..0006aaddee54a5 100644 --- a/packages/mui-material/src/styles/overrides.ts +++ b/packages/mui-material/src/styles/overrides.ts @@ -67,6 +67,22 @@ import { ListSubheaderClassKey } from '../ListSubheader'; import { MenuClassKey } from '../Menu'; import { MenuItemClassKey } from '../MenuItem'; import { MenuListClassKey } from '../MenuList'; +import { + MenuPreviewCheckboxItemClassKey, + MenuPreviewCheckboxItemIndicatorClassKey, + MenuPreviewGroupClassKey, + MenuPreviewGroupLabelClassKey, + MenuPreviewItemClassKey, + MenuPreviewLinkItemClassKey, + MenuPreviewPopupClassKey, + MenuPreviewRadioGroupClassKey, + MenuPreviewRadioItemClassKey, + MenuPreviewRadioItemIndicatorClassKey, + MenuPreviewSeparatorClassKey, + MenuPreviewSubmenuPopupClassKey, + MenuPreviewSubmenuTriggerClassKey, + MenuPreviewTriggerClassKey, +} from '../MenuPreview'; import { MobileStepperClassKey } from '../MobileStepper'; import { ModalClassKey } from '../Modal'; import { NativeSelectClassKey } from '../NativeSelect'; @@ -211,6 +227,20 @@ export interface ComponentNameToClassKey { MuiMenu: MenuClassKey; MuiMenuItem: MenuItemClassKey; MuiMenuList: MenuListClassKey; + MuiMenuPreviewCheckboxItem: MenuPreviewCheckboxItemClassKey; + MuiMenuPreviewCheckboxItemIndicator: MenuPreviewCheckboxItemIndicatorClassKey; + MuiMenuPreviewGroup: MenuPreviewGroupClassKey; + MuiMenuPreviewGroupLabel: MenuPreviewGroupLabelClassKey; + MuiMenuPreviewItem: MenuPreviewItemClassKey; + MuiMenuPreviewLinkItem: MenuPreviewLinkItemClassKey; + MuiMenuPreviewPopup: MenuPreviewPopupClassKey; + MuiMenuPreviewRadioGroup: MenuPreviewRadioGroupClassKey; + MuiMenuPreviewRadioItem: MenuPreviewRadioItemClassKey; + MuiMenuPreviewRadioItemIndicator: MenuPreviewRadioItemIndicatorClassKey; + MuiMenuPreviewSeparator: MenuPreviewSeparatorClassKey; + MuiMenuPreviewSubmenuPopup: MenuPreviewSubmenuPopupClassKey; + MuiMenuPreviewSubmenuTrigger: MenuPreviewSubmenuTriggerClassKey; + MuiMenuPreviewTrigger: MenuPreviewTriggerClassKey; MuiMobileStepper: MobileStepperClassKey; MuiModal: ModalClassKey; MuiNativeSelect: NativeSelectClassKey; diff --git a/packages/mui-material/src/styles/props.ts b/packages/mui-material/src/styles/props.ts index 35d8b8783307af..c60edf2923a04f 100644 --- a/packages/mui-material/src/styles/props.ts +++ b/packages/mui-material/src/styles/props.ts @@ -64,6 +64,22 @@ import { ListProps } from '../List'; import { ListSubheaderProps } from '../ListSubheader'; import { MenuItemProps } from '../MenuItem'; import { MenuListProps } from '../MenuList'; +import { MenuPreviewProps } from '../MenuPreview'; +import { MenuPreviewCheckboxItemProps } from '../MenuPreviewCheckboxItem'; +import { MenuPreviewCheckboxItemIndicatorProps } from '../MenuPreviewCheckboxItemIndicator'; +import { MenuPreviewGroupProps } from '../MenuPreviewGroup'; +import { MenuPreviewGroupLabelProps } from '../MenuPreviewGroupLabel'; +import { MenuPreviewItemProps } from '../MenuPreviewItem'; +import { MenuPreviewLinkItemProps } from '../MenuPreviewLinkItem'; +import { MenuPreviewPopupProps } from '../MenuPreviewPopup'; +import { MenuPreviewRadioGroupProps } from '../MenuPreviewRadioGroup'; +import { MenuPreviewRadioItemProps } from '../MenuPreviewRadioItem'; +import { MenuPreviewRadioItemIndicatorProps } from '../MenuPreviewRadioItemIndicator'; +import { MenuPreviewSeparatorProps } from '../MenuPreviewSeparator'; +import { MenuPreviewSubmenuPopupProps } from '../MenuPreviewSubmenuPopup'; +import { MenuPreviewSubmenuRootProps } from '../MenuPreviewSubmenuRoot'; +import { MenuPreviewSubmenuTriggerProps } from '../MenuPreviewSubmenuTrigger'; +import { MenuPreviewTriggerProps } from '../MenuPreviewTrigger'; import { MenuProps } from '../Menu'; import { MobileStepperProps } from '../MobileStepper'; import { ModalProps } from '../Modal'; @@ -189,6 +205,22 @@ export interface ComponentsPropsList { MuiMenu: MenuProps; MuiMenuItem: MenuItemProps; MuiMenuList: MenuListProps; + MuiMenuPreview: MenuPreviewProps; + MuiMenuPreviewCheckboxItem: MenuPreviewCheckboxItemProps; + MuiMenuPreviewCheckboxItemIndicator: MenuPreviewCheckboxItemIndicatorProps; + MuiMenuPreviewGroup: MenuPreviewGroupProps; + MuiMenuPreviewGroupLabel: MenuPreviewGroupLabelProps; + MuiMenuPreviewItem: MenuPreviewItemProps; + MuiMenuPreviewLinkItem: MenuPreviewLinkItemProps; + MuiMenuPreviewPopup: MenuPreviewPopupProps; + MuiMenuPreviewRadioGroup: MenuPreviewRadioGroupProps; + MuiMenuPreviewRadioItem: MenuPreviewRadioItemProps; + MuiMenuPreviewRadioItemIndicator: MenuPreviewRadioItemIndicatorProps; + MuiMenuPreviewSeparator: MenuPreviewSeparatorProps; + MuiMenuPreviewSubmenuPopup: MenuPreviewSubmenuPopupProps; + MuiMenuPreviewSubmenuRoot: MenuPreviewSubmenuRootProps; + MuiMenuPreviewSubmenuTrigger: MenuPreviewSubmenuTriggerProps; + MuiMenuPreviewTrigger: MenuPreviewTriggerProps; MuiMobileStepper: MobileStepperProps; MuiModal: ModalProps; MuiNativeSelect: NativeSelectProps; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f6083fb3521de..463a3ac3257662 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: docs: '@base-ui/react': - specifier: ^1.4.1 - version: 1.4.1 + specifier: ^1.5.0 + version: 1.5.0 '@docsearch/react': specifier: ^3.9.0 version: 3.9.0 @@ -256,7 +256,7 @@ importers: version: 7.29.2 '@base-ui/react': specifier: catalog:docs - version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@docsearch/react': specifier: ^3.9.0 version: 3.9.0(@algolia/client-search@5.18.0)(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(search-insights@2.13.0) @@ -697,8 +697,8 @@ importers: specifier: ^7.29.2 version: 7.29.2 '@base-ui/react': - specifier: ^1 - version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^1.5.0 + version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@docsearch/react': specifier: catalog:docs version: 3.9.0(@algolia/client-search@5.18.0)(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(search-insights@2.13.0) @@ -1111,6 +1111,9 @@ importers: '@babel/runtime': specifier: ^7.29.2 version: 7.29.2 + '@base-ui/react': + specifier: ^1.5.0 + version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@emotion/react': specifier: ^11.5.0 version: 11.14.0(@types/react@19.2.14)(react@19.2.6) @@ -2404,8 +2407,8 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.4.1': - resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==} + '@base-ui/react@1.5.0': + resolution: {integrity: sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -2421,16 +2424,6 @@ packages: date-fns: optional: true - '@base-ui/utils@0.2.8': - resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==} - peerDependencies: - '@types/react': ^17 || ^18 || ^19 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true - '@base-ui/utils@0.2.9': resolution: {integrity: sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw==} peerDependencies: @@ -4374,10 +4367,10 @@ packages: resolution: {integrity: sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw==} engines: {node: ^20.17.0 || >=22.9.0} - '@nx/devkit@22.7.5': - resolution: {integrity: sha512-/63ziS7kdHXYTLLhwWBu9hFwoFFT8xf+PkcQjsNdPqc5JmkYkSew0cE/vp5ORgBpGLWWnFPJgmfqjbJoO2C7jA==} + '@nx/devkit@21.6.8': + resolution: {integrity: sha512-N0cj0NqdxY2pcI0IJV+fAu362B6tppdv2ohSBNGacNeSqxfAlJxO5TFZePDmxX5nt0t9hAqT+iasfu4BSYGfZw==} peerDependencies: - nx: '>= 21 <= 23 || ^22.0.0-0' + nx: '>= 20 <= 22' '@nx/nx-darwin-arm64@22.7.5': resolution: {integrity: sha512-eoPtwx0qZqvRUD+VVOHm150AlSYwYoPxkDHBBGqKCn5nzPspb0lLWw8q83crM/L1M928YgK0WmGf3C++7eqsTA==} @@ -5926,6 +5919,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@3.0.0: + resolution: {integrity: sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==} + array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} @@ -5990,6 +5986,9 @@ packages: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -6108,8 +6107,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.0.2: + resolution: {integrity: sha512-SNMk0OONlQ01uk8EPeiBvTW7W4ovpL5b1O3t1sjpPgfxOQ6BqQJ6XjxinDPR79Z6HdcD5zBBwr5ssiTlgdNztQ==} engines: {node: '>=18'} boolbase@1.0.0: @@ -6475,10 +6474,6 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} - conventional-changelog-angular@7.0.0: resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} engines: {node: '>=16'} @@ -6524,8 +6519,8 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} cookie@1.1.1: @@ -6737,6 +6732,14 @@ packages: supports-color: optional: true + debug@3.1.0: + resolution: {integrity: sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -6745,6 +6748,15 @@ packages: supports-color: optional: true + debug@4.3.6: + resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -6829,6 +6841,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -6902,6 +6918,11 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + ejs@5.0.1: resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} engines: {node: '>=0.12.18'} @@ -6926,6 +6947,10 @@ packages: resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} engines: {node: '>= 4'} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -7285,8 +7310,8 @@ packages: exponential-backoff@3.1.2: resolution: {integrity: sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==} - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + express@5.0.1: + resolution: {integrity: sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==} engines: {node: '>= 18'} extend@3.0.2: @@ -7373,6 +7398,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -7381,9 +7409,9 @@ packages: resolution: {integrity: sha512-Ohygw2lDgc2HNynfUu82Jp5U45+OLLeBQcwWbrex1IAbQw0uNaFMUbm5dhYCF6y7jcORgl5tg19/1zYxlJtLmg==} engines: {node: '>=18'} - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} + finalhandler@2.0.0: + resolution: {integrity: sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==} + engines: {node: '>= 0.8'} find-babel-config@2.1.1: resolution: {integrity: sha512-5Ji+EAysHGe1OipH7GN4qDjok5Z1uw5KAwDCbicU/4wyTZY7CqOCzcWbG7J5ad9mazq67k89fXlbc1MuIfl9uA==} @@ -7474,6 +7502,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -7617,8 +7649,8 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true @@ -7825,8 +7857,8 @@ packages: http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} http-proxy-agent@7.0.2: @@ -7852,6 +7884,10 @@ packages: hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + iconv-lite@0.5.2: + resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -8255,6 +8291,11 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + jake@10.9.2: + resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} + engines: {node: '>=10'} + hasBin: true + java-properties@1.0.2: resolution: {integrity: sha512-qjdpeo2yKlYTH7nFdK0vbZWuTCesk4o63v5iVOlhMQPfuIZQfW/HI35SjfhA+4qpg36rnFSvUK5b1m+ckIblQQ==} engines: {node: '>= 0.6.0'} @@ -8820,6 +8861,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -8843,6 +8888,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -9005,8 +9054,16 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@8.0.7: - resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@8.0.4: + resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} minimatch@9.0.9: @@ -9071,6 +9128,9 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -9653,8 +9713,9 @@ packages: path-to-regexp@3.3.0: resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-to-regexp@8.1.0: + resolution: {integrity: sha512-Bqn3vc8CMHty6zuD+tG23s6v2kwxslHEhTj4eYaVKGIEB+YX/2wd0/rgXLFD9G9id9KCtbVy/3ZgmvZjpa0UdQ==} + engines: {node: '>=16'} path-type@3.0.0: resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} @@ -9924,8 +9985,8 @@ packages: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} engines: {node: '>=20'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} queue-microtask@1.2.3: @@ -9946,9 +10007,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} raw-loader@4.0.2: resolution: {integrity: sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==} @@ -10349,9 +10410,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} + router@2.0.0: + resolution: {integrity: sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==} + engines: {node: '>= 0.10'} rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -10444,15 +10505,15 @@ packages: engines: {node: '>=10'} hasBin: true - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + send@1.1.0: + resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} engines: {node: '>= 18'} serve-handler@6.1.7: resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + serve-static@2.1.0: + resolution: {integrity: sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==} engines: {node: '>= 18'} serve@14.2.6: @@ -10653,6 +10714,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -11131,9 +11196,13 @@ packages: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.0: + resolution: {integrity: sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==} + engines: {node: '>= 0.6'} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -11315,6 +11384,10 @@ packages: util@0.10.4: resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8flags@3.2.0: resolution: {integrity: sha512-mH8etigqMfiGWdeXpaaqGfs6BndypxusHHcv2qSHyZkGEznCd/qAXCWWRzeowtL54147cktFOC4P5y+kl8d8Jg==} engines: {node: '>= 0.10'} @@ -12755,10 +12828,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@base-ui/react@1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@base-ui/utils': 0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@floating-ui/utils': 0.2.11 react: 19.2.6 @@ -12767,17 +12840,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - '@base-ui/utils@0.2.8(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - reselect: 5.2.0 - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - '@types/react': 19.2.14 - '@base-ui/utils@0.2.9(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 @@ -14685,7 +14747,7 @@ snapshots: '@npmcli/package-json@7.0.2': dependencies: '@npmcli/git': 7.0.1 - glob: 11.1.0 + glob: 11.0.3 hosted-git-info: 9.0.2 json-parse-even-better-errors: 5.0.0 proc-log: 6.0.0 @@ -14721,12 +14783,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@nx/devkit@22.7.5(nx@22.7.5)': + '@nx/devkit@21.6.8(nx@22.7.5)': dependencies: - '@zkochan/js-yaml': 0.0.7 - ejs: 5.0.1 + ejs: 3.1.10 enquirer: 2.3.6 - minimatch: 10.2.5 + ignore: 5.3.2 + minimatch: 9.0.3 nx: 22.7.5 semver: 7.8.2 tslib: 2.8.1 @@ -15191,9 +15253,9 @@ snapshots: '@slack/web-api': 7.15.1 '@types/express': 5.0.0 axios: 1.16.0 - express: 5.2.1 - path-to-regexp: 8.4.2 - raw-body: 3.0.2 + express: 5.0.1 + path-to-regexp: 8.1.0 + raw-body: 3.0.0 tsscmp: 1.0.6 transitivePeerDependencies: - bufferutil @@ -16243,6 +16305,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@3.0.0: {} + array-ify@1.0.0: {} array-includes@3.1.9: @@ -16341,6 +16405,8 @@ snapshots: astral-regex@2.0.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} autosuggest-highlight@3.3.4: @@ -16474,17 +16540,18 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.2.2: + body-parser@2.0.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.0 + debug: 3.1.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.5.2 on-finished: 2.4.1 - qs: 6.15.2 - raw-body: 3.0.2 - type-is: 2.1.0 + qs: 6.13.0 + raw-body: 3.0.0 + type-is: 1.6.18 transitivePeerDependencies: - supports-color @@ -16555,7 +16622,7 @@ snapshots: dependencies: '@npmcli/fs': 4.0.0 fs-minipass: 3.0.3 - glob: 11.1.0 + glob: 11.0.3 lru-cache: 11.5.1 minipass: 7.1.3 minipass-collect: 2.0.1 @@ -16844,8 +16911,6 @@ snapshots: content-type@1.0.5: {} - content-type@2.0.0: {} - conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -16911,7 +16976,7 @@ snapshots: cookie-signature@1.2.2: {} - cookie@0.7.2: {} + cookie@0.7.1: {} cookie@1.1.1: {} @@ -17130,10 +17195,18 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.1.0: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 + debug@4.3.6: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -17196,6 +17269,8 @@ snapshots: dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} devlop@1.1.0: @@ -17269,6 +17344,10 @@ snapshots: ee-first@1.1.1: {} + ejs@3.1.10: + dependencies: + jake: 10.9.2 + ejs@5.0.1: {} electron-to-chromium@1.5.368: {} @@ -17283,6 +17362,8 @@ snapshots: emojis-list@3.0.0: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} encoding@0.1.13: @@ -17847,35 +17928,39 @@ snapshots: exponential-backoff@3.1.2: {} - express@5.2.1: + express@5.0.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.0.2 content-disposition: 1.0.0 content-type: 1.0.5 - cookie: 0.7.2 + cookie: 0.7.1 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.3.6 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.0.0 fresh: 2.0.0 - http-errors: 2.0.1 + http-errors: 2.0.0 merge-descriptors: 2.0.0 + methods: 1.1.2 mime-types: 3.0.2 on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 + qs: 6.13.0 range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.1.0 + router: 2.0.0 + safe-buffer: 5.2.1 + send: 1.1.0 + serve-static: 2.1.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 2.0.0 + utils-merge: 1.0.1 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -17961,6 +18046,10 @@ snapshots: dependencies: flat-cache: 4.0.1 + filelist@1.0.4: + dependencies: + minimatch: 5.1.9 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -17969,14 +18058,15 @@ snapshots: dependencies: '@babel/runtime': 7.29.2 - finalhandler@2.1.1: + finalhandler@2.0.0: dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 + debug: 2.6.9 + encodeurl: 1.0.2 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.2 + statuses: 2.0.1 + unpipe: 1.0.0 transitivePeerDependencies: - supports-color @@ -18065,6 +18155,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -18217,7 +18309,7 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@11.1.0: + glob@11.0.3: dependencies: foreground-child: 3.3.1 jackspeak: 4.1.1 @@ -18244,7 +18336,7 @@ snapshots: glob@9.3.5: dependencies: fs.realpath: 1.0.0 - minimatch: 8.0.7 + minimatch: 8.0.4 minipass: 4.2.8 path-scurry: 1.11.1 @@ -18440,12 +18532,12 @@ snapshots: http-cache-semantics@4.1.1: {} - http-errors@2.0.1: + http-errors@2.0.0: dependencies: depd: 2.0.0 inherits: 2.0.4 setprototypeof: 1.2.0 - statuses: 2.0.2 + statuses: 2.0.1 toidentifier: 1.0.1 http-proxy-agent@7.0.2: @@ -18470,6 +18562,10 @@ snapshots: hyphenate-style-name@1.1.0: {} + iconv-lite@0.5.2: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -18810,6 +18906,13 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + jake@10.9.2: + dependencies: + async: 3.2.6 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.5 + java-properties@1.0.2: {} jest-diff@30.2.0: @@ -19058,7 +19161,7 @@ snapshots: '@npmcli/arborist': 9.1.6 '@npmcli/package-json': 7.0.2 '@npmcli/run-script': 10.0.3 - '@nx/devkit': 22.7.5(nx@22.7.5) + '@nx/devkit': 21.6.8(nx@22.7.5) '@octokit/plugin-enterprise-rest': 6.0.1 '@octokit/rest': 20.1.2 aproba: 2.0.0 @@ -19584,6 +19687,8 @@ snapshots: mdn-data@2.27.1: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} meow@14.1.0: {} @@ -19608,6 +19713,8 @@ snapshots: merge2@1.4.1: {} + methods@1.1.2: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.0.2 @@ -19924,7 +20031,15 @@ snapshots: dependencies: brace-expansion: 1.1.15 - minimatch@8.0.7: + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@8.0.4: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.3: dependencies: brace-expansion: 2.1.1 @@ -19984,6 +20099,8 @@ snapshots: ms@2.0.0: {} + ms@2.1.2: {} + ms@2.1.3: {} multipipe@1.0.2: @@ -20765,7 +20882,7 @@ snapshots: path-to-regexp@3.3.0: {} - path-to-regexp@8.4.2: {} + path-to-regexp@8.1.0: {} path-type@3.0.0: dependencies: @@ -20997,7 +21114,7 @@ snapshots: dependencies: hookified: 1.15.0 - qs@6.15.2: + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -21011,11 +21128,11 @@ snapshots: range-parser@1.2.1: {} - raw-body@3.0.2: + raw-body@3.0.0: dependencies: bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.0 + http-errors: 2.0.0 + iconv-lite: 0.6.3 unpipe: 1.0.0 raw-loader@4.0.2(webpack@5.107.1(esbuild@0.27.7)(lightningcss@1.32.0)(postcss@8.5.15)): @@ -21629,15 +21746,15 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.61.1 fsevents: 2.3.3 - router@2.2.0: + router@2.0.0: dependencies: - debug: 4.4.3 - depd: 2.0.0 + array-flatten: 3.0.0 is-promise: 4.0.0 + methods: 1.1.2 parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color + path-to-regexp: 8.1.0 + setprototypeof: 1.2.0 + utils-merge: 1.0.1 rrweb-cssom@0.8.0: {} @@ -21722,15 +21839,16 @@ snapshots: semver@7.8.2: {} - send@1.2.1: + send@1.1.0: dependencies: debug: 4.4.3 + destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 + fresh: 0.5.2 + http-errors: 2.0.0 + mime-types: 2.1.35 ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 @@ -21748,12 +21866,12 @@ snapshots: path-to-regexp: 3.3.0 range-parser: 1.2.0 - serve-static@2.2.1: + serve-static@2.1.0: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.1.0 transitivePeerDependencies: - supports-color @@ -22006,6 +22124,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.1: {} + statuses@2.0.2: {} std-env@4.1.0: {} @@ -22472,9 +22592,14 @@ snapshots: type-fest@3.13.1: {} - type-is@2.1.0: + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.0.0: dependencies: - content-type: 2.0.0 + content-type: 1.0.5 media-typer: 1.1.0 mime-types: 3.0.2 @@ -22725,6 +22850,8 @@ snapshots: dependencies: inherits: 2.0.3 + utils-merge@1.0.1: {} + v8flags@3.2.0: dependencies: homedir-polyfill: 1.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b8dd8576572781..9ae8ef3988a95d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,7 +27,7 @@ trustPolicyIgnoreAfter: 525600 catalogs: docs: stylis: '4.2.0' - '@base-ui/react': '^1.4.1' + '@base-ui/react': '^1.5.0' '@docsearch/react': '^3.9.0' '@emotion/cache': '^11.14.0' '@emotion/react': '^11.14.0' From e591fa63090273dedac4b5fa5906e4ad800ede23 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 18 Jun 2026 06:52:26 +0800 Subject: [PATCH 02/62] add preview card with popover --- docs/pages/experiments/menu-preview.tsx | 227 ++++++++++++++++++++++-- 1 file changed, 210 insertions(+), 17 deletions(-) diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx index 44756c616a4809..ed1892ea0eeb47 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu-preview.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import Container from '@mui/material/Container'; import CssBaseline from '@mui/material/CssBaseline'; +import Popover from '@mui/material/Popover'; import Stack from '@mui/material/Stack'; import Tooltip, { type TooltipProps } from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; @@ -29,7 +30,7 @@ import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; interface MenuSettings { modal: boolean; disabled: boolean; - openOnHover: boolean; + submenusOpenOnHover: boolean; } const theme = createTheme({}); @@ -37,9 +38,54 @@ const theme = createTheme({}); const defaultSettings: MenuSettings = { modal: true, disabled: false, - openOnHover: false, + submenusOpenOnHover: false, }; +interface PreviewCardItem { + id: string; + label: string; + description: string; + footer: string; +} + +const rootPreviewCardItems: PreviewCardItem[] = [ + { + id: 'template-gallery', + label: 'Template gallery', + description: 'Start from a polished document layout for notes, proposals, and project plans.', + footer: 'Opens the template picker', + }, + { + id: 'publish-web', + label: 'Publish to web', + description: 'Create a public read-only page that updates when this document changes.', + footer: 'Requires sharing permission', + }, +]; + +const versionHistoryPreviewCardItems: PreviewCardItem[] = [ + { + id: 'named-versions', + label: 'Named versions', + description: 'Create and manage named checkpoints for important document milestones.', + footer: 'Keeps the current version history', + }, + { + id: 'compare-changes', + label: 'Compare changes', + description: 'Review edits between two versions and inspect who changed each section.', + footer: 'Opens in a side-by-side view', + }, + { + id: 'restore-version', + label: 'Restore version', + description: 'Replace the current document with a selected earlier version.', + footer: 'Creates a new restore checkpoint', + }, +]; + +const previewCardItems = [...rootPreviewCardItems, ...versionHistoryPreviewCardItems]; + const horizontalTooltipProps = { placement: 'right', slotProps: { @@ -98,6 +144,68 @@ function MenuTooltip(props: { ); } +function MaterialPreviewCard(props: { + id: string | undefined; + item: PreviewCardItem | null; + anchorEl: HTMLElement | null; +}) { + const { id, item, anchorEl } = props; + const open = Boolean(item && anchorEl); + + return ( + + {item ? ( + + + {item.label} + + + {item.description} + + + {item.footer} + + + ) : null} + + ); +} + function DisabledTooltip(props: { title: string; children: React.ReactElement }) { const { title, children } = props; @@ -110,6 +218,91 @@ function DisabledTooltip(props: { title: string; children: React.ReactElement }) ); } +function MenuPreviewWithPreviewCardsDemo({ + submenusOpenOnHover, +}: { + submenusOpenOnHover: boolean; +}) { + const previewCardIdPrefix = React.useId(); + const [activeItemId, setActiveItemId] = React.useState(null); + const [anchorEl, setAnchorEl] = React.useState(null); + const activeItem = + previewCardItems.find((previewCardItem) => previewCardItem.id === activeItemId) ?? null; + const activePreviewCardId = activeItem + ? `${previewCardIdPrefix}-${activeItem.id}-preview-card` + : undefined; + + const clearActiveItem = () => { + setActiveItemId(null); + setAnchorEl(null); + }; + + const getPreviewCardProps = (item: PreviewCardItem) => { + const setActiveItem = (element: HTMLElement) => { + setActiveItemId(item.id); + setAnchorEl(element); + }; + + return { + 'aria-describedby': + activeItemId === item.id ? `${previewCardIdPrefix}-${item.id}-preview-card` : undefined, + onFocus: (event: React.FocusEvent) => { + setActiveItem(event.currentTarget); + }, + onMouseEnter: (event: React.MouseEvent) => { + setActiveItem(event.currentTarget); + }, + }; + }; + + return ( + { + if (!open) { + setActiveItemId(null); + setAnchorEl(null); + } + }} + > + }> + Help cards + + + + {rootPreviewCardItems[0].label} + + + + Version history + + + + {versionHistoryPreviewCardItems.map((item) => ( + + {item.label} + + ))} + + + + {rootPreviewCardItems[1].label} + + + + + ); +} + function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { const handleItemClick = React.useCallback((event: React.MouseEvent) => { // eslint-disable-next-line no-console @@ -118,11 +311,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { return ( - } - openOnHover={settings.openOnHover} - > + }> File @@ -142,7 +331,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { - + View options @@ -194,7 +383,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { - + More tools @@ -208,7 +397,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { - + Download @@ -220,7 +409,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { - + Add-ons unavailable @@ -233,7 +422,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { ); } -function MenuPreviewWithTooltipsDemo() { +function MenuPreviewWithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: boolean }) { return ( }> @@ -259,7 +448,7 @@ function MenuPreviewWithTooltipsDemo() { - + View options @@ -349,10 +538,10 @@ export default function MenuPreviewExperiment() {
@@ -361,7 +550,11 @@ export default function MenuPreviewExperiment() {

Material UI Tooltip integrated with every menu item.

- + +
+
+

Material UI Popover used as a PreviewCard-style menu item help card.

+
Base UI Menu API From edcecab3f5a3fa48d67a4b601366610b5ed77756 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Fri, 19 Jun 2026 07:49:10 +0800 Subject: [PATCH 03/62] do not flip submenu tooltips --- docs/pages/experiments/menu-preview.tsx | 34 ++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx index ed1892ea0eeb47..57fdde8267c223 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu-preview.tsx @@ -7,7 +7,7 @@ import Tooltip, { type TooltipProps } from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles'; import Menu, { CheckboxItem, CheckboxItemIndicator, @@ -111,8 +111,9 @@ interface MenuTooltipChildProps { function MenuTooltip(props: { title: string; children: React.ReactElement; + tooltipProps?: Partial; }) { - const { title, children } = props; + const { title, children, tooltipProps = horizontalTooltipProps } = props; const [open, setOpen] = React.useState(false); const handleOpen = React.useCallback(() => { @@ -132,7 +133,7 @@ function MenuTooltip(props: { return ( >( + () => ({ + placement: direction === 'rtl' ? 'right' : 'left', + slotProps: { + popper: { + popperOptions: { + modifiers: [ + { + // Submenus default to inline-end, so keep this tooltip on + // inline-start instead of letting Popper flip it onto the submenu. + name: 'flip', + enabled: false, + }, + ], + }, + }, + }, + }), + [direction], + ); + return ( }> @@ -447,7 +470,7 @@ function MenuPreviewWithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHo - + View options @@ -545,14 +568,17 @@ export default function MenuPreviewExperiment() {
+

Fully-featured menu with submenus, links, radio groups, and checkbox items.

+

Material UI Tooltip integrated with every menu item.

+

Material UI Popover used as a PreviewCard-style menu item help card.

From a922a5f2f0c7b7892c4f4f7f325b79868eecb6a6 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 22 Jun 2026 08:13:26 +0800 Subject: [PATCH 04/62] dedupe styles --- packages/mui-material/src/Menu/Menu.js | 15 +- packages/mui-material/src/Menu/menuStyles.js | 15 ++ .../mui-material/src/MenuItem/MenuItem.js | 123 +------------- .../src/MenuItem/menuItemStyles.js | 137 +++++++++++++++ .../src/MenuPreview/MenuPreview.test.tsx | 2 + .../MenuPreview/menuPreviewPopupShared.tsx | 2 +- .../MenuPreview/menuPreviewSharedStyles.ts | 158 ++++-------------- 7 files changed, 195 insertions(+), 257 deletions(-) create mode 100644 packages/mui-material/src/Menu/menuStyles.js create mode 100644 packages/mui-material/src/MenuItem/menuItemStyles.js diff --git a/packages/mui-material/src/Menu/Menu.js b/packages/mui-material/src/Menu/Menu.js index 0b7831588c0dea..b683260516ff00 100644 --- a/packages/mui-material/src/Menu/Menu.js +++ b/packages/mui-material/src/Menu/Menu.js @@ -12,6 +12,7 @@ import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenuUtilityClass } from './menuClasses'; import useSlot from '../utils/useSlot'; +import { menuListStyles, menuPaperStyles } from './menuStyles'; const RTL_ORIGIN = { vertical: 'top', @@ -44,22 +45,12 @@ const MenuRoot = styled(Popover, { export const MenuPaper = styled(PopoverPaper, { name: 'MuiMenu', slot: 'Paper', -})({ - // specZ: The maximum height of a simple menu should be one or more rows less than the view - // height. This ensures a tappable area outside of the simple menu with which to dismiss - // the menu. - maxHeight: 'calc(100% - 96px)', - // Add iOS momentum scrolling for iOS < 13.0 - WebkitOverflowScrolling: 'touch', -}); +})(menuPaperStyles); const MenuMenuList = styled(MenuList, { name: 'MuiMenu', slot: 'List', -})({ - // We disable the focus ring for mouse, touch and keyboard users. - outline: 0, -}); +})(menuListStyles); const Menu = React.forwardRef(function Menu(inProps, ref) { const props = useDefaultProps({ props: inProps, name: 'MuiMenu' }); diff --git a/packages/mui-material/src/Menu/menuStyles.js b/packages/mui-material/src/Menu/menuStyles.js new file mode 100644 index 00000000000000..7a7b2cd6e57311 --- /dev/null +++ b/packages/mui-material/src/Menu/menuStyles.js @@ -0,0 +1,15 @@ +/** @type {import('@mui/system').CSSInterpolation} */ +export const menuPaperStyles = { + // specZ: The maximum height of a simple menu should be one or more rows less than the view + // height. This ensures a tappable area outside of the simple menu with which to dismiss + // the menu. + maxHeight: 'calc(100% - 96px)', + // Add iOS momentum scrolling for iOS < 13.0 + WebkitOverflowScrolling: 'touch', +}; + +/** @type {import('@mui/system').CSSInterpolation} */ +export const menuListStyles = { + // We disable the focus ring for mouse, touch and keyboard users. + outline: 0, +}; diff --git a/packages/mui-material/src/MenuItem/MenuItem.js b/packages/mui-material/src/MenuItem/MenuItem.js index 68df44938b4404..538639bccd98c7 100644 --- a/packages/mui-material/src/MenuItem/MenuItem.js +++ b/packages/mui-material/src/MenuItem/MenuItem.js @@ -14,23 +14,12 @@ import focusWithVisible from '../utils/focusWithVisible'; import useForkRef from '../utils/useForkRef'; import useId from '../utils/useId'; import { useRovingTabIndexItem } from '../utils/useRovingTabIndex'; -import { dividerClasses } from '../Divider'; -import { listItemIconClasses } from '../ListItemIcon'; -import { listItemTextClasses } from '../ListItemText'; import { useMenuListContext } from '../MenuList/MenuListContext'; import { useSelectFocusSource } from '../Select/utils'; import menuItemClasses, { getMenuItemUtilityClass } from './menuItemClasses'; +import { getMenuItemRootStyles, menuItemOverridesResolver } from './menuItemStyles'; -export const overridesResolver = (props, styles) => { - const { ownerState } = props; - - return [ - styles.root, - ownerState.dense && styles.dense, - ownerState.divider && styles.divider, - !ownerState.disableGutters && styles.gutters, - ]; -}; +export const overridesResolver = menuItemOverridesResolver; const useUtilityClasses = (ownerState) => { const { disabled, dense, divider, disableGutters, selected, classes } = ownerState; @@ -58,113 +47,7 @@ const MenuItemRoot = styled(ButtonBase, { name: 'MuiMenuItem', slot: 'Root', overridesResolver, -})( - memoTheme(({ theme }) => ({ - ...theme.typography.body1, - display: 'flex', - justifyContent: 'flex-start', - alignItems: 'center', - position: 'relative', - textDecoration: 'none', - minHeight: 48, - paddingTop: 6, - paddingBottom: 6, - boxSizing: 'border-box', - whiteSpace: 'nowrap', - '&:hover': { - textDecoration: 'none', - backgroundColor: (theme.vars || theme).palette.action.hover, - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - [`&.${menuItemClasses.selected}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - [`&.${menuItemClasses.focusVisible}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.focusOpacity}`, - ), - }, - }, - [`&.${menuItemClasses.selected}:hover`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${(theme.vars || theme).palette.action.hoverOpacity}`, - ), - // Reset on touch devices, it doesn't add specificity - '@media (hover: none)': { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - }, - }, - [`&.${menuItemClasses.focusVisible}`]: { - backgroundColor: (theme.vars || theme).palette.action.focus, - }, - [`&.${menuItemClasses.disabled}`]: { - opacity: (theme.vars || theme).palette.action.disabledOpacity, - }, - [`& + .${dividerClasses.root}`]: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - [`& + .${dividerClasses.inset}`]: { - marginLeft: 52, - }, - [`& .${listItemTextClasses.root}`]: { - marginTop: 0, - marginBottom: 0, - }, - [`& .${listItemTextClasses.inset}`]: { - paddingLeft: 36, - }, - [`& .${listItemIconClasses.root}`]: { - minWidth: 36, - }, - variants: [ - { - props: ({ ownerState }) => !ownerState.disableGutters, - style: { - paddingLeft: 16, - paddingRight: 16, - }, - }, - { - props: ({ ownerState }) => ownerState.divider, - style: { - borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, - backgroundClip: 'padding-box', - }, - }, - { - props: ({ ownerState }) => !ownerState.dense, - style: { - [theme.breakpoints.up('sm')]: { - minHeight: 'auto', - }, - }, - }, - { - props: ({ ownerState }) => ownerState.dense, - style: { - minHeight: 32, // https://m2.material.io/components/menus#specs > Dense - paddingTop: 4, - paddingBottom: 4, - ...theme.typography.body2, - [`& .${listItemIconClasses.root} svg`]: { - fontSize: '1.25rem', - }, - }, - }, - ], - })), -); +})(memoTheme(({ theme }) => getMenuItemRootStyles(theme, menuItemClasses))); const MenuItem = React.forwardRef(function MenuItem(inProps, ref) { const props = useDefaultProps({ props: inProps, name: 'MuiMenuItem' }); diff --git a/packages/mui-material/src/MenuItem/menuItemStyles.js b/packages/mui-material/src/MenuItem/menuItemStyles.js new file mode 100644 index 00000000000000..d51264edc4a55a --- /dev/null +++ b/packages/mui-material/src/MenuItem/menuItemStyles.js @@ -0,0 +1,137 @@ +import { dividerClasses } from '../Divider'; +import { listItemIconClasses } from '../ListItemIcon'; +import { listItemTextClasses } from '../ListItemText'; + +export const menuItemOverridesResolver = (props, styles) => { + const { ownerState } = props; + + return [ + styles.root, + ownerState.dense && styles.dense, + ownerState.divider && styles.divider, + !ownerState.disableGutters && styles.gutters, + ]; +}; + +export function getMenuItemRootStyles(theme, classes, options = {}) { + const focusVisibleClass = options.focusVisibleClass ?? classes.focusVisible; + const disabledPointerEvents = options.disabledPointerEvents ?? false; + + return { + ...theme.typography.body1, + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + position: 'relative', + textDecoration: 'none', + minHeight: 48, + paddingTop: 6, + paddingBottom: 6, + boxSizing: 'border-box', + whiteSpace: 'nowrap', + '&:hover': { + textDecoration: 'none', + backgroundColor: (theme.vars || theme).palette.action.hover, + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: 'transparent', + }, + }, + [`&.${classes.selected}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + (theme.vars || theme).palette.action.selectedOpacity, + ), + ...(focusVisibleClass && { + [`&.${focusVisibleClass}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, + ), + }, + }), + }, + [`&.${classes.selected}:hover`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.hoverOpacity + }`, + ), + // Reset on touch devices, it doesn't add specificity + '@media (hover: none)': { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + (theme.vars || theme).palette.action.selectedOpacity, + ), + }, + }, + ...(focusVisibleClass && { + [`&.${focusVisibleClass}`]: { + backgroundColor: (theme.vars || theme).palette.action.focus, + }, + }), + [`&.${classes.disabled}`]: { + opacity: (theme.vars || theme).palette.action.disabledOpacity, + ...(disabledPointerEvents && { + pointerEvents: 'none', + cursor: 'default', + }), + }, + [`& + .${dividerClasses.root}`]: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + [`& + .${dividerClasses.inset}`]: { + marginLeft: 52, + }, + [`& .${listItemTextClasses.root}`]: { + marginTop: 0, + marginBottom: 0, + }, + [`& .${listItemTextClasses.inset}`]: { + paddingLeft: 36, + }, + [`& .${listItemIconClasses.root}`]: { + minWidth: 36, + }, + variants: [ + { + props: ({ ownerState }) => !ownerState.disableGutters, + style: { + paddingLeft: 16, + paddingRight: 16, + }, + }, + { + props: ({ ownerState }) => ownerState.divider, + style: { + borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, + backgroundClip: 'padding-box', + }, + }, + { + props: ({ ownerState }) => !ownerState.dense, + style: { + [theme.breakpoints.up('sm')]: { + minHeight: 'auto', + }, + }, + }, + { + props: ({ ownerState }) => ownerState.dense, + style: { + minHeight: 32, // https://m2.material.io/components/menus#specs > Dense + paddingTop: 4, + paddingBottom: 4, + ...theme.typography.body2, + [`& .${listItemIconClasses.root} svg`]: { + fontSize: '1.25rem', + }, + }, + }, + ], + }; +} diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx index 7b7155e31b7ec4..3dac7e6dd93801 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx +++ b/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; +import { listClasses } from '@mui/material/List'; import Tooltip from '@mui/material/Tooltip'; import MenuPreview, { MenuPreviewCheckboxItem, @@ -64,6 +65,7 @@ describe('', () => { const list = screen.getByTestId('paper').querySelector(`.${menuPreviewPopupClasses.list}`); expect(list).not.to.equal(null); expect(list!.tagName).to.equal('DIV'); + expect(list!).to.have.class(listClasses.padding); expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( menuPreviewItemClasses.root, diff --git a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx index 2e5bc2a049171e..e41db8fd1aa674 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx +++ b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx @@ -336,7 +336,7 @@ export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBa ListSlot, { component: 'div', - disablePadding: true, + disablePadding: false, ...resolvedListProps, className: clsx(classes?.list, resolvedListProps?.className), }, diff --git a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts b/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts index 75295e4ea2b1fb..5621e902dddd89 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts +++ b/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts @@ -1,9 +1,8 @@ import { CSSInterpolation } from '@mui/system'; -import { dividerClasses } from '../Divider'; -import { listItemIconClasses } from '../ListItemIcon'; -import { listItemTextClasses } from '../ListItemText'; import memoTheme from '../utils/memoTheme'; import { Theme } from '../styles'; +import { menuListStyles, menuPaperStyles } from '../Menu/menuStyles'; +import { getMenuItemRootStyles } from '../MenuItem/menuItemStyles'; export interface SharedMenuPreviewItemClasses { highlighted: string; @@ -12,143 +11,54 @@ export interface SharedMenuPreviewItemClasses { divider: string; gutters: string; selected: string; -} - -interface MenuPreviewItemVariantOwnerState { - dense: boolean; - divider: boolean; - disableGutters: boolean; + open?: string; } export function getMenuPreviewItemStyles( theme: Theme, classes: SharedMenuPreviewItemClasses, ): CSSInterpolation { + const selectedFocusBackgroundColor = theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, + ); + return { - ...theme.typography.body1, - display: 'flex', - justifyContent: 'flex-start', - alignItems: 'center', - position: 'relative', - textDecoration: 'none', - minHeight: 48, - paddingTop: 6, - paddingBottom: 6, - boxSizing: 'border-box', - whiteSpace: 'nowrap', - cursor: 'default', + WebkitTapHighlightColor: 'transparent', + backgroundColor: 'transparent', + border: 0, + margin: 0, + borderRadius: 0, + color: 'inherit', + cursor: 'pointer', userSelect: 'none', + verticalAlign: 'middle', + MozAppearance: 'none', + WebkitAppearance: 'none', outline: 0, - '&:hover': { - textDecoration: 'none', - backgroundColor: (theme.vars || theme).palette.action.hover, - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - [`&.${classes.selected}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - [`&.${classes.highlighted}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${ - (theme.vars || theme).palette.action.focusOpacity - }`, - ), - }, - }, - [`&.${classes.selected}:hover`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${ - (theme.vars || theme).palette.action.hoverOpacity - }`, - ), - '@media (hover: none)': { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - (theme.vars || theme).palette.action.selectedOpacity, - ), - }, - }, - [`&.${classes.highlighted}`]: { - backgroundColor: (theme.vars || theme).palette.action.focus, + '&::-moz-focus-inner': { + borderStyle: 'none', }, - [`&.${classes.disabled}`]: { - opacity: (theme.vars || theme).palette.action.disabledOpacity, - pointerEvents: 'none', - }, - [`& + .${dividerClasses.root}`]: { - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - [`& + .${dividerClasses.inset}`]: { - marginLeft: 52, - }, - [`& .${listItemTextClasses.root}`]: { - marginTop: 0, - marginBottom: 0, - }, - [`& .${listItemTextClasses.inset}`]: { - paddingLeft: 36, - }, - [`& .${listItemIconClasses.root}`]: { - minWidth: 36, - }, - variants: [ - { - props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => - !ownerState.disableGutters, - style: { - paddingLeft: 16, - paddingRight: 16, - }, - }, - { - props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => - ownerState.divider, - style: { - borderBottom: `1px solid ${(theme.vars || theme).palette.divider}`, - backgroundClip: 'padding-box', - }, - }, - { - props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => - !ownerState.dense, - style: { - [theme.breakpoints.up('sm')]: { - minHeight: 'auto', - }, - }, + ...getMenuItemRootStyles(theme, classes, { + focusVisibleClass: classes.highlighted, + disabledPointerEvents: true, + }), + ...(classes.open && { + [`&.${classes.open}`]: { + backgroundColor: (theme.vars || theme).palette.action.focus, }, - { - props: ({ ownerState }: { ownerState: MenuPreviewItemVariantOwnerState }) => - ownerState.dense, - style: { - minHeight: 32, - paddingTop: 4, - paddingBottom: 4, - ...theme.typography.body2, - [`& .${listItemIconClasses.root} svg`]: { - fontSize: '1.25rem', - }, - }, + [`&.${classes.selected}.${classes.open}`]: { + backgroundColor: selectedFocusBackgroundColor, }, - ], + }), }; } -export const menuPreviewPopupPaperStyles: CSSInterpolation = { - maxHeight: 'calc(100% - 96px)', - WebkitOverflowScrolling: 'touch', -}; +export const menuPreviewPopupPaperStyles: CSSInterpolation = menuPaperStyles; -export const menuPreviewPopupListStyles: CSSInterpolation = { - outline: 0, -}; +export const menuPreviewPopupListStyles: CSSInterpolation = menuListStyles; export const menuPreviewIndicatorStyles = memoTheme(({ theme }) => ({ display: 'inline-flex', From 65c477725b100a119b448d22337b7a21f80ac3fa Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 29 Jun 2026 18:47:46 +0800 Subject: [PATCH 05/62] fixes --- .../menu-preview-checkbox-item-indicator.js | 12 ++----- .../api/menu-preview-checkbox-item.js | 12 ++----- .../api/menu-preview-group-label.js | 12 ++----- .../material-ui/api/menu-preview-group.js | 12 ++----- .../material-ui/api/menu-preview-item.js | 12 ++----- .../material-ui/api/menu-preview-link-item.js | 12 ++----- .../material-ui/api/menu-preview-popup.js | 12 ++----- .../material-ui/api/menu-preview-popup.json | 1 + .../api/menu-preview-radio-group.js | 12 ++----- .../api/menu-preview-radio-item-indicator.js | 12 ++----- .../api/menu-preview-radio-item.js | 12 ++----- .../material-ui/api/menu-preview-separator.js | 12 ++----- .../api/menu-preview-submenu-popup.js | 12 ++----- .../api/menu-preview-submenu-popup.json | 1 + .../api/menu-preview-submenu-root.js | 12 ++----- .../api/menu-preview-submenu-trigger.js | 12 ++----- .../material-ui/api/menu-preview-trigger.js | 12 ++----- docs/pages/material-ui/api/menu-preview.js | 12 ++----- .../menu-preview-popup.json | 1 + .../menu-preview-submenu-popup.json | 1 + .../src/MenuPreview/MenuPreview.test.tsx | 31 +++++++++++++++++++ .../MenuPreview/menuPreviewPopupShared.tsx | 8 ++++- .../src/MenuPreviewPopup/MenuPreviewPopup.tsx | 10 ++++++ .../MenuPreviewSubmenuPopup.tsx | 10 ++++++ 24 files changed, 94 insertions(+), 161 deletions(-) diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js index dc73f4f5df5157..0fc1bd2331944b 100644 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json'; import jsonPageContent from './menu-preview-checkbox-item-indicator.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item.js b/docs/pages/material-ui/api/menu-preview-checkbox-item.js index 273dae896d9732..0f2c3bb92fb575 100644 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item.js +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json'; import jsonPageContent from './menu-preview-checkbox-item.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-group-label.js b/docs/pages/material-ui/api/menu-preview-group-label.js index b27a7aea1cbebf..8c2b8cbdec3e7b 100644 --- a/docs/pages/material-ui/api/menu-preview-group-label.js +++ b/docs/pages/material-ui/api/menu-preview-group-label.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json'; import jsonPageContent from './menu-preview-group-label.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-group.js b/docs/pages/material-ui/api/menu-preview-group.js index dd950c267f3918..e4ba5947235e0a 100644 --- a/docs/pages/material-ui/api/menu-preview-group.js +++ b/docs/pages/material-ui/api/menu-preview-group.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-group/menu-preview-group.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-group/menu-preview-group.json'; import jsonPageContent from './menu-preview-group.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-item.js b/docs/pages/material-ui/api/menu-preview-item.js index 874e8da3b3785d..9b5ba881bc9d25 100644 --- a/docs/pages/material-ui/api/menu-preview-item.js +++ b/docs/pages/material-ui/api/menu-preview-item.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-item/menu-preview-item.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-item/menu-preview-item.json'; import jsonPageContent from './menu-preview-item.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-link-item.js b/docs/pages/material-ui/api/menu-preview-link-item.js index a4d03702e1b16c..9f12334dbbafee 100644 --- a/docs/pages/material-ui/api/menu-preview-link-item.js +++ b/docs/pages/material-ui/api/menu-preview-link-item.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json'; import jsonPageContent from './menu-preview-link-item.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-popup.js b/docs/pages/material-ui/api/menu-preview-popup.js index 1a9fcae0e09e49..127dc9feed6913 100644 --- a/docs/pages/material-ui/api/menu-preview-popup.js +++ b/docs/pages/material-ui/api/menu-preview-popup.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json'; import jsonPageContent from './menu-preview-popup.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-popup.json b/docs/pages/material-ui/api/menu-preview-popup.json index 74825d504428e0..eae28eec44cda5 100644 --- a/docs/pages/material-ui/api/menu-preview-popup.json +++ b/docs/pages/material-ui/api/menu-preview-popup.json @@ -48,6 +48,7 @@ } }, "disableAnchorTracking": { "type": { "name": "bool" }, "default": "false" }, + "elevation": { "type": { "name": "number" }, "default": "8" }, "finalFocus": { "type": { "name": "union", diff --git a/docs/pages/material-ui/api/menu-preview-radio-group.js b/docs/pages/material-ui/api/menu-preview-radio-group.js index 3b9f531b0034b3..dd1bbd6501522b 100644 --- a/docs/pages/material-ui/api/menu-preview-radio-group.js +++ b/docs/pages/material-ui/api/menu-preview-radio-group.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json'; import jsonPageContent from './menu-preview-radio-group.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js index d44d1d01f14efc..700de197f4f9bb 100644 --- a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js +++ b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json'; import jsonPageContent from './menu-preview-radio-item-indicator.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item.js b/docs/pages/material-ui/api/menu-preview-radio-item.js index fc06a00097e28e..8f58a8bd490986 100644 --- a/docs/pages/material-ui/api/menu-preview-radio-item.js +++ b/docs/pages/material-ui/api/menu-preview-radio-item.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json'; import jsonPageContent from './menu-preview-radio-item.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-separator.js b/docs/pages/material-ui/api/menu-preview-separator.js index 24e4dee90d1c5d..46e88927598939 100644 --- a/docs/pages/material-ui/api/menu-preview-separator.js +++ b/docs/pages/material-ui/api/menu-preview-separator.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json'; import jsonPageContent from './menu-preview-separator.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-popup.js b/docs/pages/material-ui/api/menu-preview-submenu-popup.js index 1feb03c4f823ef..b9e2198291d25d 100644 --- a/docs/pages/material-ui/api/menu-preview-submenu-popup.js +++ b/docs/pages/material-ui/api/menu-preview-submenu-popup.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json'; import jsonPageContent from './menu-preview-submenu-popup.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-popup.json b/docs/pages/material-ui/api/menu-preview-submenu-popup.json index edb42f9acb55e1..44e03df304c3bc 100644 --- a/docs/pages/material-ui/api/menu-preview-submenu-popup.json +++ b/docs/pages/material-ui/api/menu-preview-submenu-popup.json @@ -48,6 +48,7 @@ } }, "disableAnchorTracking": { "type": { "name": "bool" }, "default": "false" }, + "elevation": { "type": { "name": "number" }, "default": "8" }, "finalFocus": { "type": { "name": "union", diff --git a/docs/pages/material-ui/api/menu-preview-submenu-root.js b/docs/pages/material-ui/api/menu-preview-submenu-root.js index 5f12e3a1db11de..71117c38788e95 100644 --- a/docs/pages/material-ui/api/menu-preview-submenu-root.js +++ b/docs/pages/material-ui/api/menu-preview-submenu-root.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json'; import jsonPageContent from './menu-preview-submenu-root.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-trigger.js b/docs/pages/material-ui/api/menu-preview-submenu-trigger.js index 4d4d3bcc553a57..64d99de2e3afc7 100644 --- a/docs/pages/material-ui/api/menu-preview-submenu-trigger.js +++ b/docs/pages/material-ui/api/menu-preview-submenu-trigger.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json'; import jsonPageContent from './menu-preview-submenu-trigger.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview-trigger.js b/docs/pages/material-ui/api/menu-preview-trigger.js index 623e31aedb5914..445e0f871bd7be 100644 --- a/docs/pages/material-ui/api/menu-preview-trigger.js +++ b/docs/pages/material-ui/api/menu-preview-trigger.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json'; +import descriptions from 'docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json'; import jsonPageContent from './menu-preview-trigger.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/pages/material-ui/api/menu-preview.js b/docs/pages/material-ui/api/menu-preview.js index 3cb1b61dd994ee..9ed07a5cc58816 100644 --- a/docs/pages/material-ui/api/menu-preview.js +++ b/docs/pages/material-ui/api/menu-preview.js @@ -1,16 +1,8 @@ import * as React from 'react'; import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import { mapApiPageTranslation } from '@mui/internal-core-docs/mapApiPageTranslations'; -import translation from 'docs/translations/api-docs/menu-preview/menu-preview.json'; +import descriptions from 'docs/translations/api-docs/menu-preview/menu-preview.json'; import jsonPageContent from './menu-preview.json'; -export default function Page(props) { - const { descriptions } = props; +export default function Page() { return ; } - -export async function getStaticProps() { - const descriptions = mapApiPageTranslation(translation); - - return { props: { descriptions } }; -} diff --git a/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json b/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json index 3c92b01c458026..b7b59afbc03738 100644 --- a/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json +++ b/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json @@ -25,6 +25,7 @@ "disableAnchorTracking": { "description": "Whether to disable the popup from tracking layout shifts of its positioning anchor." }, + "elevation": { "description": "The elevation of the menu surface." }, "finalFocus": { "description": "Determines the element to focus when the menu is closed." }, "keepMounted": { "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." diff --git a/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json b/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json index 24661184a10744..e8c18a9115d7cd 100644 --- a/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json +++ b/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json @@ -25,6 +25,7 @@ "disableAnchorTracking": { "description": "Whether to disable the popup from tracking layout shifts of its positioning anchor." }, + "elevation": { "description": "The elevation of the menu surface." }, "finalFocus": { "description": "Determines the element to focus when the menu is closed." }, "keepMounted": { "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx index 3dac7e6dd93801..319651a86943d9 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx +++ b/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx @@ -3,6 +3,7 @@ import { expect } from 'chai'; import { spy } from 'sinon'; import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; import { listClasses } from '@mui/material/List'; +import { paperClasses } from '@mui/material/Paper'; import Tooltip from '@mui/material/Tooltip'; import MenuPreview, { MenuPreviewCheckboxItem, @@ -432,6 +433,36 @@ describe('', () => { expect(list).not.to.have.attribute('sx'); }); + it('defaults the popup surface elevation to 8', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByTestId('paper')).to.have.class(paperClasses.elevation8); + }); + + it('forwards a custom elevation to the popup surface', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByTestId('paper')).to.have.class(paperClasses.elevation4); + }); + it('supports controlled open state and Base UI cancellation details', async () => { const handleOpenChange = spy((open: boolean, eventDetails: any) => { expect(open).to.equal(true); diff --git a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx index e41db8fd1aa674..771522fa73cd3b 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx +++ b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx @@ -220,6 +220,11 @@ export interface MenuPreviewPopupPublicProps { * Determines the element to focus when the menu is closed. */ finalFocus?: MenuPreviewPopupFinalFocus; + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation?: number | undefined; } export interface MenuPreviewPopupSharedProps @@ -269,6 +274,7 @@ export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBa collisionAvoidance, id, finalFocus, + elevation, style, ...other } = props; @@ -325,7 +331,7 @@ export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBa const paperSlotProps = getSlotProps( PaperSlot, { - elevation: 8, + elevation: elevation ?? 8, ...resolvedPaperProps, className: clsx(classes?.paper, resolvedPaperProps?.className), sx: mergeSx(sx, resolvedPaperProps?.sx), diff --git a/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx b/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx index cafc2212940014..3eb6c741f8666c 100644 --- a/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx +++ b/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx @@ -118,6 +118,11 @@ export interface MenuPreviewPopupProps extends Omit< * Determines the element to focus when the menu is closed. */ finalFocus?: MenuPreviewPopupPublicProps['finalFocus']; + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation?: MenuPreviewPopupPublicProps['elevation']; /** * Override or extend the styles applied to the component. */ @@ -317,6 +322,11 @@ MenuPreviewPopup.propTypes /* remove-proptypes */ = { * @default false */ disableAnchorTracking: PropTypes.bool, + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation: PropTypes.number, /** * Determines the element to focus when the menu is closed. */ diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx b/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx index 752bd5347ad8d7..bb171f81d1afce 100644 --- a/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx +++ b/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx @@ -118,6 +118,11 @@ export interface MenuPreviewSubmenuPopupProps extends Omit< * Determines the element to focus when the menu is closed. */ finalFocus?: MenuPreviewPopupPublicProps['finalFocus']; + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation?: MenuPreviewPopupPublicProps['elevation']; /** * Override or extend the styles applied to the component. */ @@ -317,6 +322,11 @@ MenuPreviewSubmenuPopup.propTypes /* remove-proptypes */ = { * @default false */ disableAnchorTracking: PropTypes.bool, + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation: PropTypes.number, /** * Determines the element to focus when the menu is closed. */ From 6bdd72c0a69178982e3f65a119ba02af3e569ae7 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 29 Jun 2026 19:05:34 +0800 Subject: [PATCH 06/62] Add context menu recipe --- docs/pages/experiments/menu-preview.tsx | 91 +++++++++++++++++++ .../src/MenuPreview/MenuPreview.tsx | 4 +- .../src/MenuPreview/menuPreviewItemShared.tsx | 10 +- .../MenuPreview/menuPreviewPopupShared.tsx | 54 +++++------ .../MenuPreview/menuPreviewSharedStyles.ts | 2 +- .../src/MenuPreview/menuPreviewUtils.ts | 2 +- .../src/MenuPreviewPopup/MenuPreviewPopup.tsx | 36 ++++---- .../MenuPreviewSubmenuPopup.tsx | 36 ++++---- .../MenuPreviewSubmenuRoot.tsx | 4 +- 9 files changed, 165 insertions(+), 74 deletions(-) diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx index 57fdde8267c223..9f08660603a43d 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu-preview.tsx @@ -41,6 +41,19 @@ const defaultSettings: MenuSettings = { submenusOpenOnHover: false, }; +function createVirtualAnchor(mouseX: number, mouseY: number) { + return { + getBoundingClientRect() { + return DOMRect.fromRect({ + x: mouseX, + y: mouseY, + width: 0, + height: 0, + }); + }, + }; +} + interface PreviewCardItem { id: string; label: string; @@ -519,6 +532,79 @@ function MenuPreviewWithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHo ); } +function MenuPreviewContextMenuRecipe() { + const [anchor, setAnchor] = React.useState | null>(null); + const open = anchor !== null; + + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + + setAnchor( + anchor === null + ? createVirtualAnchor(event.clientX + 2, event.clientY - 6) + : // Keep the old Material recipe behavior: a repeated contextmenu event while + // open closes the menu instead of relocating it through the backdrop. + null, + ); + + // Preserve selected text after opening the context menu in Safari and Firefox. + const selection = document.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + + setTimeout(() => { + selection.addRange(range); + }); + } + }; + + const handleClose = () => { + setAnchor(null); + }; + + const handleOpenChange: React.ComponentProps['onOpenChange'] = ( + nextOpen, + eventDetails, + ) => { + if (nextOpen) { + return; + } + + if ( + eventDetails.reason === 'item-press' || + eventDetails.reason === 'outside-press' || + eventDetails.reason === 'escape-key' + ) { + handleClose(); + return; + } + + eventDetails.cancel(); + }; + + return ( +
+ + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ipsum purus, bibendum sit + amet vulputate eget, porta semper ligula. Donec bibendum vulputate erat, ac fringilla mi + finibus nec. Donec ac dolor sed dolor porttitor blandit vel vel purus. Fusce vel malesuada + ligula. Nam quis vehicula ante, eu finibus est. Proin ullamcorper fermentum orci, quis + finibus massa. Nunc lobortis, massa ut rutrum ultrices, metus metus finibus ex, sit amet + facilisis neque enim sed neque. Quisque accumsan metus vel maximus consequat. Suspendisse + lacinia tellus a libero volutpat maximus. + + + + Copy + Print + Highlight + Email + + +
+ ); +} + export default function MenuPreviewExperiment() { const [settings, setSettings] = React.useState(defaultSettings); @@ -582,6 +668,11 @@ export default function MenuPreviewExperiment() {

Material UI Popover used as a PreviewCard-style menu item help card.

+
+ +

Right-click the text to open a cursor-positioned MenuPreview popup.

+ +
Base UI Menu API diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.tsx index 6ea6ec15b4d7fd..b47beb7f169753 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.tsx +++ b/packages/mui-material/src/MenuPreview/MenuPreview.tsx @@ -23,11 +23,11 @@ export interface MenuPreviewProps { /** * Event handler called when the menu is opened or closed. */ - onOpenChange?: BaseMenu.Root.Props['onOpenChange']; + onOpenChange?: (BaseMenu.Root.Props['onOpenChange']) | undefined; /** * Event handler called after any animations complete when the menu is opened or closed. */ - onOpenChangeComplete?: BaseMenu.Root.Props['onOpenChangeComplete']; + onOpenChangeComplete?: (BaseMenu.Root.Props['onOpenChangeComplete']) | undefined; /** * Determines if the menu enters a modal state when open. * @default true diff --git a/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx b/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx index 904641b370bfbb..62f72cde410274 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx +++ b/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx @@ -155,8 +155,8 @@ export interface MenuPreviewSubmenuTriggerBaseProps { } export interface MenuPreviewBaseItemState { - disabled?: boolean; - highlighted?: boolean; + disabled?: (boolean) | undefined; + highlighted?: (boolean) | undefined; } export function menuPreviewItemOverridesResolver( @@ -191,9 +191,9 @@ export function getMenuPreviewItemOwnerState( export function useMenuPreviewItemUtilityClasses( ownerState: MenuPreviewItemOwnerState & { - classes?: Partial; - checked?: boolean; - open?: boolean; + classes?: (Partial) | undefined; + checked?: (boolean) | undefined; + open?: (boolean) | undefined; }, getUtilityClass: (slot: string) => string, ) { diff --git a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx index 771522fa73cd3b..7848b0cfe2a790 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx +++ b/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx @@ -12,7 +12,7 @@ import { resolveSlotProps, SlotProps } from './menuPreviewUtils'; type ExternalSlotProps = Omit, 'className' | 'render' | 'style'> & { className?: string | undefined; - render?: never; + render?: (never) | undefined; style?: React.CSSProperties | undefined; } & Record; @@ -88,35 +88,35 @@ export interface MenuPreviewPopupSharedSlots { * The component used for the portal. * @default BaseMenu.Portal */ - portal?: React.ElementType; + portal?: (React.ElementType) | undefined; /** * The component used for the positioner. * @default BaseMenu.Positioner */ - positioner?: React.ElementType; + positioner?: (React.ElementType) | undefined; /** * The component rendered by the Base UI popup. * @default 'div' */ - popup?: React.ElementType; + popup?: (React.ElementType) | undefined; /** * The component used for the Material surface. * @default Paper */ - paper?: React.ElementType; + paper?: (React.ElementType) | undefined; /** * The component used for the presentational list wrapper. * @default List */ - list?: React.ElementType; + list?: (React.ElementType) | undefined; } export interface MenuPreviewPopupSharedSlotProps { - portal?: SlotProps, OwnerState>; - positioner?: SlotProps, OwnerState>; - popup?: SlotProps, OwnerState>; - paper?: SlotProps, OwnerState>; - list?: SlotProps, OwnerState>; + portal?: (SlotProps, OwnerState>) | undefined; + positioner?: (SlotProps, OwnerState>) | undefined; + popup?: (SlotProps, OwnerState>) | undefined; + paper?: (SlotProps, OwnerState>) | undefined; + list?: (SlotProps, OwnerState>) | undefined; } type MenuPreviewPositionerProps = BaseMenu.Positioner.Props; @@ -152,12 +152,12 @@ export interface MenuPreviewPopupPublicProps { * * By default, the popup is positioned against the trigger. */ - anchor?: MenuPreviewPopupAnchor; + anchor?: (MenuPreviewPopupAnchor) | undefined; /** * Determines which CSS `position` property to use. * @default 'absolute' */ - positionMethod?: MenuPreviewPopupPositionMethod; + positionMethod?: (MenuPreviewPopupPositionMethod) | undefined; /** * Which side of the anchor element to align the popup against. * @default 'bottom' @@ -182,44 +182,44 @@ export interface MenuPreviewPopupPublicProps { * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' */ - collisionBoundary?: MenuPreviewPopupCollisionBoundary; + collisionBoundary?: (MenuPreviewPopupCollisionBoundary) | undefined; /** * Additional space to maintain from the edge of the collision boundary. * @default 5 */ - collisionPadding?: MenuPreviewPopupCollisionPadding; + collisionPadding?: (MenuPreviewPopupCollisionPadding) | undefined; /** * Minimum distance to maintain between the arrow and the edges of the popup. * @default 5 */ - arrowPadding?: MenuPreviewPositionerProps['arrowPadding']; + arrowPadding?: (MenuPreviewPositionerProps['arrowPadding']) | undefined; /** * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. * @default false */ - sticky?: MenuPreviewPositionerProps['sticky']; + sticky?: (MenuPreviewPositionerProps['sticky']) | undefined; /** * Whether to disable the popup from tracking layout shifts of its positioning anchor. * @default false */ - disableAnchorTracking?: MenuPreviewPositionerProps['disableAnchorTracking']; + disableAnchorTracking?: (MenuPreviewPositionerProps['disableAnchorTracking']) | undefined; /** * Determines how to handle collisions when positioning the popup. */ - collisionAvoidance?: MenuPreviewPopupCollisionAvoidance; + collisionAvoidance?: (MenuPreviewPopupCollisionAvoidance) | undefined; /** * The container element to portal the popup into. */ - container?: MenuPreviewPopupContainer; + container?: (MenuPreviewPopupContainer) | undefined; /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false */ - keepMounted?: MenuPreviewPortalProps['keepMounted']; + keepMounted?: (MenuPreviewPortalProps['keepMounted']) | undefined; /** * Determines the element to focus when the menu is closed. */ - finalFocus?: MenuPreviewPopupFinalFocus; + finalFocus?: (MenuPreviewPopupFinalFocus) | undefined; /** * The elevation of the menu surface. * @default 8 @@ -231,17 +231,17 @@ export interface MenuPreviewPopupSharedProps extends Omit, MenuPreviewPopupPublicProps { - classes?: Partial>; + classes?: (Partial>) | undefined; ownerState: OwnerState; - slots?: MenuPreviewPopupSharedSlots; - slotProps?: MenuPreviewPopupSharedSlotProps; + slots?: (MenuPreviewPopupSharedSlots) | undefined; + slotProps?: (MenuPreviewPopupSharedSlotProps) | undefined; defaultSlots: { popup: React.ElementType; paper: React.ElementType; list: React.ElementType; }; - defaultPositionerProps?: Partial; - sx?: SxProps; + defaultPositionerProps?: (Partial) | undefined; + sx?: (SxProps) | undefined; } export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBase( diff --git a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts b/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts index 5621e902dddd89..bc63a096c86d88 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts +++ b/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts @@ -11,7 +11,7 @@ export interface SharedMenuPreviewItemClasses { divider: string; gutters: string; selected: string; - open?: string; + open?: (string) | undefined; } export function getMenuPreviewItemStyles( diff --git a/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts b/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts index bfb185ae77ba5e..6ba6b353ea620e 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts +++ b/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts @@ -34,7 +34,7 @@ export function resolveSlotProps( } export interface MenuPreviewRootSlots { - root?: React.ElementType; + root?: (React.ElementType) | undefined; } export interface MenuPreviewRootSlotProps { diff --git a/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx b/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx index 3eb6c741f8666c..282103a24e3bc2 100644 --- a/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx +++ b/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx @@ -40,89 +40,89 @@ export interface MenuPreviewPopupProps extends Omit< /** * CSS class applied to the Base UI popup element. */ - className?: MenuPreviewPopupPublicProps['className']; + className?: (MenuPreviewPopupPublicProps['className']) | undefined; /** * Styles applied to the Base UI popup element. */ - style?: MenuPreviewPopupPublicProps['style']; + style?: (MenuPreviewPopupPublicProps['style']) | undefined; /** * An element to position the popup against. * * By default, the popup is positioned against the trigger. */ - anchor?: MenuPreviewPopupPublicProps['anchor']; + anchor?: (MenuPreviewPopupPublicProps['anchor']) | undefined; /** * Determines which CSS `position` property to use. * @default 'absolute' */ - positionMethod?: MenuPreviewPopupPublicProps['positionMethod']; + positionMethod?: (MenuPreviewPopupPublicProps['positionMethod']) | undefined; /** * Which side of the anchor element to align the popup against. * @default 'bottom' */ - side?: MenuPreviewPopupPublicProps['side']; + side?: (MenuPreviewPopupPublicProps['side']) | undefined; /** * Distance between the anchor and the popup in pixels. * @default 0 */ - sideOffset?: MenuPreviewPopupPublicProps['sideOffset']; + sideOffset?: (MenuPreviewPopupPublicProps['sideOffset']) | undefined; /** * How to align the popup relative to the specified side. * @default 'start' */ - align?: MenuPreviewPopupPublicProps['align']; + align?: (MenuPreviewPopupPublicProps['align']) | undefined; /** * Additional offset along the alignment axis in pixels. * @default 0 */ - alignOffset?: MenuPreviewPopupPublicProps['alignOffset']; + alignOffset?: (MenuPreviewPopupPublicProps['alignOffset']) | undefined; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' */ - collisionBoundary?: MenuPreviewPopupPublicProps['collisionBoundary']; + collisionBoundary?: (MenuPreviewPopupPublicProps['collisionBoundary']) | undefined; /** * Additional space to maintain from the edge of the collision boundary. * @default 5 */ - collisionPadding?: MenuPreviewPopupPublicProps['collisionPadding']; + collisionPadding?: (MenuPreviewPopupPublicProps['collisionPadding']) | undefined; /** * Minimum distance to maintain between the arrow and the edges of the popup. * @default 5 */ - arrowPadding?: MenuPreviewPopupPublicProps['arrowPadding']; + arrowPadding?: (MenuPreviewPopupPublicProps['arrowPadding']) | undefined; /** * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. * @default false */ - sticky?: MenuPreviewPopupPublicProps['sticky']; + sticky?: (MenuPreviewPopupPublicProps['sticky']) | undefined; /** * Whether to disable the popup from tracking layout shifts of its positioning anchor. * @default false */ - disableAnchorTracking?: MenuPreviewPopupPublicProps['disableAnchorTracking']; + disableAnchorTracking?: (MenuPreviewPopupPublicProps['disableAnchorTracking']) | undefined; /** * Determines how to handle collisions when positioning the popup. */ - collisionAvoidance?: MenuPreviewPopupPublicProps['collisionAvoidance']; + collisionAvoidance?: (MenuPreviewPopupPublicProps['collisionAvoidance']) | undefined; /** * The container element to portal the popup into. */ - container?: MenuPreviewPopupPublicProps['container']; + container?: (MenuPreviewPopupPublicProps['container']) | undefined; /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false */ - keepMounted?: MenuPreviewPopupPublicProps['keepMounted']; + keepMounted?: (MenuPreviewPopupPublicProps['keepMounted']) | undefined; /** * Determines the element to focus when the menu is closed. */ - finalFocus?: MenuPreviewPopupPublicProps['finalFocus']; + finalFocus?: (MenuPreviewPopupPublicProps['finalFocus']) | undefined; /** * The elevation of the menu surface. * @default 8 */ - elevation?: MenuPreviewPopupPublicProps['elevation']; + elevation?: (MenuPreviewPopupPublicProps['elevation']) | undefined; /** * Override or extend the styles applied to the component. */ diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx b/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx index bb171f81d1afce..4f35324e1646ca 100644 --- a/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx +++ b/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx @@ -40,89 +40,89 @@ export interface MenuPreviewSubmenuPopupProps extends Omit< /** * CSS class applied to the Base UI popup element. */ - className?: MenuPreviewPopupPublicProps['className']; + className?: (MenuPreviewPopupPublicProps['className']) | undefined; /** * Styles applied to the Base UI popup element. */ - style?: MenuPreviewPopupPublicProps['style']; + style?: (MenuPreviewPopupPublicProps['style']) | undefined; /** * An element to position the popup against. * * By default, the popup is positioned against the submenu trigger. */ - anchor?: MenuPreviewPopupPublicProps['anchor']; + anchor?: (MenuPreviewPopupPublicProps['anchor']) | undefined; /** * Determines which CSS `position` property to use. * @default 'absolute' */ - positionMethod?: MenuPreviewPopupPublicProps['positionMethod']; + positionMethod?: (MenuPreviewPopupPublicProps['positionMethod']) | undefined; /** * Which side of the anchor element to align the popup against. * @default 'inline-end' */ - side?: MenuPreviewPopupPublicProps['side']; + side?: (MenuPreviewPopupPublicProps['side']) | undefined; /** * Distance between the anchor and the popup in pixels. * @default 0 */ - sideOffset?: MenuPreviewPopupPublicProps['sideOffset']; + sideOffset?: (MenuPreviewPopupPublicProps['sideOffset']) | undefined; /** * How to align the popup relative to the specified side. * @default 'start' */ - align?: MenuPreviewPopupPublicProps['align']; + align?: (MenuPreviewPopupPublicProps['align']) | undefined; /** * Additional offset along the alignment axis in pixels. * @default 0 */ - alignOffset?: MenuPreviewPopupPublicProps['alignOffset']; + alignOffset?: (MenuPreviewPopupPublicProps['alignOffset']) | undefined; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' */ - collisionBoundary?: MenuPreviewPopupPublicProps['collisionBoundary']; + collisionBoundary?: (MenuPreviewPopupPublicProps['collisionBoundary']) | undefined; /** * Additional space to maintain from the edge of the collision boundary. * @default 5 */ - collisionPadding?: MenuPreviewPopupPublicProps['collisionPadding']; + collisionPadding?: (MenuPreviewPopupPublicProps['collisionPadding']) | undefined; /** * Minimum distance to maintain between the arrow and the edges of the popup. * @default 5 */ - arrowPadding?: MenuPreviewPopupPublicProps['arrowPadding']; + arrowPadding?: (MenuPreviewPopupPublicProps['arrowPadding']) | undefined; /** * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. * @default false */ - sticky?: MenuPreviewPopupPublicProps['sticky']; + sticky?: (MenuPreviewPopupPublicProps['sticky']) | undefined; /** * Whether to disable the popup from tracking layout shifts of its positioning anchor. * @default false */ - disableAnchorTracking?: MenuPreviewPopupPublicProps['disableAnchorTracking']; + disableAnchorTracking?: (MenuPreviewPopupPublicProps['disableAnchorTracking']) | undefined; /** * Determines how to handle collisions when positioning the popup. */ - collisionAvoidance?: MenuPreviewPopupPublicProps['collisionAvoidance']; + collisionAvoidance?: (MenuPreviewPopupPublicProps['collisionAvoidance']) | undefined; /** * The container element to portal the popup into. */ - container?: MenuPreviewPopupPublicProps['container']; + container?: (MenuPreviewPopupPublicProps['container']) | undefined; /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false */ - keepMounted?: MenuPreviewPopupPublicProps['keepMounted']; + keepMounted?: (MenuPreviewPopupPublicProps['keepMounted']) | undefined; /** * Determines the element to focus when the menu is closed. */ - finalFocus?: MenuPreviewPopupPublicProps['finalFocus']; + finalFocus?: (MenuPreviewPopupPublicProps['finalFocus']) | undefined; /** * The elevation of the menu surface. * @default 8 */ - elevation?: MenuPreviewPopupPublicProps['elevation']; + elevation?: (MenuPreviewPopupPublicProps['elevation']) | undefined; /** * Override or extend the styles applied to the component. */ diff --git a/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx b/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx index 11ee567dbc9571..cdd393f1b8ef46 100644 --- a/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx +++ b/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx @@ -23,11 +23,11 @@ export interface MenuPreviewSubmenuRootProps { /** * Event handler called when the submenu is opened or closed. */ - onOpenChange?: BaseMenu.SubmenuRoot.Props['onOpenChange']; + onOpenChange?: (BaseMenu.SubmenuRoot.Props['onOpenChange']) | undefined; /** * Event handler called after any animations complete when the submenu is opened or closed. */ - onOpenChangeComplete?: BaseMenu.SubmenuRoot.Props['onOpenChangeComplete']; + onOpenChangeComplete?: (BaseMenu.SubmenuRoot.Props['onOpenChangeComplete']) | undefined; /** * Whether the component should ignore user interaction. * @default false From 62aa760c54a76610d4f04651f28be047f8983f94 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Mon, 29 Jun 2026 19:46:21 +0800 Subject: [PATCH 07/62] fix ci --- .../menu-preview-checkbox-item-indicator.json | 7 +- .../api/menu-preview-checkbox-item.json | 9 ++- .../api/menu-preview-group-label.json | 9 ++- .../material-ui/api/menu-preview-group.json | 9 ++- .../material-ui/api/menu-preview-item.json | 9 ++- .../api/menu-preview-link-item.json | 9 ++- .../api/menu-preview-radio-group.json | 9 ++- .../menu-preview-radio-item-indicator.json | 7 +- .../api/menu-preview-radio-item.json | 9 ++- .../api/menu-preview-separator.json | 9 ++- .../api/menu-preview-submenu-trigger.json | 9 ++- .../material-ui/api/menu-preview-trigger.json | 9 ++- .../menu-preview-checkbox-item-indicator.json | 2 +- .../menu-preview-checkbox-item.json | 2 +- .../menu-preview-group-label.json | 2 +- .../menu-preview-group.json | 2 +- .../menu-preview-item/menu-preview-item.json | 2 +- .../menu-preview-link-item.json | 2 +- .../menu-preview-radio-group.json | 2 +- .../menu-preview-radio-item-indicator.json | 2 +- .../menu-preview-radio-item.json | 2 +- .../menu-preview-separator.json | 2 +- .../menu-preview-submenu-trigger.json | 2 +- .../menu-preview-trigger.json | 2 +- .../src/MenuPreview/MenuPreview.spec.tsx | 2 +- .../src/MenuPreview/MenuPreview.test.tsx | 2 +- .../src/MenuPreview/MenuPreview.tsx | 4 +- .../mui-material/src/MenuPreview/index.d.ts | 2 - .../mui-material/src/MenuPreview/index.js | 2 - .../src/MenuPreview/menuPreviewItemShared.tsx | 10 +-- .../MenuPreview/menuPreviewPopupShared.tsx | 54 +++++++-------- .../MenuPreview/menuPreviewSharedStyles.ts | 2 +- .../src/MenuPreview/menuPreviewUtils.ts | 2 +- .../MenuPreviewCheckboxItem.tsx | 9 ++- .../MenuPreviewCheckboxItemIndicator.tsx | 9 ++- .../src/MenuPreviewGroup/MenuPreviewGroup.tsx | 9 ++- .../MenuPreviewGroupLabel.tsx | 9 ++- .../src/MenuPreviewItem/MenuPreviewItem.tsx | 9 ++- .../MenuPreviewLinkItem.tsx | 9 ++- .../src/MenuPreviewPopup/MenuPreviewPopup.tsx | 65 +++++++++++++------ .../MenuPreviewRadioGroup.tsx | 9 ++- .../MenuPreviewRadioItem.tsx | 9 ++- .../MenuPreviewRadioItemIndicator.tsx | 9 ++- .../MenuPreviewSeparator.tsx | 9 ++- .../MenuPreviewSubmenuPopup.tsx | 65 +++++++++++++------ .../MenuPreviewSubmenuRoot.tsx | 4 +- .../MenuPreviewSubmenuTrigger.tsx | 9 ++- .../MenuPreviewTrigger/MenuPreviewTrigger.tsx | 9 ++- 48 files changed, 318 insertions(+), 132 deletions(-) diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json index 49c213f68f5fef..578f4099507f42 100644 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json @@ -22,7 +22,12 @@ "import MenuPreviewCheckboxItemIndicator from '@mui/material/MenuPreviewCheckboxItemIndicator';" ], "slots": [ - { "name": "root", "description": "", "class": "MuiMenuPreviewCheckboxItemIndicator-root" } + { + "name": "root", + "description": "The component that renders the root.", + "default": "'span'", + "class": "MuiMenuPreviewCheckboxItemIndicator-root" + } ], "classes": [ { diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item.json b/docs/pages/material-ui/api/menu-preview-checkbox-item.json index e1ee7b88166402..c111984ae17e80 100644 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item.json +++ b/docs/pages/material-ui/api/menu-preview-checkbox-item.json @@ -30,7 +30,14 @@ }, "name": "MenuPreviewCheckboxItem", "imports": ["import MenuPreviewCheckboxItem from '@mui/material/MenuPreviewCheckboxItem';"], - "slots": [{ "name": "root", "description": "", "class": null }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": null + } + ], "classes": [], "muiName": "MuiMenuPreviewCheckboxItem", "filename": "/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-group-label.json b/docs/pages/material-ui/api/menu-preview-group-label.json index 15ac51dd170114..05e7ee00cc3678 100644 --- a/docs/pages/material-ui/api/menu-preview-group-label.json +++ b/docs/pages/material-ui/api/menu-preview-group-label.json @@ -18,7 +18,14 @@ }, "name": "MenuPreviewGroupLabel", "imports": ["import MenuPreviewGroupLabel from '@mui/material/MenuPreviewGroupLabel';"], - "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewGroupLabel-root" }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "ListSubheader", + "class": "MuiMenuPreviewGroupLabel-root" + } + ], "classes": [], "muiName": "MuiMenuPreviewGroupLabel", "filename": "/packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-group.json b/docs/pages/material-ui/api/menu-preview-group.json index 0bbc5b7c39d31d..75a9f51a590117 100644 --- a/docs/pages/material-ui/api/menu-preview-group.json +++ b/docs/pages/material-ui/api/menu-preview-group.json @@ -19,7 +19,14 @@ }, "name": "MenuPreviewGroup", "imports": ["import MenuPreviewGroup from '@mui/material/MenuPreviewGroup';"], - "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewGroup-root" }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": "MuiMenuPreviewGroup-root" + } + ], "classes": [], "muiName": "MuiMenuPreviewGroup", "filename": "/packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-item.json b/docs/pages/material-ui/api/menu-preview-item.json index 80b360babe0bcc..85f2432722027e 100644 --- a/docs/pages/material-ui/api/menu-preview-item.json +++ b/docs/pages/material-ui/api/menu-preview-item.json @@ -27,7 +27,14 @@ }, "name": "MenuPreviewItem", "imports": ["import MenuPreviewItem from '@mui/material/MenuPreviewItem';"], - "slots": [{ "name": "root", "description": "", "class": null }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": null + } + ], "classes": [], "muiName": "MuiMenuPreviewItem", "filename": "/packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-link-item.json b/docs/pages/material-ui/api/menu-preview-link-item.json index c1a50c2e21e20b..2f2aa34b194857 100644 --- a/docs/pages/material-ui/api/menu-preview-link-item.json +++ b/docs/pages/material-ui/api/menu-preview-link-item.json @@ -26,7 +26,14 @@ }, "name": "MenuPreviewLinkItem", "imports": ["import MenuPreviewLinkItem from '@mui/material/MenuPreviewLinkItem';"], - "slots": [{ "name": "root", "description": "", "class": null }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'a'", + "class": null + } + ], "classes": [], "muiName": "MuiMenuPreviewLinkItem", "filename": "/packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-radio-group.json b/docs/pages/material-ui/api/menu-preview-radio-group.json index c972d5644f3576..0c709d5d214111 100644 --- a/docs/pages/material-ui/api/menu-preview-radio-group.json +++ b/docs/pages/material-ui/api/menu-preview-radio-group.json @@ -23,7 +23,14 @@ }, "name": "MenuPreviewRadioGroup", "imports": ["import MenuPreviewRadioGroup from '@mui/material/MenuPreviewRadioGroup';"], - "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewRadioGroup-root" }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": "MuiMenuPreviewRadioGroup-root" + } + ], "classes": [ { "key": "disabled", diff --git a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json index cc0278e7e2ba7d..0ba43b87d70be3 100644 --- a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json +++ b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json @@ -22,7 +22,12 @@ "import MenuPreviewRadioItemIndicator from '@mui/material/MenuPreviewRadioItemIndicator';" ], "slots": [ - { "name": "root", "description": "", "class": "MuiMenuPreviewRadioItemIndicator-root" } + { + "name": "root", + "description": "The component that renders the root.", + "default": "'span'", + "class": "MuiMenuPreviewRadioItemIndicator-root" + } ], "classes": [ { diff --git a/docs/pages/material-ui/api/menu-preview-radio-item.json b/docs/pages/material-ui/api/menu-preview-radio-item.json index bb264de79c2630..f5e4735f558304 100644 --- a/docs/pages/material-ui/api/menu-preview-radio-item.json +++ b/docs/pages/material-ui/api/menu-preview-radio-item.json @@ -28,7 +28,14 @@ }, "name": "MenuPreviewRadioItem", "imports": ["import MenuPreviewRadioItem from '@mui/material/MenuPreviewRadioItem';"], - "slots": [{ "name": "root", "description": "", "class": null }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": null + } + ], "classes": [], "muiName": "MuiMenuPreviewRadioItem", "filename": "/packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-separator.json b/docs/pages/material-ui/api/menu-preview-separator.json index f601b79a6a6eba..9c121e406aa332 100644 --- a/docs/pages/material-ui/api/menu-preview-separator.json +++ b/docs/pages/material-ui/api/menu-preview-separator.json @@ -22,7 +22,14 @@ }, "name": "MenuPreviewSeparator", "imports": ["import MenuPreviewSeparator from '@mui/material/MenuPreviewSeparator';"], - "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewSeparator-root" }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "Divider", + "class": "MuiMenuPreviewSeparator-root" + } + ], "classes": [], "muiName": "MuiMenuPreviewSeparator", "filename": "/packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-submenu-trigger.json b/docs/pages/material-ui/api/menu-preview-submenu-trigger.json index 387fe73eb537b9..1de0b51f8fe169 100644 --- a/docs/pages/material-ui/api/menu-preview-submenu-trigger.json +++ b/docs/pages/material-ui/api/menu-preview-submenu-trigger.json @@ -29,7 +29,14 @@ }, "name": "MenuPreviewSubmenuTrigger", "imports": ["import MenuPreviewSubmenuTrigger from '@mui/material/MenuPreviewSubmenuTrigger';"], - "slots": [{ "name": "root", "description": "", "class": null }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "'div'", + "class": null + } + ], "classes": [], "muiName": "MuiMenuPreviewSubmenuTrigger", "filename": "/packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx", diff --git a/docs/pages/material-ui/api/menu-preview-trigger.json b/docs/pages/material-ui/api/menu-preview-trigger.json index 96aca0e828262b..385c860b98acb7 100644 --- a/docs/pages/material-ui/api/menu-preview-trigger.json +++ b/docs/pages/material-ui/api/menu-preview-trigger.json @@ -23,7 +23,14 @@ }, "name": "MenuPreviewTrigger", "imports": ["import MenuPreviewTrigger from '@mui/material/MenuPreviewTrigger';"], - "slots": [{ "name": "root", "description": "", "class": "MuiMenuPreviewTrigger-root" }], + "slots": [ + { + "name": "root", + "description": "The component that renders the root.", + "default": "Button", + "class": "MuiMenuPreviewTrigger-root" + } + ], "classes": [ { "key": "disabled", diff --git a/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json b/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json index 2849161fdc3460..5d76236eb0765d 100644 --- a/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json +++ b/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json @@ -31,5 +31,5 @@ "conditions": "highlighted" } }, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json b/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json index fc921efed518e8..d166f10000526b 100644 --- a/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json +++ b/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json @@ -40,5 +40,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json b/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json index 8c92b392b4a3d8..84f2fc365ca341 100644 --- a/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json +++ b/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json @@ -12,5 +12,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-group/menu-preview-group.json b/docs/translations/api-docs/menu-preview-group/menu-preview-group.json index 8743d2e26e1f58..5b609b8b501add 100644 --- a/docs/translations/api-docs/menu-preview-group/menu-preview-group.json +++ b/docs/translations/api-docs/menu-preview-group/menu-preview-group.json @@ -13,5 +13,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-item/menu-preview-item.json b/docs/translations/api-docs/menu-preview-item/menu-preview-item.json index 552a6e719d95ae..f7a24beb397a62 100644 --- a/docs/translations/api-docs/menu-preview-item/menu-preview-item.json +++ b/docs/translations/api-docs/menu-preview-item/menu-preview-item.json @@ -31,5 +31,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json b/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json index 79bd5fbfce859a..56fc025de29c66 100644 --- a/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json +++ b/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json @@ -28,5 +28,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json b/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json index 8d6137b9a1f39a..2660a224a7df17 100644 --- a/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json +++ b/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json @@ -27,5 +27,5 @@ "conditions": "disabled={true}" } }, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json b/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json index faa8d5c3697420..df183e63479557 100644 --- a/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json +++ b/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json @@ -31,5 +31,5 @@ "conditions": "highlighted" } }, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json b/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json index 03866a7e3ba528..b3eb6029c79fd2 100644 --- a/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json +++ b/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json @@ -32,5 +32,5 @@ "value": { "description": "Value of the radio item." } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json b/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json index 39ea1cd2fc4e6b..87b20cb20534de 100644 --- a/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json +++ b/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json @@ -13,5 +13,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json b/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json index e577f74db637bf..63b8e9926e33af 100644 --- a/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json +++ b/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json @@ -39,5 +39,5 @@ } }, "classDescriptions": {}, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json b/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json index 1b289124ab482c..de06b7a775229e 100644 --- a/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json +++ b/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json @@ -36,5 +36,5 @@ "conditions": "the menu is open" } }, - "slotDescriptions": { "root": "" } + "slotDescriptions": { "root": "The component that renders the root." } } diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx b/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx index 70b8079302e562..6ca3ce1f3d2a11 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx +++ b/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx @@ -182,7 +182,7 @@ createTheme({ } + render={ + setClassicAnchorEl(null)} + elevation={settings.elevation} + > + {parityItems.map((item) => ( + setClassicAnchorEl(null)} + > + {item.label} + + ))} + +
+ + }> + Successor + + + {parityItems.map((item) => ( + + {item.label} + + ))} + + + + ); +} + +function ControlledAnchorDemo() { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + + const handleOpenChange: MenuProps['onOpenChange'] = (nextOpen) => { + if (!nextOpen) { + setAnchorEl(null); + } + }; + + return ( +
+ + + + setAnchorEl(null)}>Profile + setAnchorEl(null)}>My account + setAnchorEl(null)}>Logout + + +
+ ); +} + +const typeaheadEntries = [ + 'Argentina', + 'Australia', + 'Austria', + 'Belgium', + 'Brazil', + 'Canada', + 'Chile', + 'Colombia', + 'Czechia', + 'Denmark', + 'Estonia', + 'Finland', + 'France', + 'Germany', + 'Greece', + 'Hungary', + 'Iceland', + 'India', + 'Ireland', + 'Italy', + 'Japan', + 'Lithuania', + 'Mexico', + 'Netherlands', + 'New Zealand', + 'Norway', + 'Poland', + 'Portugal', + 'Spain', + 'Sweden', + 'Switzerland', + 'United Kingdom', +]; + +function TypeaheadScrollDemo() { + return ( + + }> + Country + + + {typeaheadEntries.map((entry) => ( + + {entry} + + ))} + + + ); +} + +function SettingsPanel({ + settings, + onChange, +}: { + settings: PlaygroundSettings; + onChange: React.Dispatch>; +}) { + const setSetting = ( + key: Key, + value: PlaygroundSettings[Key], + ) => { + onChange((currentSettings) => ({ ...currentSettings, [key]: value })); + }; + + const renderCheckbox = (key: keyof PlaygroundSettings, label: string) => ( + + ); + + const renderNumber = (key: keyof PlaygroundSettings, label: string, step = 50) => ( + + ); + + return ( + + Playground knobs +
+ Root behavior + {renderCheckbox('modal', 'modal')} + {renderCheckbox('loopFocus', 'loopFocus')} + {renderCheckbox('highlightItemOnHover', 'highlightItemOnHover')} +
+
+ Submenus + {renderCheckbox('submenusOpenOnHover', 'openOnHover')} + {renderNumber('submenuDelay', 'delay (ms)')} + {renderNumber('submenuCloseDelay', 'closeDelay (ms)')} + {renderCheckbox('closeParentOnEsc', 'closeParentOnEsc')} +
+
+ Positioning + + + {renderNumber('sideOffset', 'sideOffset', 4)} + {renderNumber('alignOffset', 'alignOffset', 4)} + {renderCheckbox('keepMounted', 'keepMounted')} +
+
+ Appearance (RFC open questions) + + + {renderCheckbox('dense', 'dense items')} + {renderCheckbox('dividers', 'item dividers')} + {renderCheckbox('rtl', 'RTL direction')} +
+
+ ); +} + +export default function MenuRfcExperiment() { + const [settings, setSettings] = React.useState(defaultSettings); + const [log, setLog] = React.useState([]); + + const pushLog = React.useCallback((entry: string) => { + setLog((currentLog) => [...currentLog.slice(-11), entry]); + }, []); + + const playgroundTheme = settings.rtl ? rtlTheme : theme; + + return ( + + + + + + + Menu RFC playground + + + Companion experiment for the Menu successor RFC. Every knob maps to a prop or an RFC + open question. See also the{' '} + MenuPreview experiment for + Tooltip, PreviewCard, and ContextMenu recipes. + + + + +
+

Kitchen sink

+

+ Nested submenus (three levels), groups with labels, checkbox and radio items, a + disabled item, a visual-only selected item, and a link item. All knobs apply. +

+ + + + + + + + + {log.length === 0 + ? 'Event log: interact with the menu to see onOpenChange reasons.' + : log.join('\n')} + + +
+ +
+

Classic vs successor

+

+ The same item set rendered by the classic Menu and the successor, for visual parity + checks (dense, dividers, selected, disabled, elevation knobs apply to both). Note the + classic top-level elevation prop vs{' '} + slotProps.paper.elevation on the successor (RFC open question). +

+ +
+ +
+

Classic-style controlled usage

+

+ No Trigger part: external anchor element plus controlled open /{' '} + onOpenChange, approximating the classic anchorEl pattern. +

+ +
+ +
+

Typeahead and scrolling

+

+ Open the menu and type to jump between items (for example type "sw"). The + popup constrains height via slotProps.paper. +

+ +
+ + Base UI Menu API +
+
+
+ ); +} From 1650ef471c19b4db2bc1fd192e55ba4af51a9be1 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 11:00:54 +0300 Subject: [PATCH 09/62] Remove docs/API tooling special-casing for the menu experiment Per the agreed standard, docs tooling is a fixed constraint: drop the projectSettings/getComponentImports carve-out, the apiDocs.d.ts entry point, the generated API reference pages and translations, and restore the menus.md frontmatter. The experiment is exercised via the non-public /experiments pages instead. Co-Authored-By: Claude Fable 5 --- docs/data/material/components/menus/menus.md | 2 +- .../menu-preview-checkbox-item-indicator.json | 56 ------- .../api/menu-preview-checkbox-item.json | 46 ------ .../api/menu-preview-group-label.json | 34 ----- .../material-ui/api/menu-preview-group.json | 35 ----- .../material-ui/api/menu-preview-item.json | 43 ------ .../api/menu-preview-link-item.json | 42 ------ .../material-ui/api/menu-preview-popup.json | 142 ------------------ .../api/menu-preview-radio-group.json | 46 ------ .../menu-preview-radio-item-indicator.json | 56 ------- .../api/menu-preview-radio-item.json | 44 ------ .../api/menu-preview-separator.json | 38 ----- .../api/menu-preview-submenu-popup.json | 142 ------------------ .../api/menu-preview-submenu-root.json | 24 --- .../api/menu-preview-submenu-trigger.json | 45 ------ .../material-ui/api/menu-preview-trigger.json | 52 ------- docs/pages/material-ui/api/menu-preview.json | 27 ---- .../menu-preview-checkbox-item-indicator.json | 35 ----- .../menu-preview-checkbox-item.json | 44 ------ .../menu-preview-group-label.json | 16 -- .../menu-preview-group.json | 17 --- .../menu-preview-item/menu-preview-item.json | 35 ----- .../menu-preview-link-item.json | 32 ---- .../menu-preview-popup.json | 56 ------- .../menu-preview-radio-group.json | 31 ---- .../menu-preview-radio-item-indicator.json | 35 ----- .../menu-preview-radio-item.json | 36 ----- .../menu-preview-separator.json | 17 --- .../menu-preview-submenu-popup.json | 56 ------- .../menu-preview-submenu-root.json | 24 --- .../menu-preview-submenu-trigger.json | 43 ------ .../menu-preview-trigger.json | 40 ----- .../api-docs/menu-preview/menu-preview.json | 25 --- .../materialUi/projectSettings.ts | 40 +---- .../mui-material/src/MenuPreview/apiDocs.d.ts | 49 ------ 35 files changed, 2 insertions(+), 1503 deletions(-) delete mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json delete mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item.json delete mode 100644 docs/pages/material-ui/api/menu-preview-group-label.json delete mode 100644 docs/pages/material-ui/api/menu-preview-group.json delete mode 100644 docs/pages/material-ui/api/menu-preview-item.json delete mode 100644 docs/pages/material-ui/api/menu-preview-link-item.json delete mode 100644 docs/pages/material-ui/api/menu-preview-popup.json delete mode 100644 docs/pages/material-ui/api/menu-preview-radio-group.json delete mode 100644 docs/pages/material-ui/api/menu-preview-radio-item-indicator.json delete mode 100644 docs/pages/material-ui/api/menu-preview-radio-item.json delete mode 100644 docs/pages/material-ui/api/menu-preview-separator.json delete mode 100644 docs/pages/material-ui/api/menu-preview-submenu-popup.json delete mode 100644 docs/pages/material-ui/api/menu-preview-submenu-root.json delete mode 100644 docs/pages/material-ui/api/menu-preview-submenu-trigger.json delete mode 100644 docs/pages/material-ui/api/menu-preview-trigger.json delete mode 100644 docs/pages/material-ui/api/menu-preview.json delete mode 100644 docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json delete mode 100644 docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json delete mode 100644 docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json delete mode 100644 docs/translations/api-docs/menu-preview-group/menu-preview-group.json delete mode 100644 docs/translations/api-docs/menu-preview-item/menu-preview-item.json delete mode 100644 docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json delete mode 100644 docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json delete mode 100644 docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json delete mode 100644 docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json delete mode 100644 docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json delete mode 100644 docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json delete mode 100644 docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json delete mode 100644 docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json delete mode 100644 docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json delete mode 100644 docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json delete mode 100644 docs/translations/api-docs/menu-preview/menu-preview.json delete mode 100644 packages/mui-material/src/MenuPreview/apiDocs.d.ts diff --git a/docs/data/material/components/menus/menus.md b/docs/data/material/components/menus/menus.md index 93abb6bb4d2a9b..0ae32bfd270d10 100644 --- a/docs/data/material/components/menus/menus.md +++ b/docs/data/material/components/menus/menus.md @@ -1,7 +1,7 @@ --- productId: material-ui title: React Menu component -components: Menu, MenuItem, MenuList, MenuPreview, MenuPreviewTrigger, MenuPreviewPopup, MenuPreviewSubmenuPopup, MenuPreviewItem, MenuPreviewLinkItem, MenuPreviewCheckboxItem, MenuPreviewCheckboxItemIndicator, MenuPreviewRadioGroup, MenuPreviewRadioItem, MenuPreviewRadioItemIndicator, MenuPreviewGroup, MenuPreviewGroupLabel, MenuPreviewSeparator, MenuPreviewSubmenuRoot, MenuPreviewSubmenuTrigger, ClickAwayListener, Popover, Popper +components: Menu, MenuItem, MenuList, ClickAwayListener, Popover, Popper githubLabel: 'scope: menu' materialDesign: https://m2.material.io/components/menus waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/ diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json deleted file mode 100644 index 578f4099507f42..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "props": { - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "component": { "type": { "name": "elementType" } }, - "keepMounted": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewCheckboxItemIndicator", - "imports": [ - "import MenuPreviewCheckboxItemIndicator from '@mui/material/MenuPreviewCheckboxItemIndicator';" - ], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'span'", - "class": "MuiMenuPreviewCheckboxItemIndicator-root" - } - ], - "classes": [ - { - "key": "checked", - "className": "Mui-checked", - "description": "State class applied to the root element if `checked={true}`.", - "isGlobal": true - }, - { - "key": "disabled", - "className": "Mui-disabled", - "description": "State class applied to the root element if `disabled={true}`.", - "isGlobal": true - }, - { - "key": "highlighted", - "className": "MuiMenuPreviewCheckboxItemIndicator-highlighted", - "description": "State class applied to the root element if highlighted.", - "isGlobal": false - } - ], - "muiName": "MuiMenuPreviewCheckboxItemIndicator", - "filename": "/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item.json b/docs/pages/material-ui/api/menu-preview-checkbox-item.json deleted file mode 100644 index c111984ae17e80..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "props": { - "checked": { "type": { "name": "bool" } }, - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "closeOnClick": { "type": { "name": "bool" }, "default": "false" }, - "component": { "type": { "name": "elementType" } }, - "defaultChecked": { "type": { "name": "bool" }, "default": "false" }, - "dense": { "type": { "name": "bool" }, "default": "false" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "disableGutters": { "type": { "name": "bool" }, "default": "false" }, - "divider": { "type": { "name": "bool" }, "default": "false" }, - "label": { "type": { "name": "string" } }, - "nativeButton": { "type": { "name": "bool" } }, - "onChange": { "type": { "name": "func" } }, - "selected": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewCheckboxItem", - "imports": ["import MenuPreviewCheckboxItem from '@mui/material/MenuPreviewCheckboxItem';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'div'", - "class": null - } - ], - "classes": [], - "muiName": "MuiMenuPreviewCheckboxItem", - "filename": "/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-group-label.json b/docs/pages/material-ui/api/menu-preview-group-label.json deleted file mode 100644 index 05e7ee00cc3678..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-group-label.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "props": { - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "component": { "type": { "name": "elementType" } }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewGroupLabel", - "imports": ["import MenuPreviewGroupLabel from '@mui/material/MenuPreviewGroupLabel';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "ListSubheader", - "class": "MuiMenuPreviewGroupLabel-root" - } - ], - "classes": [], - "muiName": "MuiMenuPreviewGroupLabel", - "filename": "/packages/mui-material/src/MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-group.json b/docs/pages/material-ui/api/menu-preview-group.json deleted file mode 100644 index 75a9f51a590117..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-group.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "component": { "type": { "name": "elementType" } }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewGroup", - "imports": ["import MenuPreviewGroup from '@mui/material/MenuPreviewGroup';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'div'", - "class": "MuiMenuPreviewGroup-root" - } - ], - "classes": [], - "muiName": "MuiMenuPreviewGroup", - "filename": "/packages/mui-material/src/MenuPreviewGroup/MenuPreviewGroup.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-item.json b/docs/pages/material-ui/api/menu-preview-item.json deleted file mode 100644 index 85f2432722027e..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-item.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "closeOnClick": { "type": { "name": "bool" }, "default": "true" }, - "component": { "type": { "name": "elementType" } }, - "dense": { "type": { "name": "bool" }, "default": "false" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "disableGutters": { "type": { "name": "bool" }, "default": "false" }, - "divider": { "type": { "name": "bool" }, "default": "false" }, - "label": { "type": { "name": "string" } }, - "nativeButton": { "type": { "name": "bool" } }, - "selected": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewItem", - "imports": ["import MenuPreviewItem from '@mui/material/MenuPreviewItem';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'div'", - "class": null - } - ], - "classes": [], - "muiName": "MuiMenuPreviewItem", - "filename": "/packages/mui-material/src/MenuPreviewItem/MenuPreviewItem.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-link-item.json b/docs/pages/material-ui/api/menu-preview-link-item.json deleted file mode 100644 index 2f2aa34b194857..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-link-item.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "closeOnClick": { "type": { "name": "bool" }, "default": "false" }, - "component": { "type": { "name": "elementType" } }, - "dense": { "type": { "name": "bool" }, "default": "false" }, - "disableGutters": { "type": { "name": "bool" }, "default": "false" }, - "divider": { "type": { "name": "bool" }, "default": "false" }, - "href": { "type": { "name": "string" } }, - "label": { "type": { "name": "string" } }, - "selected": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewLinkItem", - "imports": ["import MenuPreviewLinkItem from '@mui/material/MenuPreviewLinkItem';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'a'", - "class": null - } - ], - "classes": [], - "muiName": "MuiMenuPreviewLinkItem", - "filename": "/packages/mui-material/src/MenuPreviewLinkItem/MenuPreviewLinkItem.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-popup.json b/docs/pages/material-ui/api/menu-preview-popup.json deleted file mode 100644 index eae28eec44cda5..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-popup.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "props": { - "align": { - "type": { - "name": "enum", - "description": "'center'
| 'end'
| 'start'" - }, - "default": "'start'" - }, - "alignOffset": { - "type": { "name": "union", "description": "func
| number" }, - "default": "0" - }, - "anchor": { - "type": { - "name": "union", - "description": "HTML element
| object
| func" - } - }, - "arrowPadding": { "type": { "name": "number" }, "default": "5" }, - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "collisionAvoidance": { - "type": { - "name": "union", - "description": "{ align?: 'flip'
| 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'flip'
| 'none' }
| { align?: 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'none'
| 'shift' }" - } - }, - "collisionBoundary": { - "type": { - "name": "union", - "description": "'clipping-ancestors'
| HTML element
| Array<HTML element>
| { height: number, width: number, x: number, y: number }" - }, - "default": "'clipping-ancestors'" - }, - "collisionPadding": { - "type": { - "name": "union", - "description": "number
| { bottom?: number, left?: number, right?: number, top?: number }" - }, - "default": "5" - }, - "container": { - "type": { - "name": "union", - "description": "HTML element
| object
| func" - } - }, - "disableAnchorTracking": { "type": { "name": "bool" }, "default": "false" }, - "elevation": { "type": { "name": "number" }, "default": "8" }, - "finalFocus": { - "type": { - "name": "union", - "description": "func
| { current?: HTML element }
| bool" - } - }, - "keepMounted": { "type": { "name": "bool" }, "default": "false" }, - "positionMethod": { - "type": { "name": "enum", "description": "'absolute'
| 'fixed'" }, - "default": "'absolute'" - }, - "side": { - "type": { - "name": "enum", - "description": "'bottom'
| 'inline-end'
| 'inline-start'
| 'left'
| 'right'
| 'top'" - }, - "default": "'bottom'" - }, - "sideOffset": { - "type": { "name": "union", "description": "func
| number" }, - "default": "0" - }, - "slotProps": { - "type": { - "name": "shape", - "description": "{ list?: func
| object, paper?: func
| object, popup?: func
| object, portal?: func
| object, positioner?: func
| object }" - } - }, - "slots": { - "type": { - "name": "shape", - "description": "{ list?: elementType, paper?: elementType, popup?: elementType, portal?: elementType, positioner?: elementType }" - } - }, - "sticky": { "type": { "name": "bool" }, "default": "false" }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewPopup", - "imports": ["import MenuPreviewPopup from '@mui/material/MenuPreviewPopup';"], - "slots": [ - { - "name": "portal", - "description": "The component used for the portal.", - "default": "BaseMenu.Portal", - "class": null - }, - { - "name": "positioner", - "description": "The component used for the positioner.", - "default": "BaseMenu.Positioner", - "class": null - }, - { - "name": "popup", - "description": "The component rendered by the Base UI popup.", - "default": "'div'", - "class": null - }, - { - "name": "paper", - "description": "The component used for the Material surface.", - "default": "Paper", - "class": "MuiMenuPreviewPopup-paper" - }, - { - "name": "list", - "description": "The component used for the presentational list wrapper.", - "default": "List", - "class": "MuiMenuPreviewPopup-list" - } - ], - "classes": [ - { - "key": "root", - "className": "MuiMenuPreviewPopup-root", - "description": "Styles applied to the root element.", - "isGlobal": false - } - ], - "muiName": "MuiMenuPreviewPopup", - "filename": "/packages/mui-material/src/MenuPreviewPopup/MenuPreviewPopup.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-group.json b/docs/pages/material-ui/api/menu-preview-radio-group.json deleted file mode 100644 index 0c709d5d214111..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-radio-group.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "component": { "type": { "name": "elementType" } }, - "defaultValue": { "type": { "name": "any" } }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "onChange": { "type": { "name": "func" } }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - }, - "value": { "type": { "name": "any" } } - }, - "name": "MenuPreviewRadioGroup", - "imports": ["import MenuPreviewRadioGroup from '@mui/material/MenuPreviewRadioGroup';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'div'", - "class": "MuiMenuPreviewRadioGroup-root" - } - ], - "classes": [ - { - "key": "disabled", - "className": "Mui-disabled", - "description": "State class applied to the root element if `disabled={true}`.", - "isGlobal": true - } - ], - "muiName": "MuiMenuPreviewRadioGroup", - "filename": "/packages/mui-material/src/MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json deleted file mode 100644 index 0ba43b87d70be3..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "props": { - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "component": { "type": { "name": "elementType" } }, - "keepMounted": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewRadioItemIndicator", - "imports": [ - "import MenuPreviewRadioItemIndicator from '@mui/material/MenuPreviewRadioItemIndicator';" - ], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'span'", - "class": "MuiMenuPreviewRadioItemIndicator-root" - } - ], - "classes": [ - { - "key": "checked", - "className": "Mui-checked", - "description": "State class applied to the root element if `checked={true}`.", - "isGlobal": true - }, - { - "key": "disabled", - "className": "Mui-disabled", - "description": "State class applied to the root element if `disabled={true}`.", - "isGlobal": true - }, - { - "key": "highlighted", - "className": "MuiMenuPreviewRadioItemIndicator-highlighted", - "description": "State class applied to the root element if highlighted.", - "isGlobal": false - } - ], - "muiName": "MuiMenuPreviewRadioItemIndicator", - "filename": "/packages/mui-material/src/MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item.json b/docs/pages/material-ui/api/menu-preview-radio-item.json deleted file mode 100644 index f5e4735f558304..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-radio-item.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "props": { - "value": { "type": { "name": "any" }, "required": true }, - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "closeOnClick": { "type": { "name": "bool" }, "default": "false" }, - "component": { "type": { "name": "elementType" } }, - "dense": { "type": { "name": "bool" }, "default": "false" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "disableGutters": { "type": { "name": "bool" }, "default": "false" }, - "divider": { "type": { "name": "bool" }, "default": "false" }, - "label": { "type": { "name": "string" } }, - "nativeButton": { "type": { "name": "bool" } }, - "selected": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewRadioItem", - "imports": ["import MenuPreviewRadioItem from '@mui/material/MenuPreviewRadioItem';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'div'", - "class": null - } - ], - "classes": [], - "muiName": "MuiMenuPreviewRadioItem", - "filename": "/packages/mui-material/src/MenuPreviewRadioItem/MenuPreviewRadioItem.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-separator.json b/docs/pages/material-ui/api/menu-preview-separator.json deleted file mode 100644 index 9c121e406aa332..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-separator.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "props": { - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "component": { "type": { "name": "elementType" } }, - "orientation": { - "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, - "default": "'horizontal'" - }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewSeparator", - "imports": ["import MenuPreviewSeparator from '@mui/material/MenuPreviewSeparator';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "Divider", - "class": "MuiMenuPreviewSeparator-root" - } - ], - "classes": [], - "muiName": "MuiMenuPreviewSeparator", - "filename": "/packages/mui-material/src/MenuPreviewSeparator/MenuPreviewSeparator.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-popup.json b/docs/pages/material-ui/api/menu-preview-submenu-popup.json deleted file mode 100644 index 44e03df304c3bc..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-submenu-popup.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "props": { - "align": { - "type": { - "name": "enum", - "description": "'center'
| 'end'
| 'start'" - }, - "default": "'start'" - }, - "alignOffset": { - "type": { "name": "union", "description": "func
| number" }, - "default": "0" - }, - "anchor": { - "type": { - "name": "union", - "description": "HTML element
| object
| func" - } - }, - "arrowPadding": { "type": { "name": "number" }, "default": "5" }, - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "collisionAvoidance": { - "type": { - "name": "union", - "description": "{ align?: 'flip'
| 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'flip'
| 'none' }
| { align?: 'none'
| 'shift', fallbackAxisSide?: 'end'
| 'none'
| 'start', side?: 'none'
| 'shift' }" - } - }, - "collisionBoundary": { - "type": { - "name": "union", - "description": "'clipping-ancestors'
| HTML element
| Array<HTML element>
| { height: number, width: number, x: number, y: number }" - }, - "default": "'clipping-ancestors'" - }, - "collisionPadding": { - "type": { - "name": "union", - "description": "number
| { bottom?: number, left?: number, right?: number, top?: number }" - }, - "default": "5" - }, - "container": { - "type": { - "name": "union", - "description": "HTML element
| object
| func" - } - }, - "disableAnchorTracking": { "type": { "name": "bool" }, "default": "false" }, - "elevation": { "type": { "name": "number" }, "default": "8" }, - "finalFocus": { - "type": { - "name": "union", - "description": "func
| { current?: HTML element }
| bool" - } - }, - "keepMounted": { "type": { "name": "bool" }, "default": "false" }, - "positionMethod": { - "type": { "name": "enum", "description": "'absolute'
| 'fixed'" }, - "default": "'absolute'" - }, - "side": { - "type": { - "name": "enum", - "description": "'bottom'
| 'inline-end'
| 'inline-start'
| 'left'
| 'right'
| 'top'" - }, - "default": "'inline-end'" - }, - "sideOffset": { - "type": { "name": "union", "description": "func
| number" }, - "default": "0" - }, - "slotProps": { - "type": { - "name": "shape", - "description": "{ list?: func
| object, paper?: func
| object, popup?: func
| object, portal?: func
| object, positioner?: func
| object }" - } - }, - "slots": { - "type": { - "name": "shape", - "description": "{ list?: elementType, paper?: elementType, popup?: elementType, portal?: elementType, positioner?: elementType }" - } - }, - "sticky": { "type": { "name": "bool" }, "default": "false" }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewSubmenuPopup", - "imports": ["import MenuPreviewSubmenuPopup from '@mui/material/MenuPreviewSubmenuPopup';"], - "slots": [ - { - "name": "portal", - "description": "The component used for the portal.", - "default": "BaseMenu.Portal", - "class": null - }, - { - "name": "positioner", - "description": "The component used for the positioner.", - "default": "BaseMenu.Positioner", - "class": null - }, - { - "name": "popup", - "description": "The component rendered by the Base UI popup.", - "default": "'div'", - "class": null - }, - { - "name": "paper", - "description": "The component used for the Material surface.", - "default": "Paper", - "class": "MuiMenuPreviewSubmenuPopup-paper" - }, - { - "name": "list", - "description": "The component used for the presentational list wrapper.", - "default": "List", - "class": "MuiMenuPreviewSubmenuPopup-list" - } - ], - "classes": [ - { - "key": "root", - "className": "MuiMenuPreviewSubmenuPopup-root", - "description": "Styles applied to the root element.", - "isGlobal": false - } - ], - "muiName": "MuiMenuPreviewSubmenuPopup", - "filename": "/packages/mui-material/src/MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-root.json b/docs/pages/material-ui/api/menu-preview-submenu-root.json deleted file mode 100644 index 3ec88efda62789..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-submenu-root.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "closeParentOnEsc": { "type": { "name": "bool" }, "default": "false" }, - "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "highlightItemOnHover": { "type": { "name": "bool" }, "default": "true" }, - "loopFocus": { "type": { "name": "bool" }, "default": "true" }, - "onOpenChange": { "type": { "name": "func" } }, - "onOpenChangeComplete": { "type": { "name": "func" } }, - "open": { "type": { "name": "bool" } }, - "orientation": { - "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, - "default": "'vertical'" - } - }, - "name": "MenuPreviewSubmenuRoot", - "imports": ["import MenuPreviewSubmenuRoot from '@mui/material/MenuPreviewSubmenuRoot';"], - "classes": [], - "muiName": "MuiMenuPreviewSubmenuRoot", - "filename": "/packages/mui-material/src/MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-trigger.json b/docs/pages/material-ui/api/menu-preview-submenu-trigger.json deleted file mode 100644 index 1de0b51f8fe169..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-submenu-trigger.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "closeDelay": { "type": { "name": "number" }, "default": "0" }, - "component": { "type": { "name": "elementType" } }, - "delay": { "type": { "name": "number" }, "default": "100" }, - "dense": { "type": { "name": "bool" }, "default": "false" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "disableGutters": { "type": { "name": "bool" }, "default": "false" }, - "divider": { "type": { "name": "bool" }, "default": "false" }, - "label": { "type": { "name": "string" } }, - "nativeButton": { "type": { "name": "bool" } }, - "openOnHover": { "type": { "name": "bool" } }, - "selected": { "type": { "name": "bool" }, "default": "false" }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewSubmenuTrigger", - "imports": ["import MenuPreviewSubmenuTrigger from '@mui/material/MenuPreviewSubmenuTrigger';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "'div'", - "class": null - } - ], - "classes": [], - "muiName": "MuiMenuPreviewSubmenuTrigger", - "filename": "/packages/mui-material/src/MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview-trigger.json b/docs/pages/material-ui/api/menu-preview-trigger.json deleted file mode 100644 index 385c860b98acb7..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-trigger.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "props": { - "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, - "className": { "type": { "name": "string" } }, - "closeDelay": { "type": { "name": "number" }, "default": "0" }, - "component": { "type": { "name": "elementType" } }, - "delay": { "type": { "name": "number" }, "default": "100" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "nativeButton": { "type": { "name": "bool" } }, - "openOnHover": { "type": { "name": "bool" } }, - "slotProps": { - "type": { "name": "shape", "description": "{ root?: func
| object }" } - }, - "slots": { "type": { "name": "shape", "description": "{ root?: elementType }" } }, - "style": { "type": { "name": "object" } }, - "sx": { - "type": { - "name": "union", - "description": "Array<func
| object
| bool>
| func
| object" - }, - "additionalInfo": { "sx": true } - } - }, - "name": "MenuPreviewTrigger", - "imports": ["import MenuPreviewTrigger from '@mui/material/MenuPreviewTrigger';"], - "slots": [ - { - "name": "root", - "description": "The component that renders the root.", - "default": "Button", - "class": "MuiMenuPreviewTrigger-root" - } - ], - "classes": [ - { - "key": "disabled", - "className": "Mui-disabled", - "description": "State class applied to the root element if `disabled={true}`.", - "isGlobal": true - }, - { - "key": "open", - "className": "Mui-open", - "description": "State class applied to the root element if the menu is open.", - "isGlobal": true - } - ], - "muiName": "MuiMenuPreviewTrigger", - "filename": "/packages/mui-material/src/MenuPreviewTrigger/MenuPreviewTrigger.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/pages/material-ui/api/menu-preview.json b/docs/pages/material-ui/api/menu-preview.json deleted file mode 100644 index 084edc4242f118..00000000000000 --- a/docs/pages/material-ui/api/menu-preview.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "props": { - "children": { "type": { "name": "node" } }, - "closeParentOnEsc": { "type": { "name": "bool" }, "default": "false" }, - "defaultOpen": { "type": { "name": "bool" }, "default": "false" }, - "disabled": { "type": { "name": "bool" }, "default": "false" }, - "highlightItemOnHover": { "type": { "name": "bool" }, "default": "true" }, - "loopFocus": { "type": { "name": "bool" }, "default": "true" }, - "modal": { "type": { "name": "bool" }, "default": "true" }, - "onOpenChange": { "type": { "name": "func" } }, - "onOpenChangeComplete": { "type": { "name": "func" } }, - "open": { "type": { "name": "bool" } }, - "orientation": { - "type": { "name": "enum", "description": "'horizontal'
| 'vertical'" }, - "default": "'vertical'" - } - }, - "name": "MenuPreview", - "imports": ["import MenuPreview from '@mui/material/MenuPreview';"], - "classes": [], - "spread": true, - "themeDefaultProps": null, - "muiName": "MuiMenuPreview", - "filename": "/packages/mui-material/src/MenuPreview/MenuPreview.tsx", - "inheritance": null, - "demos": "" -} diff --git a/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json b/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json deleted file mode 100644 index 5d76236eb0765d..00000000000000 --- a/docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "component": { "description": "The component used for the root node." }, - "keepMounted": { - "description": "Whether to keep the HTML element in the DOM when the checkbox item is not checked." - }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": { - "checked": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "checked={true}" - }, - "disabled": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "disabled={true}" - }, - "highlighted": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "highlighted" - } - }, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json b/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json deleted file mode 100644 index d166f10000526b..00000000000000 --- a/docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "checked": { - "description": "Whether the checkbox item is currently ticked.
To render an uncontrolled checkbox item, use the defaultChecked prop instead." - }, - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, - "component": { "description": "The component used for the root node." }, - "defaultChecked": { - "description": "Whether the checkbox item is initially ticked.
To render a controlled checkbox item, use the checked prop instead." - }, - "dense": { - "description": "If true, compact vertical padding designed for keyboard and mouse input is used." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "disableGutters": { - "description": "If true, the left and right padding is removed." - }, - "divider": { - "description": "If true, a 1px light border is added to the bottom of the menu item." - }, - "label": { - "description": "Overrides the text label to use when the item is matched during keyboard text navigation." - }, - "nativeButton": { - "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." - }, - "onChange": { - "description": "Event handler called when the checkbox item is ticked or unticked." - }, - "selected": { "description": "If true, the component is selected." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json b/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json deleted file mode 100644 index 84f2fc365ca341..00000000000000 --- a/docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "component": { "description": "The component used for the root node." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-group/menu-preview-group.json b/docs/translations/api-docs/menu-preview-group/menu-preview-group.json deleted file mode 100644 index 5b609b8b501add..00000000000000 --- a/docs/translations/api-docs/menu-preview-group/menu-preview-group.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "component": { "description": "The component used for the root node." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-item/menu-preview-item.json b/docs/translations/api-docs/menu-preview-item/menu-preview-item.json deleted file mode 100644 index f7a24beb397a62..00000000000000 --- a/docs/translations/api-docs/menu-preview-item/menu-preview-item.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, - "component": { "description": "The component used for the root node." }, - "dense": { - "description": "If true, compact vertical padding designed for keyboard and mouse input is used." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "disableGutters": { - "description": "If true, the left and right padding is removed." - }, - "divider": { - "description": "If true, a 1px light border is added to the bottom of the menu item." - }, - "label": { - "description": "Overrides the text label to use when the item is matched during keyboard text navigation." - }, - "nativeButton": { - "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." - }, - "selected": { "description": "If true, the component is selected." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json b/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json deleted file mode 100644 index 56fc025de29c66..00000000000000 --- a/docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, - "component": { "description": "The component used for the root node." }, - "dense": { - "description": "If true, compact vertical padding designed for keyboard and mouse input is used." - }, - "disableGutters": { - "description": "If true, the left and right padding is removed." - }, - "divider": { - "description": "If true, a 1px light border is added to the bottom of the menu item." - }, - "href": { "description": "The URL that the link item points to." }, - "label": { - "description": "Overrides the text label to use when the item is matched during keyboard text navigation." - }, - "selected": { "description": "If true, the component is selected." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json b/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json deleted file mode 100644 index b7b59afbc03738..00000000000000 --- a/docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "align": { "description": "How to align the popup relative to the specified side." }, - "alignOffset": { "description": "Additional offset along the alignment axis in pixels." }, - "anchor": { - "description": "An element to position the popup against.
By default, the popup is positioned against the trigger." - }, - "arrowPadding": { - "description": "Minimum distance to maintain between the arrow and the edges of the popup." - }, - "children": { "description": "The menu items." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the Base UI popup element." }, - "collisionAvoidance": { - "description": "Determines how to handle collisions when positioning the popup." - }, - "collisionBoundary": { - "description": "An element or a rectangle that delimits the area that the popup is confined to." - }, - "collisionPadding": { - "description": "Additional space to maintain from the edge of the collision boundary." - }, - "container": { "description": "The container element to portal the popup into." }, - "disableAnchorTracking": { - "description": "Whether to disable the popup from tracking layout shifts of its positioning anchor." - }, - "elevation": { "description": "The elevation of the menu surface." }, - "finalFocus": { "description": "Determines the element to focus when the menu is closed." }, - "keepMounted": { - "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." - }, - "positionMethod": { - "description": "Determines which CSS position property to use." - }, - "side": { "description": "Which side of the anchor element to align the popup against." }, - "sideOffset": { "description": "Distance between the anchor and the popup in pixels." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "sticky": { - "description": "Whether to maintain the popup in the viewport after the anchor element was scrolled out of view." - }, - "style": { "description": "Styles applied to the Base UI popup element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": { "root": { "description": "Styles applied to the root element." } }, - "slotDescriptions": { - "list": "The component used for the presentational list wrapper.", - "paper": "The component used for the Material surface.", - "popup": "The component rendered by the Base UI popup.", - "portal": "The component used for the portal.", - "positioner": "The component used for the positioner." - } -} diff --git a/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json b/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json deleted file mode 100644 index 2660a224a7df17..00000000000000 --- a/docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "component": { "description": "The component used for the root node." }, - "defaultValue": { - "description": "The uncontrolled value of the radio item that should be initially selected." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "onChange": { "description": "Function called when the selected value changes." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - }, - "value": { - "description": "The controlled value of the radio item that should be currently selected." - } - }, - "classDescriptions": { - "disabled": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "disabled={true}" - } - }, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json b/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json deleted file mode 100644 index df183e63479557..00000000000000 --- a/docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "component": { "description": "The component used for the root node." }, - "keepMounted": { - "description": "Whether to keep the HTML element in the DOM when the radio item is inactive." - }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": { - "checked": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "checked={true}" - }, - "disabled": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "disabled={true}" - }, - "highlighted": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "highlighted" - } - }, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json b/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json deleted file mode 100644 index b3eb6029c79fd2..00000000000000 --- a/docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "closeOnClick": { "description": "Whether to close the menu when the item is clicked." }, - "component": { "description": "The component used for the root node." }, - "dense": { - "description": "If true, compact vertical padding designed for keyboard and mouse input is used." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "disableGutters": { - "description": "If true, the left and right padding is removed." - }, - "divider": { - "description": "If true, a 1px light border is added to the bottom of the menu item." - }, - "label": { - "description": "Overrides the text label to use when the item is matched during keyboard text navigation." - }, - "nativeButton": { - "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." - }, - "selected": { "description": "If true, the component is selected." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - }, - "value": { "description": "Value of the radio item." } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json b/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json deleted file mode 100644 index 87b20cb20534de..00000000000000 --- a/docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "component": { "description": "The component used for the root node." }, - "orientation": { "description": "The orientation of the separator." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json b/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json deleted file mode 100644 index e8c18a9115d7cd..00000000000000 --- a/docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "align": { "description": "How to align the popup relative to the specified side." }, - "alignOffset": { "description": "Additional offset along the alignment axis in pixels." }, - "anchor": { - "description": "An element to position the popup against.
By default, the popup is positioned against the submenu trigger." - }, - "arrowPadding": { - "description": "Minimum distance to maintain between the arrow and the edges of the popup." - }, - "children": { "description": "The submenu items." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the Base UI popup element." }, - "collisionAvoidance": { - "description": "Determines how to handle collisions when positioning the popup." - }, - "collisionBoundary": { - "description": "An element or a rectangle that delimits the area that the popup is confined to." - }, - "collisionPadding": { - "description": "Additional space to maintain from the edge of the collision boundary." - }, - "container": { "description": "The container element to portal the popup into." }, - "disableAnchorTracking": { - "description": "Whether to disable the popup from tracking layout shifts of its positioning anchor." - }, - "elevation": { "description": "The elevation of the menu surface." }, - "finalFocus": { "description": "Determines the element to focus when the menu is closed." }, - "keepMounted": { - "description": "Whether to keep the portal mounted in the DOM while the popup is hidden." - }, - "positionMethod": { - "description": "Determines which CSS position property to use." - }, - "side": { "description": "Which side of the anchor element to align the popup against." }, - "sideOffset": { "description": "Distance between the anchor and the popup in pixels." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "sticky": { - "description": "Whether to maintain the popup in the viewport after the anchor element was scrolled out of view." - }, - "style": { "description": "Styles applied to the Base UI popup element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": { "root": { "description": "Styles applied to the root element." } }, - "slotDescriptions": { - "list": "The component used for the presentational list wrapper.", - "paper": "The component used for the Material surface.", - "popup": "The component rendered by the Base UI popup.", - "portal": "The component used for the portal.", - "positioner": "The component used for the positioner." - } -} diff --git a/docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json b/docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json deleted file mode 100644 index ff6614738e49c7..00000000000000 --- a/docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the submenu." }, - "closeParentOnEsc": { - "description": "When in a submenu, determines whether pressing the Escape key closes the entire menu." - }, - "defaultOpen": { - "description": "Whether the submenu is initially open.
To render a controlled submenu, use the open prop instead." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "highlightItemOnHover": { - "description": "Whether moving the pointer over items should highlight them." - }, - "loopFocus": { "description": "Whether to loop keyboard focus back to the first item." }, - "onOpenChange": { "description": "Event handler called when the submenu is opened or closed." }, - "onOpenChangeComplete": { - "description": "Event handler called after any animations complete when the submenu is opened or closed." - }, - "open": { "description": "Whether the submenu is currently open." }, - "orientation": { "description": "The visual orientation of the submenu." } - }, - "classDescriptions": {} -} diff --git a/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json b/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json deleted file mode 100644 index 63b8e9926e33af..00000000000000 --- a/docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the component." }, - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "closeDelay": { - "description": "How long to wait before closing the submenu that was opened on hover, in milliseconds.
Requires the openOnHover prop." - }, - "component": { "description": "The component used for the root node." }, - "delay": { - "description": "How long to wait before the submenu may be opened on hover, in milliseconds.
Requires the openOnHover prop." - }, - "dense": { - "description": "If true, compact vertical padding designed for keyboard and mouse input is used." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "disableGutters": { - "description": "If true, the left and right padding is removed." - }, - "divider": { - "description": "If true, a 1px light border is added to the bottom of the menu item." - }, - "label": { - "description": "Overrides the text label to use when the item is matched during keyboard text navigation." - }, - "nativeButton": { - "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." - }, - "openOnHover": { - "description": "Whether the submenu should also open when the trigger is hovered." - }, - "selected": { "description": "If true, the component is selected." }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": {}, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json b/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json deleted file mode 100644 index de06b7a775229e..00000000000000 --- a/docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "classes": { "description": "Override or extend the styles applied to the component." }, - "className": { "description": "CSS class applied to the element." }, - "closeDelay": { - "description": "How long to wait before closing the menu that was opened on hover, in milliseconds.
Requires the openOnHover prop." - }, - "component": { "description": "The component used for the root node." }, - "delay": { - "description": "How long to wait before the menu may be opened on hover, in milliseconds.
Requires the openOnHover prop." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "nativeButton": { - "description": "Whether the component is rendered as a native button.
By default, this is inferred from the root slot and component prop." - }, - "openOnHover": { - "description": "Whether the menu should also open when the trigger is hovered." - }, - "slotProps": { "description": "The props used for each slot inside." }, - "slots": { "description": "The components used for each slot inside." }, - "style": { "description": "Styles applied to the root element." }, - "sx": { - "description": "The system prop that allows defining system overrides as well as additional CSS styles." - } - }, - "classDescriptions": { - "disabled": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "disabled={true}" - }, - "open": { - "description": "State class applied to {{nodeName}} if {{conditions}}.", - "nodeName": "the root element", - "conditions": "the menu is open" - } - }, - "slotDescriptions": { "root": "The component that renders the root." } -} diff --git a/docs/translations/api-docs/menu-preview/menu-preview.json b/docs/translations/api-docs/menu-preview/menu-preview.json deleted file mode 100644 index 31e7124fe6bf7f..00000000000000 --- a/docs/translations/api-docs/menu-preview/menu-preview.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "componentDescription": "", - "propDescriptions": { - "children": { "description": "The content of the menu." }, - "closeParentOnEsc": { - "description": "When in a submenu, determines whether pressing the Escape key closes the entire menu." - }, - "defaultOpen": { - "description": "Whether the menu is initially open.
To render a controlled menu, use the open prop instead." - }, - "disabled": { "description": "Whether the component should ignore user interaction." }, - "highlightItemOnHover": { - "description": "Whether moving the pointer over items should highlight them." - }, - "loopFocus": { "description": "Whether to loop keyboard focus back to the first item." }, - "modal": { "description": "Determines if the menu enters a modal state when open." }, - "onOpenChange": { "description": "Event handler called when the menu is opened or closed." }, - "onOpenChangeComplete": { - "description": "Event handler called after any animations complete when the menu is opened or closed." - }, - "open": { "description": "Whether the menu is currently open." }, - "orientation": { "description": "The visual orientation of the menu." } - }, - "classDescriptions": {} -} diff --git a/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts b/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts index e1a85bdc147ba5..a52477fe5e64cd 100644 --- a/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts +++ b/packages-internal/api-docs-builder-core/materialUi/projectSettings.ts @@ -1,6 +1,6 @@ import path from 'path'; import { LANGUAGES } from '@mui/internal-core-docs/constants'; -import { ProjectSettings, findApiPages, toGitHubPath } from '@mui/internal-api-docs-builder'; +import { ProjectSettings, findApiPages } from '@mui/internal-api-docs-builder'; import generateUtilityClass, { isGlobalState } from '@mui/utils/generateUtilityClass'; import { getMaterialUiComponentInfo } from './getMaterialUiComponentInfo'; @@ -13,42 +13,6 @@ const generateClassName = (componentName: string, slot: string, globalStatePrefi return generateUtilityClass(componentName, slot, globalStatePrefix); }; -const getComponentImports = (name: string, filename: string) => { - const githubPath = toGitHubPath(filename); - const directory = githubPath.match(/\/packages\/mui-material\/src\/([^/]+)\//)?.[1]; - - if (directory?.startsWith('MenuPreview')) { - return [`import ${name} from '@mui/material/${directory}';`]; - } - - const rootImportPath = githubPath.replace( - /\/packages\/mui(?:-(.+?))?\/src\/.*/, - (match, pkg) => `@mui/${pkg}`, - ); - - const subdirectoryImportPath = githubPath.replace( - /\/packages\/mui(?:-(.+?))?\/src\/([^\\/]+)\/.*/, - (match, pkg, subdirectory) => `@mui/${pkg}/${subdirectory}`, - ); - - let namedImportName = name; - const defaultImportName = name; - - if (githubPath.includes('Unstable_')) { - namedImportName = `Unstable_${name} as ${name}`; - } - - const useNamedImports = rootImportPath === '@mui/base'; - - const subpathImport = useNamedImports - ? `import { ${namedImportName} } from '${subdirectoryImportPath}';` - : `import ${defaultImportName} from '${subdirectoryImportPath}';`; - - const rootImport = `import { ${namedImportName} } from '${rootImportPath}';`; - - return [subpathImport, rootImport]; -}; - export const projectSettings: ProjectSettings = { output: { apiManifestPath: path.join(process.cwd(), 'docs/data/material/pagesApi.js'), @@ -59,7 +23,6 @@ export const projectSettings: ProjectSettings = { rootPath: path.join(process.cwd(), 'packages/mui-material'), entryPointPath: [ 'src/index.d.ts', - 'src/MenuPreview/apiDocs.d.ts', 'src/PigmentStack/PigmentStack.tsx', 'src/PigmentContainer/PigmentContainer.tsx', 'src/PigmentGrid/PigmentGrid.tsx', @@ -78,7 +41,6 @@ export const projectSettings: ProjectSettings = { return filename.match(/(ThemeProvider|CssVarsProvider|DefaultPropsProvider)/) !== null; }, translationPagesDirectory: 'docs/translations/api-docs', - getComponentImports, generateClassName, isGlobalClassName: isGlobalState, // #host-reference diff --git a/packages/mui-material/src/MenuPreview/apiDocs.d.ts b/packages/mui-material/src/MenuPreview/apiDocs.d.ts deleted file mode 100644 index 6c939d59992308..00000000000000 --- a/packages/mui-material/src/MenuPreview/apiDocs.d.ts +++ /dev/null @@ -1,49 +0,0 @@ -export { default as MenuPreview } from './MenuPreview'; -export * from './MenuPreview'; - -export { default as MenuPreviewTrigger } from '../MenuPreviewTrigger'; -export * from '../MenuPreviewTrigger'; - -export { default as MenuPreviewPopup } from '../MenuPreviewPopup'; -export * from '../MenuPreviewPopup'; - -export { default as MenuPreviewSubmenuPopup } from '../MenuPreviewSubmenuPopup'; -export * from '../MenuPreviewSubmenuPopup'; - -export { default as MenuPreviewItem } from '../MenuPreviewItem'; -export * from '../MenuPreviewItem'; - -export { default as MenuPreviewLinkItem } from '../MenuPreviewLinkItem'; -export * from '../MenuPreviewLinkItem'; - -export { default as MenuPreviewCheckboxItem } from '../MenuPreviewCheckboxItem'; -export * from '../MenuPreviewCheckboxItem'; - -export { default as MenuPreviewCheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; -export * from '../MenuPreviewCheckboxItemIndicator'; - -export { default as MenuPreviewRadioGroup } from '../MenuPreviewRadioGroup'; -export * from '../MenuPreviewRadioGroup'; - -export { default as MenuPreviewRadioItem } from '../MenuPreviewRadioItem'; -export * from '../MenuPreviewRadioItem'; - -export { default as MenuPreviewRadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; -export * from '../MenuPreviewRadioItemIndicator'; - -export { default as MenuPreviewGroup } from '../MenuPreviewGroup'; -export * from '../MenuPreviewGroup'; - -export { default as MenuPreviewGroupLabel } from '../MenuPreviewGroupLabel'; -export * from '../MenuPreviewGroupLabel'; - -export { default as MenuPreviewSeparator } from '../MenuPreviewSeparator'; -export * from '../MenuPreviewSeparator'; - -export { default as MenuPreviewSubmenuRoot } from '../MenuPreviewSubmenuRoot'; -export * from '../MenuPreviewSubmenuRoot'; - -export { default as MenuPreviewSubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; -export * from '../MenuPreviewSubmenuTrigger'; - -export * from './menuPreviewClasses'; From a9c5e155d31fd847fefe9df9d2896dd785a53d91 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 11:11:49 +0300 Subject: [PATCH 10/62] Rename MenuPreview family to Unstable_Menu2 Aligns the experiment with the agreed naming lifecycle: - public subpaths and directories use Unstable_Menu2* (one component per subpath); the root index no longer re-exports parts or Base UI-style short aliases - internal identifiers and file names use clean Menu2* naming, following the Unstable_TrapFocus/Grid2 precedent enforced by the naming-convention lint rules - theme keys and classes use MuiMenu2* (the name-matches-component lint rule derives keys from component names; matches Grid2, and keys survive the later Unstable_ -> stable rename) - data attributes use data-mui-menu2-* Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu-preview.tsx | 86 ++- docs/pages/experiments/menu-rfc.tsx | 54 +- .../mui-material/src/MenuPreview/index.d.ts | 64 -- .../mui-material/src/MenuPreview/index.js | 64 -- .../src/MenuPreview/menuPreviewClasses.ts | 280 -------- .../src/MenuPreviewCheckboxItem/index.d.ts | 10 - .../src/MenuPreviewCheckboxItem/index.js | 6 - .../index.d.ts | 10 - .../MenuPreviewCheckboxItemIndicator/index.js | 6 - .../src/MenuPreviewGroup/index.d.ts | 10 - .../src/MenuPreviewGroup/index.js | 6 - .../src/MenuPreviewGroupLabel/index.d.ts | 10 - .../src/MenuPreviewGroupLabel/index.js | 6 - .../src/MenuPreviewItem/index.d.ts | 10 - .../mui-material/src/MenuPreviewItem/index.js | 6 - .../src/MenuPreviewLinkItem/index.d.ts | 10 - .../src/MenuPreviewLinkItem/index.js | 6 - .../src/MenuPreviewPopup/index.d.ts | 10 - .../src/MenuPreviewPopup/index.js | 6 - .../src/MenuPreviewRadioGroup/index.d.ts | 10 - .../src/MenuPreviewRadioGroup/index.js | 6 - .../src/MenuPreviewRadioItem/index.d.ts | 10 - .../src/MenuPreviewRadioItem/index.js | 6 - .../MenuPreviewRadioItemIndicator/index.d.ts | 10 - .../MenuPreviewRadioItemIndicator/index.js | 6 - .../src/MenuPreviewSeparator/index.d.ts | 10 - .../src/MenuPreviewSeparator/index.js | 6 - .../src/MenuPreviewSubmenuPopup/index.d.ts | 10 - .../src/MenuPreviewSubmenuPopup/index.js | 6 - .../src/MenuPreviewSubmenuRoot/index.d.ts | 2 - .../src/MenuPreviewSubmenuRoot/index.js | 2 - .../src/MenuPreviewSubmenuTrigger/index.d.ts | 10 - .../src/MenuPreviewSubmenuTrigger/index.js | 6 - .../src/MenuPreviewTrigger/index.d.ts | 10 - .../src/MenuPreviewTrigger/index.js | 6 - .../Menu2.spec.tsx} | 53 +- .../Menu2.test.tsx} | 610 +++++++++--------- .../Menu2.tsx} | 12 +- .../src/Unstable_Menu2/index.d.ts | 3 + .../mui-material/src/Unstable_Menu2/index.js | 3 + .../src/Unstable_Menu2/menu2Classes.ts | 273 ++++++++ .../menu2ItemShared.tsx} | 46 +- .../menu2PopupShared.tsx} | 78 +-- .../menu2SharedStyles.ts} | 18 +- .../menu2Utils.ts} | 12 +- .../Menu2CheckboxItem.tsx} | 86 +-- .../src/Unstable_Menu2CheckboxItem/index.d.ts | 10 + .../src/Unstable_Menu2CheckboxItem/index.js | 6 + .../Menu2CheckboxItemIndicator.tsx} | 73 +-- .../index.d.ts | 10 + .../index.js | 6 + .../Menu2Group.tsx} | 45 +- .../src/Unstable_Menu2Group/index.d.ts | 4 + .../src/Unstable_Menu2Group/index.js | 3 + .../Menu2GroupLabel.tsx} | 46 +- .../src/Unstable_Menu2GroupLabel/index.d.ts | 10 + .../src/Unstable_Menu2GroupLabel/index.js | 6 + .../Menu2Item.tsx} | 84 ++- .../src/Unstable_Menu2Item/index.d.ts | 4 + .../src/Unstable_Menu2Item/index.js | 3 + .../Menu2LinkItem.tsx} | 78 ++- .../src/Unstable_Menu2LinkItem/index.d.ts | 4 + .../src/Unstable_Menu2LinkItem/index.js | 3 + .../Menu2Popup.tsx} | 120 ++-- .../src/Unstable_Menu2Popup/index.d.ts | 4 + .../src/Unstable_Menu2Popup/index.js | 3 + .../Menu2RadioGroup.tsx} | 48 +- .../src/Unstable_Menu2RadioGroup/index.d.ts | 10 + .../src/Unstable_Menu2RadioGroup/index.js | 6 + .../Menu2RadioItem.tsx} | 84 ++- .../src/Unstable_Menu2RadioItem/index.d.ts | 7 + .../src/Unstable_Menu2RadioItem/index.js | 6 + .../Menu2RadioItemIndicator.tsx} | 71 +- .../index.d.ts | 10 + .../Unstable_Menu2RadioItemIndicator/index.js | 6 + .../Menu2Separator.tsx} | 46 +- .../src/Unstable_Menu2Separator/index.d.ts | 7 + .../src/Unstable_Menu2Separator/index.js | 6 + .../Menu2SubmenuPopup.tsx} | 121 ++-- .../src/Unstable_Menu2SubmenuPopup/index.d.ts | 10 + .../src/Unstable_Menu2SubmenuPopup/index.js | 6 + .../Menu2SubmenuRoot.tsx} | 12 +- .../src/Unstable_Menu2SubmenuRoot/index.d.ts | 2 + .../src/Unstable_Menu2SubmenuRoot/index.js | 2 + .../Menu2SubmenuTrigger.tsx} | 86 +-- .../Unstable_Menu2SubmenuTrigger/index.d.ts | 10 + .../src/Unstable_Menu2SubmenuTrigger/index.js | 6 + .../Menu2Trigger.tsx} | 57 +- .../src/Unstable_Menu2Trigger/index.d.ts | 4 + .../src/Unstable_Menu2Trigger/index.js | 3 + .../mui-material/src/styles/components.ts | 122 ++-- packages/mui-material/src/styles/overrides.ts | 58 +- packages/mui-material/src/styles/props.ts | 64 +- 93 files changed, 1570 insertions(+), 1792 deletions(-) delete mode 100644 packages/mui-material/src/MenuPreview/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreview/index.js delete mode 100644 packages/mui-material/src/MenuPreview/menuPreviewClasses.ts delete mode 100644 packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewCheckboxItem/index.js delete mode 100644 packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js delete mode 100644 packages/mui-material/src/MenuPreviewGroup/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewGroup/index.js delete mode 100644 packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewGroupLabel/index.js delete mode 100644 packages/mui-material/src/MenuPreviewItem/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewItem/index.js delete mode 100644 packages/mui-material/src/MenuPreviewLinkItem/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewLinkItem/index.js delete mode 100644 packages/mui-material/src/MenuPreviewPopup/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewPopup/index.js delete mode 100644 packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewRadioGroup/index.js delete mode 100644 packages/mui-material/src/MenuPreviewRadioItem/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewRadioItem/index.js delete mode 100644 packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js delete mode 100644 packages/mui-material/src/MenuPreviewSeparator/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewSeparator/index.js delete mode 100644 packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewSubmenuPopup/index.js delete mode 100644 packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewSubmenuRoot/index.js delete mode 100644 packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js delete mode 100644 packages/mui-material/src/MenuPreviewTrigger/index.d.ts delete mode 100644 packages/mui-material/src/MenuPreviewTrigger/index.js rename packages/mui-material/src/{MenuPreview/MenuPreview.spec.tsx => Unstable_Menu2/Menu2.spec.tsx} (74%) rename packages/mui-material/src/{MenuPreview/MenuPreview.test.tsx => Unstable_Menu2/Menu2.test.tsx} (63%) rename packages/mui-material/src/{MenuPreview/MenuPreview.tsx => Unstable_Menu2/Menu2.tsx} (93%) create mode 100644 packages/mui-material/src/Unstable_Menu2/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2/index.js create mode 100644 packages/mui-material/src/Unstable_Menu2/menu2Classes.ts rename packages/mui-material/src/{MenuPreview/menuPreviewItemShared.tsx => Unstable_Menu2/menu2ItemShared.tsx} (82%) rename packages/mui-material/src/{MenuPreview/menuPreviewPopupShared.tsx => Unstable_Menu2/menu2PopupShared.tsx} (77%) rename packages/mui-material/src/{MenuPreview/menuPreviewSharedStyles.ts => Unstable_Menu2/menu2SharedStyles.ts} (78%) rename packages/mui-material/src/{MenuPreview/menuPreviewUtils.ts => Unstable_Menu2/menu2Utils.ts} (87%) rename packages/mui-material/src/{MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx => Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx} (76%) create mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js rename packages/mui-material/src/{MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx => Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx} (69%) create mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js rename packages/mui-material/src/{MenuPreviewGroup/MenuPreviewGroup.tsx => Unstable_Menu2Group/Menu2Group.tsx} (75%) create mode 100644 packages/mui-material/src/Unstable_Menu2Group/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2Group/index.js rename packages/mui-material/src/{MenuPreviewGroupLabel/MenuPreviewGroupLabel.tsx => Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx} (74%) create mode 100644 packages/mui-material/src/Unstable_Menu2GroupLabel/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2GroupLabel/index.js rename packages/mui-material/src/{MenuPreviewItem/MenuPreviewItem.tsx => Unstable_Menu2Item/Menu2Item.tsx} (71%) create mode 100644 packages/mui-material/src/Unstable_Menu2Item/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2Item/index.js rename packages/mui-material/src/{MenuPreviewLinkItem/MenuPreviewLinkItem.tsx => Unstable_Menu2LinkItem/Menu2LinkItem.tsx} (71%) create mode 100644 packages/mui-material/src/Unstable_Menu2LinkItem/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2LinkItem/index.js rename packages/mui-material/src/{MenuPreviewPopup/MenuPreviewPopup.tsx => Unstable_Menu2Popup/Menu2Popup.tsx} (75%) create mode 100644 packages/mui-material/src/Unstable_Menu2Popup/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2Popup/index.js rename packages/mui-material/src/{MenuPreviewRadioGroup/MenuPreviewRadioGroup.tsx => Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx} (79%) create mode 100644 packages/mui-material/src/Unstable_Menu2RadioGroup/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2RadioGroup/index.js rename packages/mui-material/src/{MenuPreviewRadioItem/MenuPreviewRadioItem.tsx => Unstable_Menu2RadioItem/Menu2RadioItem.tsx} (71%) create mode 100644 packages/mui-material/src/Unstable_Menu2RadioItem/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2RadioItem/index.js rename packages/mui-material/src/{MenuPreviewRadioItemIndicator/MenuPreviewRadioItemIndicator.tsx => Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx} (71%) create mode 100644 packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js rename packages/mui-material/src/{MenuPreviewSeparator/MenuPreviewSeparator.tsx => Unstable_Menu2Separator/Menu2Separator.tsx} (76%) create mode 100644 packages/mui-material/src/Unstable_Menu2Separator/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2Separator/index.js rename packages/mui-material/src/{MenuPreviewSubmenuPopup/MenuPreviewSubmenuPopup.tsx => Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx} (73%) create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js rename packages/mui-material/src/{MenuPreviewSubmenuRoot/MenuPreviewSubmenuRoot.tsx => Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx} (90%) create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js rename packages/mui-material/src/{MenuPreviewSubmenuTrigger/MenuPreviewSubmenuTrigger.tsx => Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx} (72%) create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js rename packages/mui-material/src/{MenuPreviewTrigger/MenuPreviewTrigger.tsx => Unstable_Menu2Trigger/Menu2Trigger.tsx} (79%) create mode 100644 packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2Trigger/index.js diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx index 9f08660603a43d..7edf5d139a54ff 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu-preview.tsx @@ -8,25 +8,43 @@ import Typography from '@mui/material/Typography'; import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles'; -import Menu, { - CheckboxItem, - CheckboxItemIndicator, - Group, - GroupLabel, - Item, - LinkItem, - Popup, - RadioGroup, - RadioItem, - RadioItemIndicator, - Separator, - SubmenuPopup, - SubmenuRoot, - SubmenuTrigger, - Trigger, -} from '@mui/material/MenuPreview'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Unstable_Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Unstable_Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Unstable_Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Unstable_Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Unstable_Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Unstable_Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Unstable_Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; +// Local aliases for demo readability; the import lines above reflect the real +// one-component-per-subpath ergonomics (no Base UI-style short aliases). +const Menu = Unstable_Menu2; +const CheckboxItem = Unstable_Menu2CheckboxItem; +const CheckboxItemIndicator = Unstable_Menu2CheckboxItemIndicator; +const Group = Unstable_Menu2Group; +const GroupLabel = Unstable_Menu2GroupLabel; +const Item = Unstable_Menu2Item; +const LinkItem = Unstable_Menu2LinkItem; +const Popup = Unstable_Menu2Popup; +const RadioGroup = Unstable_Menu2RadioGroup; +const RadioItem = Unstable_Menu2RadioItem; +const RadioItemIndicator = Unstable_Menu2RadioItemIndicator; +const Separator = Unstable_Menu2Separator; +const SubmenuPopup = Unstable_Menu2SubmenuPopup; +const SubmenuRoot = Unstable_Menu2SubmenuRoot; +const SubmenuTrigger = Unstable_Menu2SubmenuTrigger; +const Trigger = Unstable_Menu2Trigger; + interface MenuSettings { modal: boolean; disabled: boolean; @@ -232,11 +250,7 @@ function DisabledTooltip(props: { title: string; children: React.ReactElement }) ); } -function MenuPreviewWithPreviewCardsDemo({ - submenusOpenOnHover, -}: { - submenusOpenOnHover: boolean; -}) { +function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: boolean }) { const previewCardIdPrefix = React.useId(); const [activeItemId, setActiveItemId] = React.useState(null); const [anchorEl, setAnchorEl] = React.useState(null); @@ -317,7 +331,7 @@ function MenuPreviewWithPreviewCardsDemo({ ); } -function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { +function Menu2Demo({ settings }: { settings: MenuSettings }) { const handleItemClick = React.useCallback((event: React.MouseEvent) => { // eslint-disable-next-line no-console console.log(`${event.currentTarget.textContent} clicked`); @@ -436,7 +450,7 @@ function MenuPreviewDemo({ settings }: { settings: MenuSettings }) { ); } -function MenuPreviewWithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: boolean }) { +function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: boolean }) { const { direction } = useTheme(); const submenuTriggerTooltipProps = React.useMemo>( () => ({ @@ -532,7 +546,7 @@ function MenuPreviewWithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHo ); } -function MenuPreviewContextMenuRecipe() { +function Menu2ContextMenuRecipe() { const [anchor, setAnchor] = React.useState | null>(null); const open = anchor !== null; @@ -605,7 +619,7 @@ function MenuPreviewContextMenuRecipe() { ); } -export default function MenuPreviewExperiment() { +export default function Menu2Experiment() { const [settings, setSettings] = React.useState(defaultSettings); const handleCheckboxChange = (setting: keyof MenuSettings) => { @@ -620,7 +634,7 @@ export default function MenuPreviewExperiment() { return ( - + @@ -656,22 +670,24 @@ export default function MenuPreviewExperiment() {

Fully-featured menu with submenus, links, radio groups, and checkbox items.

- +
- +

Material UI Tooltip integrated with every menu item.

- +
- +

Material UI Popover used as a PreviewCard-style menu item help card.

- +
- -

Right-click the text to open a cursor-positioned MenuPreview popup.

- + +

Right-click the text to open a cursor-positioned Unstable_Menu2 popup.

+
Base UI Menu API
diff --git a/docs/pages/experiments/menu-rfc.tsx b/docs/pages/experiments/menu-rfc.tsx index 319c15c7ba654e..ae8ab2afbeac52 100644 --- a/docs/pages/experiments/menu-rfc.tsx +++ b/docs/pages/experiments/menu-rfc.tsx @@ -13,25 +13,43 @@ import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRigh import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; import { ThemeProvider, createTheme, type SxProps, type Theme } from '@mui/material/styles'; import { DirectionProvider } from '@base-ui/react/direction-provider'; -import Menu, { - CheckboxItem, - CheckboxItemIndicator, - Group, - GroupLabel, - Item, - LinkItem, - Popup, - RadioGroup, - RadioItem, - RadioItemIndicator, - Separator, - SubmenuPopup, - SubmenuRoot, - SubmenuTrigger, - Trigger, -} from '@mui/material/MenuPreview'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Unstable_Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Unstable_Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Unstable_Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Unstable_Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Unstable_Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Unstable_Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Unstable_Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; +// Local aliases for demo readability; the import lines above reflect the real +// one-component-per-subpath ergonomics (no Base UI-style short aliases). +const Menu = Unstable_Menu2; +const CheckboxItem = Unstable_Menu2CheckboxItem; +const CheckboxItemIndicator = Unstable_Menu2CheckboxItemIndicator; +const Group = Unstable_Menu2Group; +const GroupLabel = Unstable_Menu2GroupLabel; +const Item = Unstable_Menu2Item; +const LinkItem = Unstable_Menu2LinkItem; +const Popup = Unstable_Menu2Popup; +const RadioGroup = Unstable_Menu2RadioGroup; +const RadioItem = Unstable_Menu2RadioItem; +const RadioItemIndicator = Unstable_Menu2RadioItemIndicator; +const Separator = Unstable_Menu2Separator; +const SubmenuPopup = Unstable_Menu2SubmenuPopup; +const SubmenuRoot = Unstable_Menu2SubmenuRoot; +const SubmenuTrigger = Unstable_Menu2SubmenuTrigger; +const Trigger = Unstable_Menu2Trigger; + type MenuProps = React.ComponentProps; type PopupProps = React.ComponentProps; type PopupSide = NonNullable; @@ -531,7 +549,7 @@ export default function MenuRfcExperiment() { Companion experiment for the Menu successor RFC. Every knob maps to a prop or an RFC open question. See also the{' '} - MenuPreview experiment for + Unstable_Menu2 experiment for Tooltip, PreviewCard, and ContextMenu recipes. diff --git a/packages/mui-material/src/MenuPreview/index.d.ts b/packages/mui-material/src/MenuPreview/index.d.ts deleted file mode 100644 index 1ccbd2605879ee..00000000000000 --- a/packages/mui-material/src/MenuPreview/index.d.ts +++ /dev/null @@ -1,64 +0,0 @@ -export { default } from './MenuPreview'; -export { default as MenuPreview } from './MenuPreview'; -export { default as Root } from './MenuPreview'; -export * from './MenuPreview'; - -export { default as Trigger } from '../MenuPreviewTrigger'; -export { default as MenuPreviewTrigger } from '../MenuPreviewTrigger'; -export * from '../MenuPreviewTrigger'; - -export { default as Popup } from '../MenuPreviewPopup'; -export { default as MenuPreviewPopup } from '../MenuPreviewPopup'; -export * from '../MenuPreviewPopup'; - -export { default as SubmenuPopup } from '../MenuPreviewSubmenuPopup'; -export { default as MenuPreviewSubmenuPopup } from '../MenuPreviewSubmenuPopup'; -export * from '../MenuPreviewSubmenuPopup'; - -export { default as Item } from '../MenuPreviewItem'; -export { default as MenuPreviewItem } from '../MenuPreviewItem'; -export * from '../MenuPreviewItem'; - -export { default as LinkItem } from '../MenuPreviewLinkItem'; -export { default as MenuPreviewLinkItem } from '../MenuPreviewLinkItem'; -export * from '../MenuPreviewLinkItem'; - -export { default as CheckboxItem } from '../MenuPreviewCheckboxItem'; -export { default as MenuPreviewCheckboxItem } from '../MenuPreviewCheckboxItem'; -export * from '../MenuPreviewCheckboxItem'; - -export { default as CheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; -export { default as MenuPreviewCheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; -export * from '../MenuPreviewCheckboxItemIndicator'; - -export { default as RadioGroup } from '../MenuPreviewRadioGroup'; -export { default as MenuPreviewRadioGroup } from '../MenuPreviewRadioGroup'; -export * from '../MenuPreviewRadioGroup'; - -export { default as RadioItem } from '../MenuPreviewRadioItem'; -export { default as MenuPreviewRadioItem } from '../MenuPreviewRadioItem'; -export * from '../MenuPreviewRadioItem'; - -export { default as RadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; -export { default as MenuPreviewRadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; -export * from '../MenuPreviewRadioItemIndicator'; - -export { default as Group } from '../MenuPreviewGroup'; -export { default as MenuPreviewGroup } from '../MenuPreviewGroup'; -export * from '../MenuPreviewGroup'; - -export { default as GroupLabel } from '../MenuPreviewGroupLabel'; -export { default as MenuPreviewGroupLabel } from '../MenuPreviewGroupLabel'; -export * from '../MenuPreviewGroupLabel'; - -export { default as Separator } from '../MenuPreviewSeparator'; -export { default as MenuPreviewSeparator } from '../MenuPreviewSeparator'; -export * from '../MenuPreviewSeparator'; - -export { default as SubmenuRoot } from '../MenuPreviewSubmenuRoot'; -export { default as MenuPreviewSubmenuRoot } from '../MenuPreviewSubmenuRoot'; -export * from '../MenuPreviewSubmenuRoot'; - -export { default as SubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; -export { default as MenuPreviewSubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; -export * from '../MenuPreviewSubmenuTrigger'; diff --git a/packages/mui-material/src/MenuPreview/index.js b/packages/mui-material/src/MenuPreview/index.js deleted file mode 100644 index 1ccbd2605879ee..00000000000000 --- a/packages/mui-material/src/MenuPreview/index.js +++ /dev/null @@ -1,64 +0,0 @@ -export { default } from './MenuPreview'; -export { default as MenuPreview } from './MenuPreview'; -export { default as Root } from './MenuPreview'; -export * from './MenuPreview'; - -export { default as Trigger } from '../MenuPreviewTrigger'; -export { default as MenuPreviewTrigger } from '../MenuPreviewTrigger'; -export * from '../MenuPreviewTrigger'; - -export { default as Popup } from '../MenuPreviewPopup'; -export { default as MenuPreviewPopup } from '../MenuPreviewPopup'; -export * from '../MenuPreviewPopup'; - -export { default as SubmenuPopup } from '../MenuPreviewSubmenuPopup'; -export { default as MenuPreviewSubmenuPopup } from '../MenuPreviewSubmenuPopup'; -export * from '../MenuPreviewSubmenuPopup'; - -export { default as Item } from '../MenuPreviewItem'; -export { default as MenuPreviewItem } from '../MenuPreviewItem'; -export * from '../MenuPreviewItem'; - -export { default as LinkItem } from '../MenuPreviewLinkItem'; -export { default as MenuPreviewLinkItem } from '../MenuPreviewLinkItem'; -export * from '../MenuPreviewLinkItem'; - -export { default as CheckboxItem } from '../MenuPreviewCheckboxItem'; -export { default as MenuPreviewCheckboxItem } from '../MenuPreviewCheckboxItem'; -export * from '../MenuPreviewCheckboxItem'; - -export { default as CheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; -export { default as MenuPreviewCheckboxItemIndicator } from '../MenuPreviewCheckboxItemIndicator'; -export * from '../MenuPreviewCheckboxItemIndicator'; - -export { default as RadioGroup } from '../MenuPreviewRadioGroup'; -export { default as MenuPreviewRadioGroup } from '../MenuPreviewRadioGroup'; -export * from '../MenuPreviewRadioGroup'; - -export { default as RadioItem } from '../MenuPreviewRadioItem'; -export { default as MenuPreviewRadioItem } from '../MenuPreviewRadioItem'; -export * from '../MenuPreviewRadioItem'; - -export { default as RadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; -export { default as MenuPreviewRadioItemIndicator } from '../MenuPreviewRadioItemIndicator'; -export * from '../MenuPreviewRadioItemIndicator'; - -export { default as Group } from '../MenuPreviewGroup'; -export { default as MenuPreviewGroup } from '../MenuPreviewGroup'; -export * from '../MenuPreviewGroup'; - -export { default as GroupLabel } from '../MenuPreviewGroupLabel'; -export { default as MenuPreviewGroupLabel } from '../MenuPreviewGroupLabel'; -export * from '../MenuPreviewGroupLabel'; - -export { default as Separator } from '../MenuPreviewSeparator'; -export { default as MenuPreviewSeparator } from '../MenuPreviewSeparator'; -export * from '../MenuPreviewSeparator'; - -export { default as SubmenuRoot } from '../MenuPreviewSubmenuRoot'; -export { default as MenuPreviewSubmenuRoot } from '../MenuPreviewSubmenuRoot'; -export * from '../MenuPreviewSubmenuRoot'; - -export { default as SubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; -export { default as MenuPreviewSubmenuTrigger } from '../MenuPreviewSubmenuTrigger'; -export * from '../MenuPreviewSubmenuTrigger'; diff --git a/packages/mui-material/src/MenuPreview/menuPreviewClasses.ts b/packages/mui-material/src/MenuPreview/menuPreviewClasses.ts deleted file mode 100644 index 5c32722a12a4d0..00000000000000 --- a/packages/mui-material/src/MenuPreview/menuPreviewClasses.ts +++ /dev/null @@ -1,280 +0,0 @@ -import generateUtilityClass from '@mui/utils/generateUtilityClass'; -import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; - -export interface MenuPreviewTriggerClasses { - /** Styles applied to the root element. */ - root: string; - /** State class applied to the root element if `disabled={true}`. */ - disabled: string; - /** State class applied to the root element if the menu is open. */ - open: string; -} - -export type MenuPreviewTriggerClassKey = keyof MenuPreviewTriggerClasses; - -export function getMenuPreviewTriggerUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewTrigger', slot); -} - -export const menuPreviewTriggerClasses: MenuPreviewTriggerClasses = generateUtilityClasses( - 'MuiMenuPreviewTrigger', - ['root', 'disabled', 'open'], -); - -export interface MenuPreviewPopupClasses { - /** Styles applied to the root element. */ - root: string; - /** Styles applied to the Material Paper element. */ - paper: string; - /** Styles applied to the Material List element. */ - list: string; -} - -export type MenuPreviewPopupClassKey = keyof MenuPreviewPopupClasses; - -export function getMenuPreviewPopupUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewPopup', slot); -} - -export const menuPreviewPopupClasses: MenuPreviewPopupClasses = generateUtilityClasses( - 'MuiMenuPreviewPopup', - ['root', 'paper', 'list'], -); - -export interface MenuPreviewSubmenuPopupClasses { - /** Styles applied to the root element. */ - root: string; - /** Styles applied to the Material Paper element. */ - paper: string; - /** Styles applied to the Material List element. */ - list: string; -} - -export type MenuPreviewSubmenuPopupClassKey = keyof MenuPreviewSubmenuPopupClasses; - -export function getMenuPreviewSubmenuPopupUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewSubmenuPopup', slot); -} - -export const menuPreviewSubmenuPopupClasses: MenuPreviewSubmenuPopupClasses = - generateUtilityClasses('MuiMenuPreviewSubmenuPopup', ['root', 'paper', 'list']); - -export interface MenuPreviewItemClasses { - /** Styles applied to the root element. */ - root: string; - /** State class applied to the root element if highlighted. */ - highlighted: string; - /** State class applied to the root element if `disabled={true}`. */ - disabled: string; - /** Styles applied to the root element if `dense={true}`. */ - dense: string; - /** Styles applied to the root element if `divider={true}`. */ - divider: string; - /** Styles applied to the root element unless `disableGutters={true}`. */ - gutters: string; - /** State class applied to the root element if `selected={true}`. */ - selected: string; -} - -export type MenuPreviewItemClassKey = keyof MenuPreviewItemClasses; - -export function getMenuPreviewItemUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewItem', slot); -} - -export const menuPreviewItemClasses: MenuPreviewItemClasses = generateUtilityClasses( - 'MuiMenuPreviewItem', - ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected'], -); - -export interface MenuPreviewLinkItemClasses extends MenuPreviewItemClasses {} - -export type MenuPreviewLinkItemClassKey = keyof MenuPreviewLinkItemClasses; - -export function getMenuPreviewLinkItemUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewLinkItem', slot); -} - -export const menuPreviewLinkItemClasses: MenuPreviewLinkItemClasses = generateUtilityClasses( - 'MuiMenuPreviewLinkItem', - ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected'], -); - -export interface MenuPreviewCheckboxItemClasses extends MenuPreviewItemClasses { - /** State class applied to the root element if `checked={true}`. */ - checked: string; -} - -export type MenuPreviewCheckboxItemClassKey = keyof MenuPreviewCheckboxItemClasses; - -export function getMenuPreviewCheckboxItemUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewCheckboxItem', slot); -} - -export const menuPreviewCheckboxItemClasses: MenuPreviewCheckboxItemClasses = - generateUtilityClasses('MuiMenuPreviewCheckboxItem', [ - 'root', - 'highlighted', - 'disabled', - 'dense', - 'divider', - 'gutters', - 'selected', - 'checked', - ]); - -export interface MenuPreviewCheckboxItemIndicatorClasses { - /** Styles applied to the root element. */ - root: string; - /** State class applied to the root element if `checked={true}`. */ - checked: string; - /** State class applied to the root element if `disabled={true}`. */ - disabled: string; - /** State class applied to the root element if highlighted. */ - highlighted: string; -} - -export type MenuPreviewCheckboxItemIndicatorClassKey = - keyof MenuPreviewCheckboxItemIndicatorClasses; - -export function getMenuPreviewCheckboxItemIndicatorUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewCheckboxItemIndicator', slot); -} - -export const menuPreviewCheckboxItemIndicatorClasses: MenuPreviewCheckboxItemIndicatorClasses = - generateUtilityClasses('MuiMenuPreviewCheckboxItemIndicator', [ - 'root', - 'checked', - 'disabled', - 'highlighted', - ]); - -export interface MenuPreviewRadioGroupClasses { - /** Styles applied to the root element. */ - root: string; - /** State class applied to the root element if `disabled={true}`. */ - disabled: string; -} - -export type MenuPreviewRadioGroupClassKey = keyof MenuPreviewRadioGroupClasses; - -export function getMenuPreviewRadioGroupUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewRadioGroup', slot); -} - -export const menuPreviewRadioGroupClasses: MenuPreviewRadioGroupClasses = generateUtilityClasses( - 'MuiMenuPreviewRadioGroup', - ['root', 'disabled'], -); - -export interface MenuPreviewRadioItemClasses extends MenuPreviewItemClasses { - /** State class applied to the root element if `checked={true}`. */ - checked: string; -} - -export type MenuPreviewRadioItemClassKey = keyof MenuPreviewRadioItemClasses; - -export function getMenuPreviewRadioItemUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewRadioItem', slot); -} - -export const menuPreviewRadioItemClasses: MenuPreviewRadioItemClasses = generateUtilityClasses( - 'MuiMenuPreviewRadioItem', - ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'checked'], -); - -export interface MenuPreviewRadioItemIndicatorClasses { - /** Styles applied to the root element. */ - root: string; - /** State class applied to the root element if `checked={true}`. */ - checked: string; - /** State class applied to the root element if `disabled={true}`. */ - disabled: string; - /** State class applied to the root element if highlighted. */ - highlighted: string; -} - -export type MenuPreviewRadioItemIndicatorClassKey = keyof MenuPreviewRadioItemIndicatorClasses; - -export function getMenuPreviewRadioItemIndicatorUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewRadioItemIndicator', slot); -} - -export const menuPreviewRadioItemIndicatorClasses: MenuPreviewRadioItemIndicatorClasses = - generateUtilityClasses('MuiMenuPreviewRadioItemIndicator', [ - 'root', - 'checked', - 'disabled', - 'highlighted', - ]); - -export interface MenuPreviewGroupClasses { - /** Styles applied to the root element. */ - root: string; -} - -export type MenuPreviewGroupClassKey = keyof MenuPreviewGroupClasses; - -export function getMenuPreviewGroupUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewGroup', slot); -} - -export const menuPreviewGroupClasses: MenuPreviewGroupClasses = generateUtilityClasses( - 'MuiMenuPreviewGroup', - ['root'], -); - -export interface MenuPreviewGroupLabelClasses { - /** Styles applied to the root element. */ - root: string; -} - -export type MenuPreviewGroupLabelClassKey = keyof MenuPreviewGroupLabelClasses; - -export function getMenuPreviewGroupLabelUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewGroupLabel', slot); -} - -export const menuPreviewGroupLabelClasses: MenuPreviewGroupLabelClasses = generateUtilityClasses( - 'MuiMenuPreviewGroupLabel', - ['root'], -); - -export interface MenuPreviewSeparatorClasses { - /** Styles applied to the root element. */ - root: string; -} - -export type MenuPreviewSeparatorClassKey = keyof MenuPreviewSeparatorClasses; - -export function getMenuPreviewSeparatorUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewSeparator', slot); -} - -export const menuPreviewSeparatorClasses: MenuPreviewSeparatorClasses = generateUtilityClasses( - 'MuiMenuPreviewSeparator', - ['root'], -); - -export interface MenuPreviewSubmenuTriggerClasses extends MenuPreviewItemClasses { - /** State class applied to the root element if the submenu is open. */ - open: string; -} - -export type MenuPreviewSubmenuTriggerClassKey = keyof MenuPreviewSubmenuTriggerClasses; - -export function getMenuPreviewSubmenuTriggerUtilityClass(slot: string): string { - return generateUtilityClass('MuiMenuPreviewSubmenuTrigger', slot); -} - -export const menuPreviewSubmenuTriggerClasses: MenuPreviewSubmenuTriggerClasses = - generateUtilityClasses('MuiMenuPreviewSubmenuTrigger', [ - 'root', - 'highlighted', - 'disabled', - 'dense', - 'divider', - 'gutters', - 'selected', - 'open', - ]); diff --git a/packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts b/packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts deleted file mode 100644 index 4372d2838e90bb..00000000000000 --- a/packages/mui-material/src/MenuPreviewCheckboxItem/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewCheckboxItem'; -export * from './MenuPreviewCheckboxItem'; -export { - menuPreviewCheckboxItemClasses, - getMenuPreviewCheckboxItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewCheckboxItemClasses, - MenuPreviewCheckboxItemClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItem/index.js b/packages/mui-material/src/MenuPreviewCheckboxItem/index.js deleted file mode 100644 index d453dbcbd0a5ab..00000000000000 --- a/packages/mui-material/src/MenuPreviewCheckboxItem/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewCheckboxItem'; -export * from './MenuPreviewCheckboxItem'; -export { - menuPreviewCheckboxItemClasses, - getMenuPreviewCheckboxItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts deleted file mode 100644 index 4e0c311cd37242..00000000000000 --- a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewCheckboxItemIndicator'; -export * from './MenuPreviewCheckboxItemIndicator'; -export { - menuPreviewCheckboxItemIndicatorClasses, - getMenuPreviewCheckboxItemIndicatorUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewCheckboxItemIndicatorClasses, - MenuPreviewCheckboxItemIndicatorClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js b/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js deleted file mode 100644 index fb24b5bf809efc..00000000000000 --- a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewCheckboxItemIndicator'; -export * from './MenuPreviewCheckboxItemIndicator'; -export { - menuPreviewCheckboxItemIndicatorClasses, - getMenuPreviewCheckboxItemIndicatorUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroup/index.d.ts b/packages/mui-material/src/MenuPreviewGroup/index.d.ts deleted file mode 100644 index 73a72486bb167f..00000000000000 --- a/packages/mui-material/src/MenuPreviewGroup/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewGroup'; -export * from './MenuPreviewGroup'; -export { - menuPreviewGroupClasses, - getMenuPreviewGroupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewGroupClasses, - MenuPreviewGroupClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroup/index.js b/packages/mui-material/src/MenuPreviewGroup/index.js deleted file mode 100644 index 49e6742bc562e4..00000000000000 --- a/packages/mui-material/src/MenuPreviewGroup/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewGroup'; -export * from './MenuPreviewGroup'; -export { - menuPreviewGroupClasses, - getMenuPreviewGroupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts b/packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts deleted file mode 100644 index 6b9ddf97eea6cf..00000000000000 --- a/packages/mui-material/src/MenuPreviewGroupLabel/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewGroupLabel'; -export * from './MenuPreviewGroupLabel'; -export { - menuPreviewGroupLabelClasses, - getMenuPreviewGroupLabelUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewGroupLabelClasses, - MenuPreviewGroupLabelClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewGroupLabel/index.js b/packages/mui-material/src/MenuPreviewGroupLabel/index.js deleted file mode 100644 index 30bf6f4677fed1..00000000000000 --- a/packages/mui-material/src/MenuPreviewGroupLabel/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewGroupLabel'; -export * from './MenuPreviewGroupLabel'; -export { - menuPreviewGroupLabelClasses, - getMenuPreviewGroupLabelUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewItem/index.d.ts b/packages/mui-material/src/MenuPreviewItem/index.d.ts deleted file mode 100644 index febb5776db5b46..00000000000000 --- a/packages/mui-material/src/MenuPreviewItem/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewItem'; -export * from './MenuPreviewItem'; -export { - menuPreviewItemClasses, - getMenuPreviewItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewItemClasses, - MenuPreviewItemClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewItem/index.js b/packages/mui-material/src/MenuPreviewItem/index.js deleted file mode 100644 index f2296b0ea1f44f..00000000000000 --- a/packages/mui-material/src/MenuPreviewItem/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewItem'; -export * from './MenuPreviewItem'; -export { - menuPreviewItemClasses, - getMenuPreviewItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewLinkItem/index.d.ts b/packages/mui-material/src/MenuPreviewLinkItem/index.d.ts deleted file mode 100644 index d67220bdee8596..00000000000000 --- a/packages/mui-material/src/MenuPreviewLinkItem/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewLinkItem'; -export * from './MenuPreviewLinkItem'; -export { - menuPreviewLinkItemClasses, - getMenuPreviewLinkItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewLinkItemClasses, - MenuPreviewLinkItemClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewLinkItem/index.js b/packages/mui-material/src/MenuPreviewLinkItem/index.js deleted file mode 100644 index 7a69a871b02fdc..00000000000000 --- a/packages/mui-material/src/MenuPreviewLinkItem/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewLinkItem'; -export * from './MenuPreviewLinkItem'; -export { - menuPreviewLinkItemClasses, - getMenuPreviewLinkItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewPopup/index.d.ts b/packages/mui-material/src/MenuPreviewPopup/index.d.ts deleted file mode 100644 index 82ce5ac8e7311f..00000000000000 --- a/packages/mui-material/src/MenuPreviewPopup/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewPopup'; -export * from './MenuPreviewPopup'; -export { - menuPreviewPopupClasses, - getMenuPreviewPopupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewPopupClasses, - MenuPreviewPopupClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewPopup/index.js b/packages/mui-material/src/MenuPreviewPopup/index.js deleted file mode 100644 index 25a0b859273605..00000000000000 --- a/packages/mui-material/src/MenuPreviewPopup/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewPopup'; -export * from './MenuPreviewPopup'; -export { - menuPreviewPopupClasses, - getMenuPreviewPopupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts b/packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts deleted file mode 100644 index f4e14f49d9bc87..00000000000000 --- a/packages/mui-material/src/MenuPreviewRadioGroup/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewRadioGroup'; -export * from './MenuPreviewRadioGroup'; -export { - menuPreviewRadioGroupClasses, - getMenuPreviewRadioGroupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewRadioGroupClasses, - MenuPreviewRadioGroupClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioGroup/index.js b/packages/mui-material/src/MenuPreviewRadioGroup/index.js deleted file mode 100644 index a5e718613154f1..00000000000000 --- a/packages/mui-material/src/MenuPreviewRadioGroup/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewRadioGroup'; -export * from './MenuPreviewRadioGroup'; -export { - menuPreviewRadioGroupClasses, - getMenuPreviewRadioGroupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItem/index.d.ts b/packages/mui-material/src/MenuPreviewRadioItem/index.d.ts deleted file mode 100644 index 05366886438934..00000000000000 --- a/packages/mui-material/src/MenuPreviewRadioItem/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewRadioItem'; -export * from './MenuPreviewRadioItem'; -export { - menuPreviewRadioItemClasses, - getMenuPreviewRadioItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewRadioItemClasses, - MenuPreviewRadioItemClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItem/index.js b/packages/mui-material/src/MenuPreviewRadioItem/index.js deleted file mode 100644 index fa1950c32d8e36..00000000000000 --- a/packages/mui-material/src/MenuPreviewRadioItem/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewRadioItem'; -export * from './MenuPreviewRadioItem'; -export { - menuPreviewRadioItemClasses, - getMenuPreviewRadioItemUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts b/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts deleted file mode 100644 index 4fa829e8dedd1c..00000000000000 --- a/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewRadioItemIndicator'; -export * from './MenuPreviewRadioItemIndicator'; -export { - menuPreviewRadioItemIndicatorClasses, - getMenuPreviewRadioItemIndicatorUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewRadioItemIndicatorClasses, - MenuPreviewRadioItemIndicatorClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js b/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js deleted file mode 100644 index 32b270dcd36da5..00000000000000 --- a/packages/mui-material/src/MenuPreviewRadioItemIndicator/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewRadioItemIndicator'; -export * from './MenuPreviewRadioItemIndicator'; -export { - menuPreviewRadioItemIndicatorClasses, - getMenuPreviewRadioItemIndicatorUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSeparator/index.d.ts b/packages/mui-material/src/MenuPreviewSeparator/index.d.ts deleted file mode 100644 index d39696b5ea6141..00000000000000 --- a/packages/mui-material/src/MenuPreviewSeparator/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewSeparator'; -export * from './MenuPreviewSeparator'; -export { - menuPreviewSeparatorClasses, - getMenuPreviewSeparatorUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewSeparatorClasses, - MenuPreviewSeparatorClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSeparator/index.js b/packages/mui-material/src/MenuPreviewSeparator/index.js deleted file mode 100644 index b610767aee6389..00000000000000 --- a/packages/mui-material/src/MenuPreviewSeparator/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewSeparator'; -export * from './MenuPreviewSeparator'; -export { - menuPreviewSeparatorClasses, - getMenuPreviewSeparatorUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts b/packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts deleted file mode 100644 index ee1f99c4e671e0..00000000000000 --- a/packages/mui-material/src/MenuPreviewSubmenuPopup/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewSubmenuPopup'; -export * from './MenuPreviewSubmenuPopup'; -export { - menuPreviewSubmenuPopupClasses, - getMenuPreviewSubmenuPopupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewSubmenuPopupClasses, - MenuPreviewSubmenuPopupClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuPopup/index.js b/packages/mui-material/src/MenuPreviewSubmenuPopup/index.js deleted file mode 100644 index 0941a947811103..00000000000000 --- a/packages/mui-material/src/MenuPreviewSubmenuPopup/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewSubmenuPopup'; -export * from './MenuPreviewSubmenuPopup'; -export { - menuPreviewSubmenuPopupClasses, - getMenuPreviewSubmenuPopupUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts b/packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts deleted file mode 100644 index df51cc07534051..00000000000000 --- a/packages/mui-material/src/MenuPreviewSubmenuRoot/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './MenuPreviewSubmenuRoot'; -export * from './MenuPreviewSubmenuRoot'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuRoot/index.js b/packages/mui-material/src/MenuPreviewSubmenuRoot/index.js deleted file mode 100644 index df51cc07534051..00000000000000 --- a/packages/mui-material/src/MenuPreviewSubmenuRoot/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './MenuPreviewSubmenuRoot'; -export * from './MenuPreviewSubmenuRoot'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts b/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts deleted file mode 100644 index c635b6f0a1818e..00000000000000 --- a/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewSubmenuTrigger'; -export * from './MenuPreviewSubmenuTrigger'; -export { - menuPreviewSubmenuTriggerClasses, - getMenuPreviewSubmenuTriggerUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewSubmenuTriggerClasses, - MenuPreviewSubmenuTriggerClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js b/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js deleted file mode 100644 index 21cf2aafd9dc18..00000000000000 --- a/packages/mui-material/src/MenuPreviewSubmenuTrigger/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewSubmenuTrigger'; -export * from './MenuPreviewSubmenuTrigger'; -export { - menuPreviewSubmenuTriggerClasses, - getMenuPreviewSubmenuTriggerUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewTrigger/index.d.ts b/packages/mui-material/src/MenuPreviewTrigger/index.d.ts deleted file mode 100644 index ea8d0c4a9ab413..00000000000000 --- a/packages/mui-material/src/MenuPreviewTrigger/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './MenuPreviewTrigger'; -export * from './MenuPreviewTrigger'; -export { - menuPreviewTriggerClasses, - getMenuPreviewTriggerUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; -export type { - MenuPreviewTriggerClasses, - MenuPreviewTriggerClassKey, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreviewTrigger/index.js b/packages/mui-material/src/MenuPreviewTrigger/index.js deleted file mode 100644 index 0b40dbfdcb13dd..00000000000000 --- a/packages/mui-material/src/MenuPreviewTrigger/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './MenuPreviewTrigger'; -export * from './MenuPreviewTrigger'; -export { - menuPreviewTriggerClasses, - getMenuPreviewTriggerUtilityClass, -} from '../MenuPreview/menuPreviewClasses'; diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx similarity index 74% rename from packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx index 6ca3ce1f3d2a11..d312b26a197e23 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.spec.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -1,27 +1,26 @@ import * as React from 'react'; import { expectType } from '@mui/types'; -import Menu, { - CheckboxItem, - CheckboxItemIndicator, - Group, - GroupLabel, - Item, - LinkItem, - Popup, - RadioGroup, - RadioItem, - RadioItemIndicator, - Separator, - SubmenuPopup, - SubmenuRoot, - SubmenuTrigger, - Trigger, -} from '@mui/material/MenuPreview'; +import Menu from '@mui/material/Unstable_Menu2'; +import CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Group from '@mui/material/Unstable_Menu2Group'; +import GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Item from '@mui/material/Unstable_Menu2Item'; +import LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Popup from '@mui/material/Unstable_Menu2Popup'; +import RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Separator from '@mui/material/Unstable_Menu2Separator'; +import SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Trigger from '@mui/material/Unstable_Menu2Trigger'; import { createTheme } from '@mui/material/styles'; -// @ts-expect-error MenuPreview is intentionally not exported from the root barrel for this POC. -import { MenuPreview as RootBarrelMenuPreview } from '@mui/material'; +// @ts-expect-error Menu2 is intentionally not exported from the root barrel for this POC. +import { Menu2 as RootBarrelMenu2 } from '@mui/material'; -function MenuPreviewComposition() { +function Menu2Composition() { return ( ; Options diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx similarity index 63% rename from packages/mui-material/src/MenuPreview/MenuPreview.test.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index 49e9632251ba7d..941158a051fa00 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -5,30 +5,27 @@ import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-uti import { listClasses } from '@mui/material/List'; import { paperClasses } from '@mui/material/Paper'; import Tooltip from '@mui/material/Tooltip'; -import MenuPreview, { - MenuPreviewCheckboxItem, - MenuPreviewCheckboxItemIndicator, - MenuPreviewGroup, - MenuPreviewGroupLabel, - MenuPreviewItem, - MenuPreviewLinkItem, - MenuPreviewPopup, - MenuPreviewRadioGroup, - MenuPreviewRadioItem, - MenuPreviewRadioItemIndicator, - MenuPreviewSeparator, - MenuPreviewSubmenuPopup, - MenuPreviewSubmenuRoot, - MenuPreviewSubmenuTrigger, - MenuPreviewTrigger, - menuPreviewCheckboxItemClasses, - menuPreviewItemClasses, - menuPreviewPopupClasses, - menuPreviewTriggerClasses, -} from '@mui/material/MenuPreview'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem, { + menu2CheckboxItemClasses, +} from '@mui/material/Unstable_Menu2CheckboxItem'; +import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup, { menu2PopupClasses } from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger, { menu2TriggerClasses } from '@mui/material/Unstable_Menu2Trigger'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -describe('', () => { +describe('', () => { const { render } = createRenderer(); type User = ReturnType['user']; @@ -46,41 +43,39 @@ describe('', () => { it('opens from the trigger and keeps Menu.Popup as the semantic menu root', async () => { const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); const trigger = screen.getByRole('button', { name: 'Options' }); - expect(trigger).to.have.class(menuPreviewTriggerClasses.root); + expect(trigger).to.have.class(menu2TriggerClasses.root); await user.click(trigger); const menu = await screen.findByRole('menu'); - expect(menu).to.have.class(menuPreviewPopupClasses.root); - expect(screen.getByTestId('paper')).to.have.class(menuPreviewPopupClasses.paper); + expect(menu).to.have.class(menu2PopupClasses.root); + expect(screen.getByTestId('paper')).to.have.class(menu2PopupClasses.paper); - const list = screen.getByTestId('paper').querySelector(`.${menuPreviewPopupClasses.list}`); + const list = screen.getByTestId('paper').querySelector(`.${menu2PopupClasses.list}`); expect(list).not.to.equal(null); expect(list!.tagName).to.equal('DIV'); expect(list!).to.have.class(listClasses.padding); - expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( - menuPreviewItemClasses.root, - ); + expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class(menu2ItemClasses.root); }); it('does not render the trigger as a link when href is passed by a JS caller', async () => { const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); const trigger = screen.getByRole('button', { name: 'Options' }); @@ -95,12 +90,12 @@ describe('', () => { it('supports component props, slotProps, classes, styleOverrides, and variants', async () => { const theme = createTheme({ components: { - MuiMenuPreviewTrigger: { + MuiMenu2Trigger: { defaultProps: { variant: 'outlined', }, }, - MuiMenuPreviewPopup: { + MuiMenu2Popup: { styleOverrides: { paper: { minWidth: 128, @@ -110,12 +105,12 @@ describe('', () => { { props: { align: 'start' }, style: { - '--MenuPreviewPopup-variant': '"applied"', + '--Menu2Popup-variant': '"applied"', }, }, ], }, - MuiMenuPreviewItem: { + MuiMenu2Item: { variants: [ { props: { selected: true }, @@ -126,37 +121,37 @@ describe('', () => { { props: { disabled: true }, style: { - '--MenuPreviewItem-disabledVariant': '"applied"', + '--Menu2Item-disabledVariant': '"applied"', }, }, ], }, - MuiMenuPreviewCheckboxItem: { + MuiMenu2CheckboxItem: { variants: [ { props: { checked: true }, style: { - '--MenuPreviewCheckboxItem-checkedVariant': '"applied"', + '--Menu2CheckboxItem-checkedVariant': '"applied"', }, }, ], }, - MuiMenuPreviewRadioItem: { + MuiMenu2RadioItem: { variants: [ { props: { value: 'small' }, style: { - '--MenuPreviewRadioItem-valueVariant': '"applied"', + '--Menu2RadioItem-valueVariant': '"applied"', }, }, ], }, - MuiMenuPreviewLinkItem: { + MuiMenu2LinkItem: { variants: [ { props: { href: '/profile' }, style: { - '--MenuPreviewLinkItem-hrefVariant': '"applied"', + '--Menu2LinkItem-hrefVariant': '"applied"', }, }, ], @@ -166,23 +161,23 @@ describe('', () => { const { user } = render( - - Options - + Options + - + Profile - - Disabled profile - Checked profile - - Small - - Link profile - - + + Disabled profile + Checked profile + + Small + + Link profile + + , ); @@ -191,47 +186,45 @@ describe('', () => { expect(screen.getByRole('button', { name: 'Options' })).to.have.class('custom-trigger'); expect(await screen.findByTestId('list')).to.have.class('custom-list'); expect( - window - .getComputedStyle(screen.getByRole('menu')) - .getPropertyValue('--MenuPreviewPopup-variant'), + window.getComputedStyle(screen.getByRole('menu')).getPropertyValue('--Menu2Popup-variant'), ).to.equal('"applied"'); expect(await screen.findByRole('menuitem', { name: 'Profile' })).to.have.class('custom-item'); expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( - menuPreviewItemClasses.selected, + menu2ItemClasses.selected, ); expect(screen.getByRole('menuitem', { name: 'Disabled profile' })).to.have.class( - menuPreviewItemClasses.disabled, + menu2ItemClasses.disabled, ); expect( window .getComputedStyle(screen.getByRole('menuitem', { name: 'Disabled profile' })) - .getPropertyValue('--MenuPreviewItem-disabledVariant'), + .getPropertyValue('--Menu2Item-disabledVariant'), ).to.equal('"applied"'); expect(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })).to.have.class( - menuPreviewCheckboxItemClasses.checked, + menu2CheckboxItemClasses.checked, ); expect( window .getComputedStyle(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })) - .getPropertyValue('--MenuPreviewCheckboxItem-checkedVariant'), + .getPropertyValue('--Menu2CheckboxItem-checkedVariant'), ).to.equal('"applied"'); expect( window .getComputedStyle(screen.getByRole('menuitemradio', { name: 'Small' })) - .getPropertyValue('--MenuPreviewRadioItem-valueVariant'), + .getPropertyValue('--Menu2RadioItem-valueVariant'), ).to.equal('"applied"'); expect( window .getComputedStyle(screen.getByRole('menuitem', { name: 'Link profile' })) - .getPropertyValue('--MenuPreviewLinkItem-hrefVariant'), + .getPropertyValue('--Menu2LinkItem-hrefVariant'), ).to.equal('"applied"'); }); it('composes popup class names', async () => { const { user } = render( - - Options - + Options + ', () => { }, }} > - Profile - - , + Profile + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -249,20 +242,17 @@ describe('', () => { const menu = await screen.findByRole('menu'); expect(menu).to.have.class('popup-open'); expect(menu).to.have.class('popup-side-bottom'); - expect(menu).to.have.class(menuPreviewPopupClasses.root); + expect(menu).to.have.class(menu2PopupClasses.root); }); it('does not pass ownerState to host popup slots', async () => { const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -275,30 +265,30 @@ describe('', () => { try { const { user } = render( - - Options - - + + Options + + Native item - - + + Native checkbox - - - + + + Native radio - - - - + + + + Native submenu trigger - - - Nested - - - - , + + + Nested + + + + , ); const trigger = screen.getByRole('button', { name: 'Options' }); @@ -348,24 +338,24 @@ describe('', () => { try { const { user } = render( - - + + Options - - - + + + Custom native item - - - + + + Custom native submenu trigger - - - Nested - - - - , + + + Nested + + + + , ); const trigger = screen.getByRole('button', { name: 'Options' }); @@ -391,9 +381,9 @@ describe('', () => { it('does not pass internal props to host paper and list slots', async () => { const { user } = render( - - Options - + Options + ', () => { }, }} > - Profile - - , + Profile + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -435,12 +425,12 @@ describe('', () => { it('defaults the popup surface elevation to 8', async () => { const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -450,12 +440,12 @@ describe('', () => { it('forwards a custom elevation to the popup surface', async () => { const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -472,12 +462,12 @@ describe('', () => { }); const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -490,12 +480,12 @@ describe('', () => { it('does not open when the root is disabled', async () => { render( - - Options - - Profile - - , + + Options + + Profile + + , ); expect(screen.getByRole('button', { name: 'Options' })).to.have.attribute('disabled'); @@ -504,12 +494,12 @@ describe('', () => { it('supports defaultOpen', () => { render( - - Options - - Profile - - , + + Options + + Profile + + , ); expect(screen.getByRole('menu')).not.to.equal(null); @@ -517,12 +507,12 @@ describe('', () => { it('supports keepMounted', () => { render( - - Options - - Profile - - , + + Options + + Profile + + , ); expect(screen.getByText('Profile')).not.to.equal(null); @@ -539,12 +529,12 @@ describe('', () => { - - Options - - Profile - - + + Options + + Profile + + , ); @@ -560,12 +550,12 @@ describe('', () => { const { user } = render( - - Options - - Profile - - + + Options + + Profile + + , ); @@ -590,12 +580,12 @@ describe('', () => { it('supports touch trigger interactions', async () => { const { user } = render( - - Options - - Profile - - , + + Options + + Profile + + , ); await user.pointer({ @@ -609,18 +599,18 @@ describe('', () => { it('supports modal backdrop behavior', async () => { const { user } = render( - - Modal menu - - Profile - - - - Non-modal menu - - Settings - - + + Modal menu + + Profile + + + + Non-modal menu + + Settings + + , ); @@ -644,12 +634,12 @@ describe('', () => { it('opens in an RTL tree', async () => { const { user } = render(
- - Options - - Profile - - + + Options + + Profile + +
, ); @@ -661,17 +651,17 @@ describe('', () => { it.skipIf(isJsdom())('applies Base UI positioning attributes in the browser', async () => { const { user } = render(
- - Options - + Options + - Profile - - + Profile + +
, ); @@ -696,25 +686,25 @@ describe('', () => { }); const { user } = render( - - Options - - - + + Options + + + Show hidden files - - - - + + + + Small - - - + + + Large - - - - , + + + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -725,7 +715,7 @@ describe('', () => { await user.click(checkbox); expect(checkbox).to.have.attribute('aria-checked', 'true'); - expect(checkbox).to.have.class(menuPreviewCheckboxItemClasses.checked); + expect(checkbox).to.have.class(menu2CheckboxItemClasses.checked); expect(handleCheckboxChange.callCount).to.equal(1); expect(screen.getByRole('menuitemradio', { name: /small/i })).to.have.attribute( @@ -748,30 +738,30 @@ describe('', () => { it('keeps mounted unchecked indicator marks hidden', () => { render( - - Options - - - + + Options + + + Show hidden files - - - - + + + + Small - - - + + + Large - - - - , + + + + , ); const checkboxIndicator = screen.getByTestId('checkbox-indicator'); - const checkboxIcon = checkboxIndicator.querySelector('[data-mui-menu-preview-indicator-icon]'); - const checkboxMark = checkboxIndicator.querySelector('[data-mui-menu-preview-indicator-mark]'); + const checkboxIcon = checkboxIndicator.querySelector('[data-mui-menu2-indicator-icon]'); + const checkboxMark = checkboxIndicator.querySelector('[data-mui-menu2-indicator-mark]'); expect(checkboxIndicator).to.have.attribute('data-unchecked', ''); expect(window.getComputedStyle(checkboxIndicator).visibility).to.equal('visible'); expect(checkboxIcon).not.to.equal(null); @@ -780,12 +770,8 @@ describe('', () => { expect(window.getComputedStyle(checkboxMark!).visibility).to.equal('hidden'); const checkedRadioIndicator = screen.getByTestId('checked-radio-indicator'); - const checkedRadioIcon = checkedRadioIndicator.querySelector( - '[data-mui-menu-preview-indicator-icon]', - ); - const checkedRadioMark = checkedRadioIndicator.querySelector( - '[data-mui-menu-preview-indicator-mark]', - ); + const checkedRadioIcon = checkedRadioIndicator.querySelector('[data-mui-menu2-indicator-icon]'); + const checkedRadioMark = checkedRadioIndicator.querySelector('[data-mui-menu2-indicator-mark]'); expect(checkedRadioIndicator).to.have.attribute('data-checked', ''); expect(checkedRadioIcon).not.to.equal(null); expect(window.getComputedStyle(checkedRadioIcon!).visibility).to.equal('visible'); @@ -794,10 +780,10 @@ describe('', () => { const uncheckedRadioIndicator = screen.getByTestId('unchecked-radio-indicator'); const uncheckedRadioIcon = uncheckedRadioIndicator.querySelector( - '[data-mui-menu-preview-indicator-icon]', + '[data-mui-menu2-indicator-icon]', ); const uncheckedRadioMark = uncheckedRadioIndicator.querySelector( - '[data-mui-menu-preview-indicator-mark]', + '[data-mui-menu2-indicator-mark]', ); expect(uncheckedRadioIndicator).to.have.attribute('data-unchecked', ''); expect(window.getComputedStyle(uncheckedRadioIndicator).visibility).to.equal('visible'); @@ -809,22 +795,22 @@ describe('', () => { it('supports groups, labels, separators, link items, and submenus', async () => { const { user } = render( - - Options - - - Account - Profile - - - - More - - Archive - - - - , + + Options + + + Account + Profile + + + + More + + Archive + + + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); @@ -838,28 +824,28 @@ describe('', () => { it('supports Material UI Tooltip on enabled item flavors', async () => { const { user } = render( - - Options - + + Options + - New document + New document - - + + Comments - + - + - - + + Fit - + - - - , + + + , ); await expectTooltipOnHover( @@ -914,21 +900,19 @@ describe('', () => { } const { user } = render( - - Options - - + + Options + + - - View options - + View options - - Comments - - - - , + + Comments + + + + , ); const submenuTrigger = screen.getByRole('menuitem', { name: 'View options' }); @@ -946,16 +930,16 @@ describe('', () => { it('supports Material UI Tooltip on disabled items through a non-disabled wrapper', async () => { const { user } = render( - - Options - + + Options + - Import from Drive + Import from Drive - - , + + , ); expect(screen.getByRole('menuitem', { name: 'Import from Drive' })).to.have.attribute( @@ -974,17 +958,17 @@ describe('', () => { try { const { user } = render( - - Options - - - Add-ons unavailable - - Marketplace - - - - , + + Options + + + Add-ons unavailable + + Marketplace + + + + , ); await user.click(screen.getByRole('button', { name: 'Options' })); diff --git a/packages/mui-material/src/MenuPreview/MenuPreview.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx similarity index 93% rename from packages/mui-material/src/MenuPreview/MenuPreview.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2.tsx index cf860c31fae48c..0554315c46e9cf 100644 --- a/packages/mui-material/src/MenuPreview/MenuPreview.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { Menu as BaseMenu } from '@base-ui/react/menu'; import { useDefaultProps } from '../DefaultPropsProvider'; -export interface MenuPreviewProps { +export interface Menu2Props { /** * The content of the menu. */ @@ -68,18 +68,18 @@ export interface MenuPreviewProps { * * API: * - * - [MenuPreview API](https://mui.com/material-ui/api/menu-preview/) + * - [Menu2 API](https://mui.com/material-ui/api/menu-preview/) */ -function MenuPreview(props: MenuPreviewProps): React.JSX.Element { +function Menu2(props: Menu2Props): React.JSX.Element { const themedProps = useDefaultProps({ props, - name: 'MuiMenuPreview', + name: 'MuiMenu2', }); return ; } -MenuPreview.propTypes /* remove-proptypes */ = { +Menu2.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ @@ -139,4 +139,4 @@ MenuPreview.propTypes /* remove-proptypes */ = { orientation: PropTypes.oneOf(['horizontal', 'vertical']), } as any; -export default MenuPreview; +export default Menu2; diff --git a/packages/mui-material/src/Unstable_Menu2/index.d.ts b/packages/mui-material/src/Unstable_Menu2/index.d.ts new file mode 100644 index 00000000000000..23dd0c131828d4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/index.d.ts @@ -0,0 +1,3 @@ +export { default } from './Menu2'; +export { default as Unstable_Menu2 } from './Menu2'; +export * from './Menu2'; diff --git a/packages/mui-material/src/Unstable_Menu2/index.js b/packages/mui-material/src/Unstable_Menu2/index.js new file mode 100644 index 00000000000000..23dd0c131828d4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2'; +export { default as Unstable_Menu2 } from './Menu2'; +export * from './Menu2'; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts new file mode 100644 index 00000000000000..8c2ee021bb0e00 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts @@ -0,0 +1,273 @@ +import generateUtilityClass from '@mui/utils/generateUtilityClass'; +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; + +export interface Menu2TriggerClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if the menu is open. */ + open: string; +} + +export type Menu2TriggerClassKey = keyof Menu2TriggerClasses; + +export function getMenu2TriggerUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Trigger', slot); +} + +export const menu2TriggerClasses: Menu2TriggerClasses = generateUtilityClasses('MuiMenu2Trigger', [ + 'root', + 'disabled', + 'open', +]); + +export interface Menu2PopupClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the Material Paper element. */ + paper: string; + /** Styles applied to the Material List element. */ + list: string; +} + +export type Menu2PopupClassKey = keyof Menu2PopupClasses; + +export function getMenu2PopupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Popup', slot); +} + +export const menu2PopupClasses: Menu2PopupClasses = generateUtilityClasses('MuiMenu2Popup', [ + 'root', + 'paper', + 'list', +]); + +export interface Menu2SubmenuPopupClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the Material Paper element. */ + paper: string; + /** Styles applied to the Material List element. */ + list: string; +} + +export type Menu2SubmenuPopupClassKey = keyof Menu2SubmenuPopupClasses; + +export function getMenu2SubmenuPopupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2SubmenuPopup', slot); +} + +export const menu2SubmenuPopupClasses: Menu2SubmenuPopupClasses = generateUtilityClasses( + 'MuiMenu2SubmenuPopup', + ['root', 'paper', 'list'], +); + +export interface Menu2ItemClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** Styles applied to the root element if `dense={true}`. */ + dense: string; + /** Styles applied to the root element if `divider={true}`. */ + divider: string; + /** Styles applied to the root element unless `disableGutters={true}`. */ + gutters: string; + /** State class applied to the root element if `selected={true}`. */ + selected: string; +} + +export type Menu2ItemClassKey = keyof Menu2ItemClasses; + +export function getMenu2ItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Item', slot); +} + +export const menu2ItemClasses: Menu2ItemClasses = generateUtilityClasses('MuiMenu2Item', [ + 'root', + 'highlighted', + 'disabled', + 'dense', + 'divider', + 'gutters', + 'selected', +]); + +export interface Menu2LinkItemClasses extends Menu2ItemClasses {} + +export type Menu2LinkItemClassKey = keyof Menu2LinkItemClasses; + +export function getMenu2LinkItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2LinkItem', slot); +} + +export const menu2LinkItemClasses: Menu2LinkItemClasses = generateUtilityClasses( + 'MuiMenu2LinkItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected'], +); + +export interface Menu2CheckboxItemClasses extends Menu2ItemClasses { + /** State class applied to the root element if `checked={true}`. */ + checked: string; +} + +export type Menu2CheckboxItemClassKey = keyof Menu2CheckboxItemClasses; + +export function getMenu2CheckboxItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2CheckboxItem', slot); +} + +export const menu2CheckboxItemClasses: Menu2CheckboxItemClasses = generateUtilityClasses( + 'MuiMenu2CheckboxItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'checked'], +); + +export interface Menu2CheckboxItemIndicatorClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; +} + +export type Menu2CheckboxItemIndicatorClassKey = keyof Menu2CheckboxItemIndicatorClasses; + +export function getMenu2CheckboxItemIndicatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2CheckboxItemIndicator', slot); +} + +export const menu2CheckboxItemIndicatorClasses: Menu2CheckboxItemIndicatorClasses = + generateUtilityClasses('MuiMenu2CheckboxItemIndicator', [ + 'root', + 'checked', + 'disabled', + 'highlighted', + ]); + +export interface Menu2RadioGroupClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; +} + +export type Menu2RadioGroupClassKey = keyof Menu2RadioGroupClasses; + +export function getMenu2RadioGroupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2RadioGroup', slot); +} + +export const menu2RadioGroupClasses: Menu2RadioGroupClasses = generateUtilityClasses( + 'MuiMenu2RadioGroup', + ['root', 'disabled'], +); + +export interface Menu2RadioItemClasses extends Menu2ItemClasses { + /** State class applied to the root element if `checked={true}`. */ + checked: string; +} + +export type Menu2RadioItemClassKey = keyof Menu2RadioItemClasses; + +export function getMenu2RadioItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2RadioItem', slot); +} + +export const menu2RadioItemClasses: Menu2RadioItemClasses = generateUtilityClasses( + 'MuiMenu2RadioItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'checked'], +); + +export interface Menu2RadioItemIndicatorClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; +} + +export type Menu2RadioItemIndicatorClassKey = keyof Menu2RadioItemIndicatorClasses; + +export function getMenu2RadioItemIndicatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2RadioItemIndicator', slot); +} + +export const menu2RadioItemIndicatorClasses: Menu2RadioItemIndicatorClasses = + generateUtilityClasses('MuiMenu2RadioItemIndicator', [ + 'root', + 'checked', + 'disabled', + 'highlighted', + ]); + +export interface Menu2GroupClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type Menu2GroupClassKey = keyof Menu2GroupClasses; + +export function getMenu2GroupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Group', slot); +} + +export const menu2GroupClasses: Menu2GroupClasses = generateUtilityClasses('MuiMenu2Group', [ + 'root', +]); + +export interface Menu2GroupLabelClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type Menu2GroupLabelClassKey = keyof Menu2GroupLabelClasses; + +export function getMenu2GroupLabelUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2GroupLabel', slot); +} + +export const menu2GroupLabelClasses: Menu2GroupLabelClasses = generateUtilityClasses( + 'MuiMenu2GroupLabel', + ['root'], +); + +export interface Menu2SeparatorClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type Menu2SeparatorClassKey = keyof Menu2SeparatorClasses; + +export function getMenu2SeparatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Separator', slot); +} + +export const menu2SeparatorClasses: Menu2SeparatorClasses = generateUtilityClasses( + 'MuiMenu2Separator', + ['root'], +); + +export interface Menu2SubmenuTriggerClasses extends Menu2ItemClasses { + /** State class applied to the root element if the submenu is open. */ + open: string; +} + +export type Menu2SubmenuTriggerClassKey = keyof Menu2SubmenuTriggerClasses; + +export function getMenu2SubmenuTriggerUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2SubmenuTrigger', slot); +} + +export const menu2SubmenuTriggerClasses: Menu2SubmenuTriggerClasses = generateUtilityClasses( + 'MuiMenu2SubmenuTrigger', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'open'], +); diff --git a/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2ItemShared.tsx similarity index 82% rename from packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx rename to packages/mui-material/src/Unstable_Menu2/menu2ItemShared.tsx index 90288780997daa..3192ace44d4d77 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewItemShared.tsx +++ b/packages/mui-material/src/Unstable_Menu2/menu2ItemShared.tsx @@ -5,13 +5,13 @@ import composeClasses from '@mui/utils/composeClasses'; import { CSSInterpolation, SxProps } from '@mui/system'; import { Theme } from '../styles'; import { - MenuPreviewRootSlotProps, - MenuPreviewRootSlots, + Menu2RootSlotProps, + Menu2RootSlots, StateClassName, mergeStateClassName, -} from './menuPreviewUtils'; +} from './menu2Utils'; -export interface MenuPreviewItemOwnerState { +export interface Menu2ItemOwnerState { checked?: boolean | undefined; dense: boolean; disabled: boolean; @@ -20,10 +20,10 @@ export interface MenuPreviewItemOwnerState { selected: boolean; } -export interface MenuPreviewItemVisualProps< +export interface Menu2ItemVisualProps< Classes, - Slots = MenuPreviewRootSlots, - SlotProps = MenuPreviewRootSlotProps, + Slots = Menu2RootSlots, + SlotProps = Menu2RootSlotProps, > { /** * The component used for the root node. @@ -67,7 +67,7 @@ export interface MenuPreviewItemVisualProps< sx?: SxProps | undefined; } -export interface MenuPreviewItemBaseProps { +export interface Menu2ItemBaseProps { /** * The content of the component. */ @@ -94,7 +94,7 @@ export interface MenuPreviewItemBaseProps { closeOnClick?: boolean | undefined; } -export interface MenuPreviewLinkItemBaseProps { +export interface Menu2LinkItemBaseProps { /** * The content of the component. */ @@ -114,7 +114,7 @@ export interface MenuPreviewLinkItemBaseProps { closeOnClick?: boolean | undefined; } -export interface MenuPreviewSubmenuTriggerBaseProps { +export interface Menu2SubmenuTriggerBaseProps { /** * The content of the component. */ @@ -154,13 +154,13 @@ export interface MenuPreviewSubmenuTriggerBaseProps { openOnHover?: boolean | undefined; } -export interface MenuPreviewBaseItemState { +export interface Menu2BaseItemState { disabled?: boolean | undefined; highlighted?: boolean | undefined; } -export function menuPreviewItemOverridesResolver( - props: { ownerState: MenuPreviewItemOwnerState }, +export function menu2ItemOverridesResolver( + props: { ownerState: Menu2ItemOwnerState }, styles: Record, ) { const { ownerState } = props; @@ -173,12 +173,12 @@ export function menuPreviewItemOverridesResolver( ] as CSSInterpolation; } -export function getMenuPreviewItemOwnerState( - props: MenuPreviewItemVisualProps & { +export function getMenu2ItemOwnerState( + props: Menu2ItemVisualProps & { checked?: boolean | undefined; disabled?: boolean | undefined; }, -): MenuPreviewItemOwnerState { +): Menu2ItemOwnerState { return { checked: props.checked, dense: props.dense ?? false, @@ -189,8 +189,8 @@ export function getMenuPreviewItemOwnerState( }; } -export function useMenuPreviewItemUtilityClasses( - ownerState: MenuPreviewItemOwnerState & { +export function useMenu2ItemUtilityClasses( + ownerState: Menu2ItemOwnerState & { classes?: Partial | undefined; checked?: boolean | undefined; open?: boolean | undefined; @@ -221,9 +221,9 @@ export function useMenuPreviewItemUtilityClasses( } as Classes; } -export function getMenuPreviewItemClassName( +export function getMenu2ItemClassName( classes: Partial>, - ownerState: MenuPreviewItemOwnerState, + ownerState: Menu2ItemOwnerState, state: State, ) { return clsx( @@ -233,12 +233,12 @@ export function getMenuPreviewItemClassName( +export function mergeMenu2ItemClassName( className: StateClassName, classes: Partial>, - ownerState: MenuPreviewItemOwnerState, + ownerState: Menu2ItemOwnerState, ) { return mergeStateClassName(className, (state) => - getMenuPreviewItemClassName(classes, ownerState, state), + getMenu2ItemClassName(classes, ownerState, state), ); } diff --git a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx similarity index 77% rename from packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx rename to packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx index c86ea82cd9ea17..f251353a7e74bf 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewPopupShared.tsx +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -8,7 +8,7 @@ import { SxProps } from '@mui/system'; import { Theme } from '../styles'; import { PaperProps } from '../Paper'; import { ListProps } from '../List'; -import { resolveSlotProps, SlotProps } from './menuPreviewUtils'; +import { resolveSlotProps, SlotProps } from './menu2Utils'; type ExternalSlotProps = Omit, 'className' | 'render' | 'style'> & { className?: string | undefined; @@ -83,7 +83,7 @@ const listHostOmittedProps = [ 'sx', ] as const; -export interface MenuPreviewPopupSharedSlots { +export interface Menu2PopupSharedSlots { /** * The component used for the portal. * @default BaseMenu.Portal @@ -111,7 +111,7 @@ export interface MenuPreviewPopupSharedSlots { list?: React.ElementType | undefined; } -export interface MenuPreviewPopupSharedSlotProps { +export interface Menu2PopupSharedSlotProps { portal?: SlotProps, OwnerState> | undefined; positioner?: SlotProps, OwnerState> | undefined; popup?: SlotProps, OwnerState> | undefined; @@ -119,22 +119,22 @@ export interface MenuPreviewPopupSharedSlotProps { list?: SlotProps, OwnerState> | undefined; } -type MenuPreviewPositionerProps = BaseMenu.Positioner.Props; -type MenuPreviewPortalProps = BaseMenu.Portal.Props; +type Menu2PositionerProps = BaseMenu.Positioner.Props; +type Menu2PortalProps = BaseMenu.Portal.Props; -export type MenuPreviewPopupState = BaseMenu.Popup.State; -export type MenuPreviewPopupSide = NonNullable; -export type MenuPreviewPopupAlign = NonNullable; -export type MenuPreviewPopupOffset = NonNullable; -export type MenuPreviewPopupAnchor = MenuPreviewPositionerProps['anchor']; -export type MenuPreviewPopupPositionMethod = MenuPreviewPositionerProps['positionMethod']; -export type MenuPreviewPopupCollisionBoundary = MenuPreviewPositionerProps['collisionBoundary']; -export type MenuPreviewPopupCollisionPadding = MenuPreviewPositionerProps['collisionPadding']; -export type MenuPreviewPopupCollisionAvoidance = MenuPreviewPositionerProps['collisionAvoidance']; -export type MenuPreviewPopupContainer = MenuPreviewPortalProps['container']; -export type MenuPreviewPopupFinalFocus = BaseMenu.Popup.Props['finalFocus']; +export type Menu2PopupState = BaseMenu.Popup.State; +export type Menu2PopupSide = NonNullable; +export type Menu2PopupAlign = NonNullable; +export type Menu2PopupOffset = NonNullable; +export type Menu2PopupAnchor = Menu2PositionerProps['anchor']; +export type Menu2PopupPositionMethod = Menu2PositionerProps['positionMethod']; +export type Menu2PopupCollisionBoundary = Menu2PositionerProps['collisionBoundary']; +export type Menu2PopupCollisionPadding = Menu2PositionerProps['collisionPadding']; +export type Menu2PopupCollisionAvoidance = Menu2PositionerProps['collisionAvoidance']; +export type Menu2PopupContainer = Menu2PortalProps['container']; +export type Menu2PopupFinalFocus = BaseMenu.Popup.Props['finalFocus']; -export interface MenuPreviewPopupPublicProps { +export interface Menu2PopupPublicProps { /** * The menu items. */ @@ -152,74 +152,74 @@ export interface MenuPreviewPopupPublicProps { * * By default, the popup is positioned against the trigger. */ - anchor?: MenuPreviewPopupAnchor | undefined; + anchor?: Menu2PopupAnchor | undefined; /** * Determines which CSS `position` property to use. * @default 'absolute' */ - positionMethod?: MenuPreviewPopupPositionMethod | undefined; + positionMethod?: Menu2PopupPositionMethod | undefined; /** * Which side of the anchor element to align the popup against. * @default 'bottom' */ - side?: MenuPreviewPopupSide | undefined; + side?: Menu2PopupSide | undefined; /** * Distance between the anchor and the popup in pixels. * @default 0 */ - sideOffset?: MenuPreviewPopupOffset | undefined; + sideOffset?: Menu2PopupOffset | undefined; /** * How to align the popup relative to the specified side. * @default 'start' */ - align?: MenuPreviewPopupAlign | undefined; + align?: Menu2PopupAlign | undefined; /** * Additional offset along the alignment axis in pixels. * @default 0 */ - alignOffset?: MenuPreviewPopupOffset | undefined; + alignOffset?: Menu2PopupOffset | undefined; /** * An element or a rectangle that delimits the area that the popup is confined to. * @default 'clipping-ancestors' */ - collisionBoundary?: MenuPreviewPopupCollisionBoundary | undefined; + collisionBoundary?: Menu2PopupCollisionBoundary | undefined; /** * Additional space to maintain from the edge of the collision boundary. * @default 5 */ - collisionPadding?: MenuPreviewPopupCollisionPadding | undefined; + collisionPadding?: Menu2PopupCollisionPadding | undefined; /** * Minimum distance to maintain between the arrow and the edges of the popup. * @default 5 */ - arrowPadding?: MenuPreviewPositionerProps['arrowPadding'] | undefined; + arrowPadding?: Menu2PositionerProps['arrowPadding'] | undefined; /** * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. * @default false */ - sticky?: MenuPreviewPositionerProps['sticky'] | undefined; + sticky?: Menu2PositionerProps['sticky'] | undefined; /** * Whether to disable the popup from tracking layout shifts of its positioning anchor. * @default false */ - disableAnchorTracking?: MenuPreviewPositionerProps['disableAnchorTracking'] | undefined; + disableAnchorTracking?: Menu2PositionerProps['disableAnchorTracking'] | undefined; /** * Determines how to handle collisions when positioning the popup. */ - collisionAvoidance?: MenuPreviewPopupCollisionAvoidance | undefined; + collisionAvoidance?: Menu2PopupCollisionAvoidance | undefined; /** * The container element to portal the popup into. */ - container?: MenuPreviewPopupContainer | undefined; + container?: Menu2PopupContainer | undefined; /** * Whether to keep the portal mounted in the DOM while the popup is hidden. * @default false */ - keepMounted?: MenuPreviewPortalProps['keepMounted'] | undefined; + keepMounted?: Menu2PortalProps['keepMounted'] | undefined; /** * Determines the element to focus when the menu is closed. */ - finalFocus?: MenuPreviewPopupFinalFocus | undefined; + finalFocus?: Menu2PopupFinalFocus | undefined; /** * The elevation of the menu surface. * @default 8 @@ -227,14 +227,14 @@ export interface MenuPreviewPopupPublicProps { elevation?: number | undefined; } -export interface MenuPreviewPopupSharedProps +export interface Menu2PopupSharedProps extends Omit, - MenuPreviewPopupPublicProps { + Menu2PopupPublicProps { classes?: Partial> | undefined; ownerState: OwnerState; - slots?: MenuPreviewPopupSharedSlots | undefined; - slotProps?: MenuPreviewPopupSharedSlotProps | undefined; + slots?: Menu2PopupSharedSlots | undefined; + slotProps?: Menu2PopupSharedSlotProps | undefined; defaultSlots: { popup: React.ElementType; paper: React.ElementType; @@ -244,8 +244,8 @@ export interface MenuPreviewPopupSharedProps sx?: SxProps | undefined; } -export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBase( - props: MenuPreviewPopupSharedProps, +export const Menu2PopupBase = React.forwardRef(function Menu2PopupBase( + props: Menu2PopupSharedProps, ref: React.ForwardedRef, ) { const { @@ -370,5 +370,5 @@ export const MenuPreviewPopupBase = React.forwardRef(function MenuPreviewPopupBa ); }) as ( - props: MenuPreviewPopupSharedProps & React.RefAttributes, + props: Menu2PopupSharedProps & React.RefAttributes, ) => React.JSX.Element; diff --git a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts similarity index 78% rename from packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts rename to packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts index ef0ad115075053..659be362373c19 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewSharedStyles.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -4,7 +4,7 @@ import { Theme } from '../styles'; import { menuListStyles, menuPaperStyles } from '../Menu/menuStyles'; import { getMenuItemRootStyles } from '../MenuItem/menuItemStyles'; -export interface SharedMenuPreviewItemClasses { +export interface SharedMenu2ItemClasses { highlighted: string; disabled: string; dense: string; @@ -14,9 +14,9 @@ export interface SharedMenuPreviewItemClasses { open?: string | undefined; } -export function getMenuPreviewItemStyles( +export function getMenu2ItemStyles( theme: Theme, - classes: SharedMenuPreviewItemClasses, + classes: SharedMenu2ItemClasses, ): CSSInterpolation { const selectedFocusBackgroundColor = theme.alpha( (theme.vars || theme).palette.primary.main, @@ -56,27 +56,27 @@ export function getMenuPreviewItemStyles( }; } -export const menuPreviewPopupPaperStyles: CSSInterpolation = menuPaperStyles; +export const menu2PopupPaperStyles: CSSInterpolation = menuPaperStyles; -export const menuPreviewPopupListStyles: CSSInterpolation = menuListStyles; +export const menu2PopupListStyles: CSSInterpolation = menuListStyles; -export const menuPreviewIndicatorStyles = memoTheme(({ theme }) => ({ +export const menu2IndicatorStyles = memoTheme(({ theme }) => ({ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 36, color: (theme.vars || theme).palette.action.active, - '& [data-mui-menu-preview-indicator-icon]': { + '& [data-mui-menu2-indicator-icon]': { display: 'inline-block', flexShrink: 0, width: '1.25rem', height: '1.25rem', fill: 'currentColor', }, - '& [data-mui-menu-preview-checkbox-checkmark]': { + '& [data-mui-menu2-checkbox-checkmark]': { fill: (theme.vars || theme).palette.background.paper, }, - '&[data-unchecked] [data-mui-menu-preview-indicator-mark]': { + '&[data-unchecked] [data-mui-menu2-indicator-mark]': { visibility: 'hidden', }, })); diff --git a/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts similarity index 87% rename from packages/mui-material/src/MenuPreview/menuPreviewUtils.ts rename to packages/mui-material/src/Unstable_Menu2/menu2Utils.ts index 98b4e53e12114b..72843f75b39976 100644 --- a/packages/mui-material/src/MenuPreview/menuPreviewUtils.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts @@ -20,9 +20,7 @@ export function mergeStateClassName( } export type SlotProps = - | SlotPropsValue - | ((ownerState: OwnerState) => SlotPropsValue) - | undefined; + SlotPropsValue | ((ownerState: OwnerState) => SlotPropsValue) | undefined; export function resolveSlotProps( slotProps: SlotProps, @@ -33,15 +31,15 @@ export function resolveSlotProps( : slotProps; } -export interface MenuPreviewRootSlots { +export interface Menu2RootSlots { root?: React.ElementType | undefined; } -export interface MenuPreviewRootSlotProps { +export interface Menu2RootSlotProps { root?: SlotProps, OwnerState>; } -export function getMenuPreviewRootRender( +export function getMenu2RootRender( RootSlot: React.ElementType, ownerState: OwnerState, props?: Record, @@ -59,7 +57,7 @@ export function getMenuPreviewRootRender( return React.createElement(RootSlot, appendOwnerState(RootSlot, props ?? {}, ownerState)); } -export function isMenuPreviewRootNativeButton( +export function isMenu2RootNativeButton( RootSlot: React.ElementType, component: React.ElementType | undefined, defaultNativeButton = false, diff --git a/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx similarity index 76% rename from packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx rename to packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx index bdb77f5bf60f91..78873aafeb3ab7 100644 --- a/packages/mui-material/src/MenuPreviewCheckboxItem/MenuPreviewCheckboxItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -7,29 +7,29 @@ import ListContext from '../List/ListContext'; import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; -import { getMenuPreviewItemStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; import { - getMenuPreviewRootRender, - isMenuPreviewRootNativeButton, - MenuPreviewRootSlotProps, + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, resolveSlotProps, -} from '../MenuPreview/menuPreviewUtils'; +} from '../Unstable_Menu2/menu2Utils'; import { - getMenuPreviewItemClassName, - getMenuPreviewItemOwnerState, - MenuPreviewItemBaseProps, - MenuPreviewItemOwnerState, - MenuPreviewItemVisualProps, - menuPreviewItemOverridesResolver, - useMenuPreviewItemUtilityClasses, -} from '../MenuPreview/menuPreviewItemShared'; + getMenu2ItemClassName, + getMenu2ItemOwnerState, + Menu2ItemBaseProps, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + menu2ItemOverridesResolver, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; import { - getMenuPreviewCheckboxItemUtilityClass, - menuPreviewCheckboxItemClasses, - MenuPreviewCheckboxItemClasses, -} from '../MenuPreview/menuPreviewClasses'; + getMenu2CheckboxItemUtilityClass, + menu2CheckboxItemClasses, + Menu2CheckboxItemClasses, +} from '../Unstable_Menu2/menu2Classes'; -export interface MenuPreviewCheckboxItemSlots { +export interface Menu2CheckboxItemSlots { /** * The component that renders the root. * @default 'div' @@ -37,19 +37,19 @@ export interface MenuPreviewCheckboxItemSlots { root?: React.ElementType | undefined; } -export interface MenuPreviewCheckboxItemSlotProps extends MenuPreviewRootSlotProps {} +export interface Menu2CheckboxItemSlotProps extends Menu2RootSlotProps {} -export interface MenuPreviewCheckboxItemProps +export interface Menu2CheckboxItemProps extends Omit< BaseMenu.CheckboxItem.Props, 'className' | 'nativeButton' | 'onChange' | 'onCheckedChange' | 'render' | 'style' >, - MenuPreviewItemBaseProps, - MenuPreviewItemVisualProps< - MenuPreviewCheckboxItemClasses, - MenuPreviewCheckboxItemSlots, - MenuPreviewCheckboxItemSlotProps + Menu2ItemBaseProps, + Menu2ItemVisualProps< + Menu2CheckboxItemClasses, + Menu2CheckboxItemSlots, + Menu2CheckboxItemSlotProps > { /** * The content of the component. @@ -102,12 +102,12 @@ export interface MenuPreviewCheckboxItemProps style?: React.CSSProperties | undefined; } -const MenuPreviewCheckboxItemRoot = styled('div', { - name: 'MuiMenuPreviewCheckboxItem', +const Menu2CheckboxItemRoot = styled('div', { + name: 'MuiMenu2CheckboxItem', slot: 'Root', - overridesResolver: menuPreviewItemOverridesResolver, -})<{ ownerState: MenuPreviewItemOwnerState }>( - memoTheme(({ theme }) => getMenuPreviewItemStyles(theme, menuPreviewCheckboxItemClasses)), + overridesResolver: menu2ItemOverridesResolver, +})<{ ownerState: Menu2ItemOwnerState }>( + memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2CheckboxItemClasses)), ); /** @@ -118,15 +118,15 @@ const MenuPreviewCheckboxItemRoot = styled('div', { * * API: * - * - [MenuPreviewCheckboxItem API](https://mui.com/material-ui/api/menu-preview-checkbox-item/) + * - [Menu2CheckboxItem API](https://mui.com/material-ui/api/menu-preview-checkbox-item/) */ -const MenuPreviewCheckboxItem = React.forwardRef(function MenuPreviewCheckboxItem( - inProps: MenuPreviewCheckboxItemProps, +const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( + inProps: Menu2CheckboxItemProps, ref: React.ForwardedRef, ) { const props = useDefaultProps({ props: inProps, - name: 'MuiMenuPreviewCheckboxItem', + name: 'MuiMenu2CheckboxItem', }); const { @@ -149,7 +149,7 @@ const MenuPreviewCheckboxItem = React.forwardRef(function MenuPreviewCheckboxIte } = props; const ownerState = { ...props, - ...getMenuPreviewItemOwnerState({ + ...getMenu2ItemOwnerState({ checked, dense, disabled, @@ -159,9 +159,9 @@ const MenuPreviewCheckboxItem = React.forwardRef(function MenuPreviewCheckboxIte }), classes: classesProp, }; - const classes = useMenuPreviewItemUtilityClasses( + const classes = useMenu2ItemUtilityClasses( ownerState, - getMenuPreviewCheckboxItemUtilityClass, + getMenu2CheckboxItemUtilityClass, ); const childContext = React.useMemo( () => ({ @@ -176,13 +176,13 @@ const MenuPreviewCheckboxItem = React.forwardRef(function MenuPreviewCheckboxIte }, [onChange], ); - const RootSlot = slots?.root ?? MenuPreviewCheckboxItemRoot; + const RootSlot = slots?.root ?? Menu2CheckboxItemRoot; return ( clsx( className, - getMenuPreviewItemClassName(classes, ownerState, state), + getMenu2ItemClassName(classes, ownerState, state), state.checked && classes.checked, ) } checked={checked} disabled={disabled} - nativeButton={nativeButtonProp ?? isMenuPreviewRootNativeButton(RootSlot, component)} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} onCheckedChange={handleCheckedChange} style={style} {...other} @@ -206,7 +206,7 @@ const MenuPreviewCheckboxItem = React.forwardRef(function MenuPreviewCheckboxIte ); }); -MenuPreviewCheckboxItem.propTypes /* remove-proptypes */ = { +Menu2CheckboxItem.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ // │ These PropTypes are generated from the TypeScript type definitions. │ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ @@ -310,4 +310,4 @@ MenuPreviewCheckboxItem.propTypes /* remove-proptypes */ = { ]), } as any; -export default MenuPreviewCheckboxItem; +export default Menu2CheckboxItem; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts new file mode 100644 index 00000000000000..dff5b9c4fec88a --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2CheckboxItem'; +export * from './Menu2CheckboxItem'; +export { + menu2CheckboxItemClasses, + getMenu2CheckboxItemUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2CheckboxItemClasses, + Menu2CheckboxItemClassKey, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js new file mode 100644 index 00000000000000..fc33e94bd00adf --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2CheckboxItem'; +export * from './Menu2CheckboxItem'; +export { + menu2CheckboxItemClasses, + getMenu2CheckboxItemUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx similarity index 69% rename from packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx rename to packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx index 85e1da901a9b1b..41c5478f99040c 100644 --- a/packages/mui-material/src/MenuPreviewCheckboxItemIndicator/MenuPreviewCheckboxItemIndicator.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx @@ -8,18 +8,18 @@ import { SxProps } from '@mui/system'; import { Theme } from '../styles'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; -import { menuPreviewIndicatorStyles } from '../MenuPreview/menuPreviewSharedStyles'; +import { menu2IndicatorStyles } from '../Unstable_Menu2/menu2SharedStyles'; import { - getMenuPreviewRootRender, - MenuPreviewRootSlotProps, + getMenu2RootRender, + Menu2RootSlotProps, resolveSlotProps, -} from '../MenuPreview/menuPreviewUtils'; +} from '../Unstable_Menu2/menu2Utils'; import { - getMenuPreviewCheckboxItemIndicatorUtilityClass, - MenuPreviewCheckboxItemIndicatorClasses, -} from '../MenuPreview/menuPreviewClasses'; + getMenu2CheckboxItemIndicatorUtilityClass, + Menu2CheckboxItemIndicatorClasses, +} from '../Unstable_Menu2/menu2Classes'; -export interface MenuPreviewCheckboxItemIndicatorSlots { +export interface Menu2CheckboxItemIndicatorSlots { /** * The component that renders the root. * @default 'span' @@ -27,9 +27,9 @@ export interface MenuPreviewCheckboxItemIndicatorSlots { root?: React.ElementType | undefined; } -export interface MenuPreviewCheckboxItemIndicatorSlotProps extends MenuPreviewRootSlotProps {} +export interface Menu2CheckboxItemIndicatorSlotProps extends Menu2RootSlotProps {} -export interface MenuPreviewCheckboxItemIndicatorProps extends Omit< +export interface Menu2CheckboxItemIndicatorProps extends Omit< BaseMenu.CheckboxItemIndicator.Props, 'className' | 'render' | 'style' > { @@ -40,7 +40,7 @@ export interface MenuPreviewCheckboxItemIndicatorProps extends Omit< /** * Override or extend the styles applied to the component. */ - classes?: Partial | undefined; + classes?: Partial | undefined; /** * CSS class applied to the element. */ @@ -53,11 +53,11 @@ export interface MenuPreviewCheckboxItemIndicatorProps extends Omit< /** * The components used for each slot inside. */ - slots?: MenuPreviewCheckboxItemIndicatorSlots | undefined; + slots?: Menu2CheckboxItemIndicatorSlots | undefined; /** * The props used for each slot inside. */ - slotProps?: MenuPreviewCheckboxItemIndicatorSlotProps | undefined; + slotProps?: Menu2CheckboxItemIndicatorSlotProps | undefined; /** * Styles applied to the root element. */ @@ -68,7 +68,7 @@ export interface MenuPreviewCheckboxItemIndicatorProps extends Omit< sx?: SxProps | undefined; } -const useUtilityClasses = (ownerState: MenuPreviewCheckboxItemIndicatorProps) => { +const useUtilityClasses = (ownerState: Menu2CheckboxItemIndicatorProps) => { const { classes } = ownerState; const slots = { @@ -80,28 +80,23 @@ const useUtilityClasses = (ownerState: MenuPreviewCheckboxItemIndicatorProps) => return { ...classes, - ...composeClasses(slots, getMenuPreviewCheckboxItemIndicatorUtilityClass, classes), + ...composeClasses(slots, getMenu2CheckboxItemIndicatorUtilityClass, classes), }; }; -const MenuPreviewCheckboxItemIndicatorRoot = styled('span', { - name: 'MuiMenuPreviewCheckboxItemIndicator', +const Menu2CheckboxItemIndicatorRoot = styled('span', { + name: 'MuiMenu2CheckboxItemIndicator', slot: 'Root', overridesResolver: (props, styles) => styles.root, -})(menuPreviewIndicatorStyles) as any; +})(menu2IndicatorStyles) as any; function DefaultCheckboxIndicatorIcon() { return ( - Actions @@ -269,7 +273,7 @@ const parityItems = [ function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings }) { const [classicAnchorEl, setClassicAnchorEl] = React.useState(null); - const { slotProps } = usePopupKnobProps(settings); + const popupKnobProps = usePopupKnobProps(settings); const itemProps = { dense: settings.dense, divider: settings.dividers }; return ( @@ -305,7 +309,7 @@ function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings }> Successor - + {parityItems.map((item) => ( Root behavior {renderCheckbox('modal', 'modal')} + {renderCheckbox('rootOpenOnHover', 'openOnHover (root)')} {renderCheckbox('loopFocus', 'loopFocus')} {renderCheckbox('highlightItemOnHover', 'highlightItemOnHover')} @@ -594,9 +599,9 @@ export default function MenuRfcExperiment() {

Classic vs successor

The same item set rendered by the classic Menu and the successor, for visual parity - checks (dense, dividers, selected, disabled, elevation knobs apply to both). Note the - classic top-level elevation prop vs{' '} - slotProps.paper.elevation on the successor (RFC open question). + checks (dense, dividers, selected, disabled, elevation knobs apply to both). Both + expose a top-level elevation prop; the successor forwards it to the Paper + slot.

diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 0554315c46e9cf..394aef53cecec0 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -4,60 +4,17 @@ import PropTypes from 'prop-types'; import { Menu as BaseMenu } from '@base-ui/react/menu'; import { useDefaultProps } from '../DefaultPropsProvider'; -export interface Menu2Props { +/** + * Inherits the full Base UI `Menu.Root` prop surface (open/close control, + * modality, hover-open with delays, `actionsRef`, keyboard behavior). + * `Omit` (a mapped type) is used instead of bare `extends` so the proptypes + * generator resolves the inherited members. + */ +export interface Menu2Props extends Omit { /** * The content of the menu. */ children?: React.ReactNode; - /** - * Whether the menu is initially open. - * - * To render a controlled menu, use the `open` prop instead. - * @default false - */ - defaultOpen?: boolean | undefined; - /** - * Whether the menu is currently open. - */ - open?: boolean | undefined; - /** - * Event handler called when the menu is opened or closed. - */ - onOpenChange?: BaseMenu.Root.Props['onOpenChange'] | undefined; - /** - * Event handler called after any animations complete when the menu is opened or closed. - */ - onOpenChangeComplete?: BaseMenu.Root.Props['onOpenChangeComplete'] | undefined; - /** - * Determines if the menu enters a modal state when open. - * @default true - */ - modal?: boolean | undefined; - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled?: boolean | undefined; - /** - * Whether to loop keyboard focus back to the first item. - * @default true - */ - loopFocus?: boolean | undefined; - /** - * Whether moving the pointer over items should highlight them. - * @default true - */ - highlightItemOnHover?: boolean | undefined; - /** - * The visual orientation of the menu. - * @default 'vertical' - */ - orientation?: 'horizontal' | 'vertical' | undefined; - /** - * When in a submenu, determines whether pressing the Escape key closes the entire menu. - * @default false - */ - closeParentOnEsc?: boolean | undefined; } /** @@ -88,55 +45,6 @@ Menu2.propTypes /* remove-proptypes */ = { * The content of the menu. */ children: PropTypes.node, - /** - * When in a submenu, determines whether pressing the Escape key closes the entire menu. - * @default false - */ - closeParentOnEsc: PropTypes.bool, - /** - * Whether the menu is initially open. - * - * To render a controlled menu, use the `open` prop instead. - * @default false - */ - defaultOpen: PropTypes.bool, - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled: PropTypes.bool, - /** - * Whether moving the pointer over items should highlight them. - * @default true - */ - highlightItemOnHover: PropTypes.bool, - /** - * Whether to loop keyboard focus back to the first item. - * @default true - */ - loopFocus: PropTypes.bool, - /** - * Determines if the menu enters a modal state when open. - * @default true - */ - modal: PropTypes.bool, - /** - * Event handler called when the menu is opened or closed. - */ - onOpenChange: PropTypes.func, - /** - * Event handler called after any animations complete when the menu is opened or closed. - */ - onOpenChangeComplete: PropTypes.func, - /** - * Whether the menu is currently open. - */ - open: PropTypes.bool, - /** - * The visual orientation of the menu. - * @default 'vertical' - */ - orientation: PropTypes.oneOf(['horizontal', 'vertical']), } as any; export default Menu2; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx index f251353a7e74bf..e1b5794fb39315 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -134,7 +134,29 @@ export type Menu2PopupCollisionAvoidance = Menu2PositionerProps['collisionAvoida export type Menu2PopupContainer = Menu2PortalProps['container']; export type Menu2PopupFinalFocus = BaseMenu.Popup.Props['finalFocus']; -export interface Menu2PopupPublicProps { +/** + * The flattened positioning/portal surface hoisted onto the popup, inherited + * from the Base UI parts via Pick so new Base UI props flow through types + * automatically. Only props that Material UI adds, or whose defaults differ + * from Base UI, are declared locally. + */ +export interface Menu2PopupPublicProps + extends + Pick< + Menu2PositionerProps, + | 'anchor' + | 'positionMethod' + | 'sideOffset' + | 'alignOffset' + | 'collisionBoundary' + | 'collisionPadding' + | 'arrowPadding' + | 'sticky' + | 'disableAnchorTracking' + | 'collisionAvoidance' + >, + Pick, + Pick { /** * The menu items. */ @@ -147,79 +169,17 @@ export interface Menu2PopupPublicProps { * Styles applied to the Base UI popup element. */ style?: React.CSSProperties | undefined; - /** - * An element to position the popup against. - * - * By default, the popup is positioned against the trigger. - */ - anchor?: Menu2PopupAnchor | undefined; - /** - * Determines which CSS `position` property to use. - * @default 'absolute' - */ - positionMethod?: Menu2PopupPositionMethod | undefined; /** * Which side of the anchor element to align the popup against. * @default 'bottom' */ side?: Menu2PopupSide | undefined; - /** - * Distance between the anchor and the popup in pixels. - * @default 0 - */ - sideOffset?: Menu2PopupOffset | undefined; /** * How to align the popup relative to the specified side. + * Defaults to `start` to match the classic Menu (Base UI defaults to `center`). * @default 'start' */ align?: Menu2PopupAlign | undefined; - /** - * Additional offset along the alignment axis in pixels. - * @default 0 - */ - alignOffset?: Menu2PopupOffset | undefined; - /** - * An element or a rectangle that delimits the area that the popup is confined to. - * @default 'clipping-ancestors' - */ - collisionBoundary?: Menu2PopupCollisionBoundary | undefined; - /** - * Additional space to maintain from the edge of the collision boundary. - * @default 5 - */ - collisionPadding?: Menu2PopupCollisionPadding | undefined; - /** - * Minimum distance to maintain between the arrow and the edges of the popup. - * @default 5 - */ - arrowPadding?: Menu2PositionerProps['arrowPadding'] | undefined; - /** - * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. - * @default false - */ - sticky?: Menu2PositionerProps['sticky'] | undefined; - /** - * Whether to disable the popup from tracking layout shifts of its positioning anchor. - * @default false - */ - disableAnchorTracking?: Menu2PositionerProps['disableAnchorTracking'] | undefined; - /** - * Determines how to handle collisions when positioning the popup. - */ - collisionAvoidance?: Menu2PopupCollisionAvoidance | undefined; - /** - * The container element to portal the popup into. - */ - container?: Menu2PopupContainer | undefined; - /** - * Whether to keep the portal mounted in the DOM while the popup is hidden. - * @default false - */ - keepMounted?: Menu2PortalProps['keepMounted'] | undefined; - /** - * Determines the element to focus when the menu is closed. - */ - finalFocus?: Menu2PopupFinalFocus | undefined; /** * The elevation of the menu surface. * @default 8 diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx index 6476956e9784d9..97dfbbadec9603 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx +++ b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx @@ -4,55 +4,17 @@ import PropTypes from 'prop-types'; import { Menu as BaseMenu } from '@base-ui/react/menu'; import { useDefaultProps } from '../DefaultPropsProvider'; -export interface Menu2SubmenuRootProps { +/** + * Inherits the full Base UI `Menu.SubmenuRoot` prop surface (open/close + * control, hover-open with delays, `closeParentOnEsc`, keyboard behavior). + * `Omit` (a mapped type) is used instead of bare `extends` so the proptypes + * generator resolves the inherited members. + */ +export interface Menu2SubmenuRootProps extends Omit { /** * The content of the submenu. */ children?: React.ReactNode; - /** - * Whether the submenu is initially open. - * - * To render a controlled submenu, use the `open` prop instead. - * @default false - */ - defaultOpen?: boolean | undefined; - /** - * Whether the submenu is currently open. - */ - open?: boolean | undefined; - /** - * Event handler called when the submenu is opened or closed. - */ - onOpenChange?: BaseMenu.SubmenuRoot.Props['onOpenChange'] | undefined; - /** - * Event handler called after any animations complete when the submenu is opened or closed. - */ - onOpenChangeComplete?: BaseMenu.SubmenuRoot.Props['onOpenChangeComplete'] | undefined; - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled?: boolean | undefined; - /** - * Whether to loop keyboard focus back to the first item. - * @default true - */ - loopFocus?: boolean | undefined; - /** - * Whether moving the pointer over items should highlight them. - * @default true - */ - highlightItemOnHover?: boolean | undefined; - /** - * The visual orientation of the submenu. - * @default 'vertical' - */ - orientation?: 'horizontal' | 'vertical' | undefined; - /** - * When in a submenu, determines whether pressing the Escape key closes the entire menu. - * @default false - */ - closeParentOnEsc?: boolean | undefined; } /** @@ -83,50 +45,6 @@ Menu2SubmenuRoot.propTypes /* remove-proptypes */ = { * The content of the submenu. */ children: PropTypes.node, - /** - * When in a submenu, determines whether pressing the Escape key closes the entire menu. - * @default false - */ - closeParentOnEsc: PropTypes.bool, - /** - * Whether the submenu is initially open. - * - * To render a controlled submenu, use the `open` prop instead. - * @default false - */ - defaultOpen: PropTypes.bool, - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled: PropTypes.bool, - /** - * Whether moving the pointer over items should highlight them. - * @default true - */ - highlightItemOnHover: PropTypes.bool, - /** - * Whether to loop keyboard focus back to the first item. - * @default true - */ - loopFocus: PropTypes.bool, - /** - * Event handler called when the submenu is opened or closed. - */ - onOpenChange: PropTypes.func, - /** - * Event handler called after any animations complete when the submenu is opened or closed. - */ - onOpenChangeComplete: PropTypes.func, - /** - * Whether the submenu is currently open. - */ - open: PropTypes.bool, - /** - * The visual orientation of the submenu. - * @default 'vertical' - */ - orientation: PropTypes.oneOf(['horizontal', 'vertical']), } as any; export default Menu2SubmenuRoot; From 84de2d40b8d37854340d8ac3dc3aa3899a9dfe7b Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 15:05:20 +0300 Subject: [PATCH 13/62] Fix popup surface clipping from inherited classic maxHeight The shared classic paper styles carry maxHeight: calc(100% - 96px), which in the classic Menu resolves against the full-viewport Modal. Inside the content-sized Base UI popup the percentage resolves against the popup itself (browser-dependent), clamping the Paper ~96px shorter than its content and clipping the end of the menu (trailing items and the separator), with a visible reflow on open. Override it for the successor with the positioner-provided collision-aware space: min(calc(100vh - 96px), var(--available-height)) plus overflowY: auto so long menus scroll inside the surface. Classic Menu keeps its original styles untouched. Regression test asserts the paper's computed max-height is no longer the classic percentage form and that the surface scrolls. Co-Authored-By: Claude Fable 5 --- .../src/Unstable_Menu2/Menu2.test.tsx | 22 +++++++++++++++++++ .../src/Unstable_Menu2/menu2SharedStyles.ts | 11 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index 941158a051fa00..eaba4f0b22ea0a 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -453,6 +453,28 @@ describe('', () => { expect(await screen.findByTestId('paper')).to.have.class(paperClasses.elevation4); }); + it.skipIf(isJsdom())('constrains the popup surface to the collision-aware height', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const paper = await screen.findByTestId('paper'); + const { maxHeight, overflowY } = window.getComputedStyle(paper); + // Regression: the classic `calc(100% - 96px)` resolved against the + // content-sized popup instead of the viewport and clipped the end of the + // menu (separators and trailing items). + expect(maxHeight).not.to.equal('calc(100% - 96px)'); + expect(maxHeight).not.to.equal('none'); + expect(overflowY).to.equal('auto'); + }); + it('supports controlled open state and Base UI cancellation details', async () => { const handleOpenChange = spy((open: boolean, eventDetails: any) => { expect(open).to.equal(true); diff --git a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts index 659be362373c19..4379130b561545 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -56,7 +56,16 @@ export function getMenu2ItemStyles( }; } -export const menu2PopupPaperStyles: CSSInterpolation = menuPaperStyles; +export const menu2PopupPaperStyles: CSSInterpolation = { + ...menuPaperStyles, + // In the classic Menu the Paper sits in a full-viewport Modal, so its + // `maxHeight: calc(100% - 96px)` means "viewport minus 96px". Inside the + // content-sized Base UI popup that percentage resolves against the popup + // itself (browser-dependent), clipping the end of the menu. Use the + // collision-aware space provided by the positioner instead. + maxHeight: 'min(calc(100vh - 96px), var(--available-height))', + overflowY: 'auto', +}; export const menu2PopupListStyles: CSSInterpolation = menuListStyles; From 7296bb65068d644e9ce98ed8de16b4891f915d28 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 15:29:22 +0300 Subject: [PATCH 14/62] Fix separator spacing collapse when a submenu opens Opening a submenu mounts Base UI inline focus-guard and portal-anchor nodes as siblings of the submenu trigger, which breaks the legacy `[item] + .MuiDivider-root` adjacency rule carried by the shared item styles: the following separator lost its 8px margins and the menu visibly contracted below the trigger. Own the spacing on Menu2Separator's root instead (same values, so the closed state is unchanged where the adjacency rule also applies). Side effect: a separator following a Group now gets the same margins, making separator spacing uniform. Regression test opens a submenu (defaultOpen) and asserts the separator keeps its margins. Co-Authored-By: Claude Fable 5 --- .../src/Unstable_Menu2/Menu2.test.tsx | 28 +++++++++++++++++++ .../Menu2Separator.tsx | 12 +++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index eaba4f0b22ea0a..f1288788fe2785 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -844,6 +844,34 @@ describe('', () => { expect(screen.getByRole('menuitem', { name: 'Archive' })).to.not.equal(null); }); + it.skipIf(isJsdom())('keeps separator spacing stable while a submenu is open', async () => { + const { user } = render( + + Options + + + View + + Zoom + + + + After + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + await screen.findByRole('menuitem', { name: 'Zoom' }); + + const separator = screen.getByRole('separator'); + const { marginTop, marginBottom } = window.getComputedStyle(separator); + // Regression: the inline focus-guard nodes of an open submenu broke the + // legacy `[item] + divider` adjacency rule and collapsed this spacing. + expect(marginTop).to.equal('8px'); + expect(marginBottom).to.equal('8px'); + }); + it('supports Material UI Tooltip on enabled item flavors', async () => { const { user } = render( diff --git a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx index 5bd1d6e3fcf74e..035b2d3e197cc2 100644 --- a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx @@ -8,6 +8,7 @@ import { SxProps } from '@mui/system'; import Divider from '../Divider'; import { Theme } from '../styles'; import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenu2RootRender, @@ -77,7 +78,16 @@ const Menu2SeparatorRoot = styled(Divider, { name: 'MuiMenu2Separator', slot: 'Root', overridesResolver: (props, styles) => styles.root, -})({}) as any; +})( + // Own the classic item/divider spacing instead of relying on the legacy + // `[item] + divider` adjacency rule: Base UI mounts inline focus-guard + // nodes next to an open submenu trigger, which breaks that selector and + // collapses the gap. + memoTheme(({ theme }) => ({ + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + })), +) as any; /** * From e802107a760ad22062d9f1ee36a8ba27300b635d Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 15:47:34 +0300 Subject: [PATCH 15/62] Fix Netlify and CI failures from the tooling cleanup and styles spread - Restore docs/data/material/pagesApi.js to upstream: it still registered the 16 removed /material-ui/api/menu-preview* routes (kebab-case, so the earlier cleanup sweep missed it), breaking the docs build on Netlify. - Narrow the JSDoc-typed classic paper styles before spreading (CSSInterpolation union fails TS2698 under tsconfig.build declaration emit, which broke the package build, publish dry run, and bundle monitor; the dev tsconfig tolerated it). - Drop the dead api/menu-preview* links from the generated component JSDoc headers. - Wire the playground hover-open knob to the trigger: Base UI puts openOnHover/delay on Menu.Trigger, not Menu.Root (caught by the docs typecheck once the barrel types were exercised). Verified: @mui/material build with dependency graph (declaration emit) exit 0, docs + package tsgo clean, proptypes regeneration churn-free, Menu2 suite 117 passed / 15 skipped, eslint/prettier clean. Co-Authored-By: Claude Fable 5 --- docs/data/material/pagesApi.js | 16 ---------------- docs/pages/experiments/menu-rfc.tsx | 13 ++++++++----- .../mui-material/src/Unstable_Menu2/Menu2.tsx | 10 +++------- .../src/Unstable_Menu2/menu2SharedStyles.ts | 6 ++++-- .../Menu2CheckboxItem.tsx | 4 ---- .../Menu2CheckboxItemIndicator.tsx | 4 ---- .../src/Unstable_Menu2Group/Menu2Group.tsx | 4 ---- .../Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx | 4 ---- .../src/Unstable_Menu2Item/Menu2Item.tsx | 4 ---- .../src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx | 4 ---- .../src/Unstable_Menu2Popup/Menu2Popup.tsx | 4 ---- .../Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx | 4 ---- .../Unstable_Menu2RadioItem/Menu2RadioItem.tsx | 4 ---- .../Menu2RadioItemIndicator.tsx | 4 ---- .../Unstable_Menu2Separator/Menu2Separator.tsx | 4 ---- .../Menu2SubmenuPopup.tsx | 4 ---- .../Menu2SubmenuRoot.tsx | 10 +++------- .../Menu2SubmenuTrigger.tsx | 4 ---- .../src/Unstable_Menu2Trigger/Menu2Trigger.tsx | 4 ---- 19 files changed, 18 insertions(+), 93 deletions(-) diff --git a/docs/data/material/pagesApi.js b/docs/data/material/pagesApi.js index 5ca508d6d9e9eb..ee76034af9e54a 100644 --- a/docs/data/material/pagesApi.js +++ b/docs/data/material/pagesApi.js @@ -73,22 +73,6 @@ export default [ { pathname: '/material-ui/api/menu' }, { pathname: '/material-ui/api/menu-item' }, { pathname: '/material-ui/api/menu-list' }, - { pathname: '/material-ui/api/menu-preview' }, - { pathname: '/material-ui/api/menu-preview-checkbox-item' }, - { pathname: '/material-ui/api/menu-preview-checkbox-item-indicator' }, - { pathname: '/material-ui/api/menu-preview-group' }, - { pathname: '/material-ui/api/menu-preview-group-label' }, - { pathname: '/material-ui/api/menu-preview-item' }, - { pathname: '/material-ui/api/menu-preview-link-item' }, - { pathname: '/material-ui/api/menu-preview-popup' }, - { pathname: '/material-ui/api/menu-preview-radio-group' }, - { pathname: '/material-ui/api/menu-preview-radio-item' }, - { pathname: '/material-ui/api/menu-preview-radio-item-indicator' }, - { pathname: '/material-ui/api/menu-preview-separator' }, - { pathname: '/material-ui/api/menu-preview-submenu-popup' }, - { pathname: '/material-ui/api/menu-preview-submenu-root' }, - { pathname: '/material-ui/api/menu-preview-submenu-trigger' }, - { pathname: '/material-ui/api/menu-preview-trigger' }, { pathname: '/material-ui/api/mobile-stepper' }, { pathname: '/material-ui/api/modal' }, { pathname: '/material-ui/api/native-select' }, diff --git a/docs/pages/experiments/menu-rfc.tsx b/docs/pages/experiments/menu-rfc.tsx index 16dfca1e64d96c..f802a9981d3217 100644 --- a/docs/pages/experiments/menu-rfc.tsx +++ b/docs/pages/experiments/menu-rfc.tsx @@ -58,7 +58,7 @@ type PopupAlign = NonNullable; interface PlaygroundSettings { // Root behavior modal: boolean; - rootOpenOnHover: boolean; + triggerOpenOnHover: boolean; loopFocus: boolean; highlightItemOnHover: boolean; // Submenu behavior @@ -82,7 +82,7 @@ interface PlaygroundSettings { const defaultSettings: PlaygroundSettings = { modal: true, - rootOpenOnHover: false, + triggerOpenOnHover: false, loopFocus: true, highlightItemOnHover: true, submenusOpenOnHover: true, @@ -166,13 +166,16 @@ function PlaygroundDemo({ return ( - }> + } + > Project Root behavior {renderCheckbox('modal', 'modal')} - {renderCheckbox('rootOpenOnHover', 'openOnHover (root)')} + {renderCheckbox('triggerOpenOnHover', 'openOnHover (trigger)')} {renderCheckbox('loopFocus', 'loopFocus')} {renderCheckbox('highlightItemOnHover', 'highlightItemOnHover')} diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 394aef53cecec0..40501b5bfeedba 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -6,9 +6,9 @@ import { useDefaultProps } from '../DefaultPropsProvider'; /** * Inherits the full Base UI `Menu.Root` prop surface (open/close control, - * modality, hover-open with delays, `actionsRef`, keyboard behavior). - * `Omit` (a mapped type) is used instead of bare `extends` so the proptypes - * generator resolves the inherited members. + * modality, `actionsRef`, keyboard behavior); hover-open props live on the + * trigger parts. `Omit` (a mapped type) is used instead of bare `extends` so + * the proptypes generator resolves the inherited members. */ export interface Menu2Props extends Omit { /** @@ -22,10 +22,6 @@ export interface Menu2Props extends Omit { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2 API](https://mui.com/material-ui/api/menu-preview/) */ function Menu2(props: Menu2Props): React.JSX.Element { const themedProps = useDefaultProps({ diff --git a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts index 4379130b561545..cd44eac5ad0121 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -1,4 +1,4 @@ -import { CSSInterpolation } from '@mui/system'; +import { CSSInterpolation, CSSObject } from '@mui/system'; import memoTheme from '../utils/memoTheme'; import { Theme } from '../styles'; import { menuListStyles, menuPaperStyles } from '../Menu/menuStyles'; @@ -57,7 +57,9 @@ export function getMenu2ItemStyles( } export const menu2PopupPaperStyles: CSSInterpolation = { - ...menuPaperStyles, + // The classic module types its exports as CSSInterpolation via JSDoc; the + // value is a plain style object, narrowed here so it can be spread. + ...(menuPaperStyles as CSSObject), // In the classic Menu the Paper sits in a full-viewport Modal, so its // `maxHeight: calc(100% - 96px)` means "viewport minus 96px". Inside the // content-sized Base UI popup that percentage resolves against the popup diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx index 78873aafeb3ab7..43face4999bb44 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -115,10 +115,6 @@ const Menu2CheckboxItemRoot = styled('div', { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2CheckboxItem API](https://mui.com/material-ui/api/menu-preview-checkbox-item/) */ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( inProps: Menu2CheckboxItemProps, diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx index 41c5478f99040c..f631a21aa669b0 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx @@ -108,10 +108,6 @@ function DefaultCheckboxIndicatorIcon() { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2CheckboxItemIndicator API](https://mui.com/material-ui/api/menu-preview-checkbox-item-indicator/) */ const Menu2CheckboxItemIndicator = React.forwardRef(function Menu2CheckboxItemIndicator( inProps: Menu2CheckboxItemIndicatorProps, diff --git a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx index dcae5be3f814a2..06ede66d21f8b0 100644 --- a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx @@ -80,10 +80,6 @@ const Menu2GroupRoot = styled('div', { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2Group API](https://mui.com/material-ui/api/menu-preview-group/) */ const Menu2Group = React.forwardRef(function Menu2Group( inProps: Menu2GroupProps, diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx index 7466bb7f9af3cd..61dad9a3b376b7 100644 --- a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx @@ -84,10 +84,6 @@ const Menu2GroupLabelRoot = styled(ListSubheader, { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2GroupLabel API](https://mui.com/material-ui/api/menu-preview-group-label/) */ const Menu2GroupLabel = React.forwardRef(function Menu2GroupLabel( inProps: Menu2GroupLabelProps, diff --git a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx index 8ae280536b80f1..909ed67f27a9bb 100644 --- a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx @@ -84,10 +84,6 @@ const Menu2ItemRoot = styled('div', { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2Item API](https://mui.com/material-ui/api/menu-preview-item/) */ const Menu2Item = React.forwardRef(function Menu2Item( inProps: Menu2ItemProps, diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx index 42f0cad017ace3..949b35c64c3def 100644 --- a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx @@ -82,10 +82,6 @@ const Menu2LinkItemRoot = styled('a', { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2LinkItem API](https://mui.com/material-ui/api/menu-preview-link-item/) */ const Menu2LinkItem = React.forwardRef(function Menu2LinkItem( inProps: Menu2LinkItemProps, diff --git a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx index bc5cd1a54d185b..231213e0b52040 100644 --- a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx +++ b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx @@ -199,10 +199,6 @@ const Menu2PopupList = styled(List, { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2Popup API](https://mui.com/material-ui/api/menu-preview-popup/) */ const Menu2Popup = React.forwardRef(function Menu2Popup( inProps: Menu2PopupProps, diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx index 248f6b1292c5dd..4ee275d0309328 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx @@ -112,10 +112,6 @@ const Menu2RadioGroupRoot = styled('div', { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2RadioGroup API](https://mui.com/material-ui/api/menu-preview-radio-group/) */ const Menu2RadioGroup = React.forwardRef(function Menu2RadioGroup( inProps: Menu2RadioGroupProps, diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx index cc4764e07050a8..7b3c4b40c89664 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx @@ -89,10 +89,6 @@ const Menu2RadioItemRoot = styled('div', { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2RadioItem API](https://mui.com/material-ui/api/menu-preview-radio-item/) */ const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( inProps: Menu2RadioItemProps, diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx index 601802f0118197..febe9761856ff3 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx @@ -107,10 +107,6 @@ function DefaultRadioIndicatorIcon() { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2RadioItemIndicator API](https://mui.com/material-ui/api/menu-preview-radio-item-indicator/) */ const Menu2RadioItemIndicator = React.forwardRef(function Menu2RadioItemIndicator( inProps: Menu2RadioItemIndicatorProps, diff --git a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx index 035b2d3e197cc2..6aa3cea1506be4 100644 --- a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx @@ -94,10 +94,6 @@ const Menu2SeparatorRoot = styled(Divider, { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2Separator API](https://mui.com/material-ui/api/menu-preview-separator/) */ const Menu2Separator = React.forwardRef(function Menu2Separator( inProps: Menu2SeparatorProps, diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx index 38d7853ff39452..eaabffb3f8b019 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx @@ -202,10 +202,6 @@ const Menu2SubmenuPopupList = styled(List, { * Demos: * * - [Menu](https://mui.com/material-ui/react-menu/) - * - * API: - * - * - [Menu2SubmenuPopup API](https://mui.com/material-ui/api/menu-preview-submenu-popup/) */ const Menu2SubmenuPopup = React.forwardRef(function Menu2SubmenuPopup( inProps: Menu2SubmenuPopupProps, diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx index 97dfbbadec9603..a918fc7ca0890c 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx +++ b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx @@ -6,9 +6,9 @@ import { useDefaultProps } from '../DefaultPropsProvider'; /** * Inherits the full Base UI `Menu.SubmenuRoot` prop surface (open/close - * control, hover-open with delays, `closeParentOnEsc`, keyboard behavior). - * `Omit` (a mapped type) is used instead of bare `extends` so the proptypes - * generator resolves the inherited members. + * control, `closeParentOnEsc`, keyboard behavior); hover-open props live on + * the submenu trigger. `Omit` (a mapped type) is used instead of bare + * `extends` so the proptypes generator resolves the inherited members. */ export interface Menu2SubmenuRootProps extends Omit { /** @@ -22,10 +22,6 @@ export interface Menu2SubmenuRootProps extends Omit Date: Fri, 24 Jul 2026 15:57:49 +0300 Subject: [PATCH 16/62] Remove leftover menu-preview API page components The docs tooling cleanup removed the generated API JSONs and translations but left the sibling page components in docs/pages/material-ui/api/, whose imports of the deleted JSONs broke the Netlify docs build (webpack module-not-found). Verified with the full Netlify-parity build locally: pnpm docs:build (llms + next build + export + build-sw + link-check) exits 0. Co-Authored-By: Claude Fable 5 --- .../api/menu-preview-checkbox-item-indicator.js | 8 -------- docs/pages/material-ui/api/menu-preview-checkbox-item.js | 8 -------- docs/pages/material-ui/api/menu-preview-group-label.js | 8 -------- docs/pages/material-ui/api/menu-preview-group.js | 8 -------- docs/pages/material-ui/api/menu-preview-item.js | 8 -------- docs/pages/material-ui/api/menu-preview-link-item.js | 8 -------- docs/pages/material-ui/api/menu-preview-popup.js | 8 -------- docs/pages/material-ui/api/menu-preview-radio-group.js | 8 -------- .../material-ui/api/menu-preview-radio-item-indicator.js | 8 -------- docs/pages/material-ui/api/menu-preview-radio-item.js | 8 -------- docs/pages/material-ui/api/menu-preview-separator.js | 8 -------- docs/pages/material-ui/api/menu-preview-submenu-popup.js | 8 -------- docs/pages/material-ui/api/menu-preview-submenu-root.js | 8 -------- .../pages/material-ui/api/menu-preview-submenu-trigger.js | 8 -------- docs/pages/material-ui/api/menu-preview-trigger.js | 8 -------- docs/pages/material-ui/api/menu-preview.js | 8 -------- 16 files changed, 128 deletions(-) delete mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js delete mode 100644 docs/pages/material-ui/api/menu-preview-checkbox-item.js delete mode 100644 docs/pages/material-ui/api/menu-preview-group-label.js delete mode 100644 docs/pages/material-ui/api/menu-preview-group.js delete mode 100644 docs/pages/material-ui/api/menu-preview-item.js delete mode 100644 docs/pages/material-ui/api/menu-preview-link-item.js delete mode 100644 docs/pages/material-ui/api/menu-preview-popup.js delete mode 100644 docs/pages/material-ui/api/menu-preview-radio-group.js delete mode 100644 docs/pages/material-ui/api/menu-preview-radio-item-indicator.js delete mode 100644 docs/pages/material-ui/api/menu-preview-radio-item.js delete mode 100644 docs/pages/material-ui/api/menu-preview-separator.js delete mode 100644 docs/pages/material-ui/api/menu-preview-submenu-popup.js delete mode 100644 docs/pages/material-ui/api/menu-preview-submenu-root.js delete mode 100644 docs/pages/material-ui/api/menu-preview-submenu-trigger.js delete mode 100644 docs/pages/material-ui/api/menu-preview-trigger.js delete mode 100644 docs/pages/material-ui/api/menu-preview.js diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js b/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js deleted file mode 100644 index 0fc1bd2331944b..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item-indicator.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-checkbox-item-indicator/menu-preview-checkbox-item-indicator.json'; -import jsonPageContent from './menu-preview-checkbox-item-indicator.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-checkbox-item.js b/docs/pages/material-ui/api/menu-preview-checkbox-item.js deleted file mode 100644 index 0f2c3bb92fb575..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-checkbox-item.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-checkbox-item/menu-preview-checkbox-item.json'; -import jsonPageContent from './menu-preview-checkbox-item.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-group-label.js b/docs/pages/material-ui/api/menu-preview-group-label.js deleted file mode 100644 index 8c2b8cbdec3e7b..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-group-label.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-group-label/menu-preview-group-label.json'; -import jsonPageContent from './menu-preview-group-label.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-group.js b/docs/pages/material-ui/api/menu-preview-group.js deleted file mode 100644 index e4ba5947235e0a..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-group.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-group/menu-preview-group.json'; -import jsonPageContent from './menu-preview-group.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-item.js b/docs/pages/material-ui/api/menu-preview-item.js deleted file mode 100644 index 9b5ba881bc9d25..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-item.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-item/menu-preview-item.json'; -import jsonPageContent from './menu-preview-item.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-link-item.js b/docs/pages/material-ui/api/menu-preview-link-item.js deleted file mode 100644 index 9f12334dbbafee..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-link-item.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-link-item/menu-preview-link-item.json'; -import jsonPageContent from './menu-preview-link-item.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-popup.js b/docs/pages/material-ui/api/menu-preview-popup.js deleted file mode 100644 index 127dc9feed6913..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-popup.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-popup/menu-preview-popup.json'; -import jsonPageContent from './menu-preview-popup.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-group.js b/docs/pages/material-ui/api/menu-preview-radio-group.js deleted file mode 100644 index dd1bbd6501522b..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-radio-group.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-radio-group/menu-preview-radio-group.json'; -import jsonPageContent from './menu-preview-radio-group.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js b/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js deleted file mode 100644 index 700de197f4f9bb..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-radio-item-indicator.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-radio-item-indicator/menu-preview-radio-item-indicator.json'; -import jsonPageContent from './menu-preview-radio-item-indicator.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-radio-item.js b/docs/pages/material-ui/api/menu-preview-radio-item.js deleted file mode 100644 index 8f58a8bd490986..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-radio-item.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-radio-item/menu-preview-radio-item.json'; -import jsonPageContent from './menu-preview-radio-item.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-separator.js b/docs/pages/material-ui/api/menu-preview-separator.js deleted file mode 100644 index 46e88927598939..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-separator.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-separator/menu-preview-separator.json'; -import jsonPageContent from './menu-preview-separator.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-popup.js b/docs/pages/material-ui/api/menu-preview-submenu-popup.js deleted file mode 100644 index b9e2198291d25d..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-submenu-popup.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-submenu-popup/menu-preview-submenu-popup.json'; -import jsonPageContent from './menu-preview-submenu-popup.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-root.js b/docs/pages/material-ui/api/menu-preview-submenu-root.js deleted file mode 100644 index 71117c38788e95..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-submenu-root.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-submenu-root/menu-preview-submenu-root.json'; -import jsonPageContent from './menu-preview-submenu-root.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-submenu-trigger.js b/docs/pages/material-ui/api/menu-preview-submenu-trigger.js deleted file mode 100644 index 64d99de2e3afc7..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-submenu-trigger.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-submenu-trigger/menu-preview-submenu-trigger.json'; -import jsonPageContent from './menu-preview-submenu-trigger.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview-trigger.js b/docs/pages/material-ui/api/menu-preview-trigger.js deleted file mode 100644 index 445e0f871bd7be..00000000000000 --- a/docs/pages/material-ui/api/menu-preview-trigger.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview-trigger/menu-preview-trigger.json'; -import jsonPageContent from './menu-preview-trigger.json'; - -export default function Page() { - return ; -} diff --git a/docs/pages/material-ui/api/menu-preview.js b/docs/pages/material-ui/api/menu-preview.js deleted file mode 100644 index 9ed07a5cc58816..00000000000000 --- a/docs/pages/material-ui/api/menu-preview.js +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react'; -import { ApiPage } from '@mui/internal-core-docs/ApiPage'; -import descriptions from 'docs/translations/api-docs/menu-preview/menu-preview.json'; -import jsonPageContent from './menu-preview.json'; - -export default function Page() { - return ; -} From b5a80ed79f9fa5a05728a3fb8e6f2086f25f1d15 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 17:23:28 +0300 Subject: [PATCH 17/62] Use Menu2 bindings in demo and spec JSX The Unstable_ subpaths use default exports, so consumers naturally drop the prefix at the import binding. The experiment pages and the type spec now import Menu2* names directly (paths keep the Unstable_ subpaths) and the JSX reads like the future stable API; the local alias blocks are gone. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu-preview.tsx | 367 +++++++++--------- docs/pages/experiments/menu-rfc.tsx | 237 ++++++----- .../src/Unstable_Menu2/Menu2.spec.tsx | 104 ++--- 3 files changed, 339 insertions(+), 369 deletions(-) diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx index 7edf5d139a54ff..0498d6f2ec4f9c 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu-preview.tsx @@ -8,43 +8,26 @@ import Typography from '@mui/material/Typography'; import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; -import Unstable_Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; -import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; -import Unstable_Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; -import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; -import Unstable_Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; -import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; -import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Unstable_Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; -import Unstable_Menu2Separator from '@mui/material/Unstable_Menu2Separator'; -import Unstable_Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; -import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Unstable_Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +// The Unstable_ subpaths use default exports, so the local bindings drop the +// prefix and the JSX mirrors the future stable names. +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; -// Local aliases for demo readability; the import lines above reflect the real -// one-component-per-subpath ergonomics (no Base UI-style short aliases). -const Menu = Unstable_Menu2; -const CheckboxItem = Unstable_Menu2CheckboxItem; -const CheckboxItemIndicator = Unstable_Menu2CheckboxItemIndicator; -const Group = Unstable_Menu2Group; -const GroupLabel = Unstable_Menu2GroupLabel; -const Item = Unstable_Menu2Item; -const LinkItem = Unstable_Menu2LinkItem; -const Popup = Unstable_Menu2Popup; -const RadioGroup = Unstable_Menu2RadioGroup; -const RadioItem = Unstable_Menu2RadioItem; -const RadioItemIndicator = Unstable_Menu2RadioItemIndicator; -const Separator = Unstable_Menu2Separator; -const SubmenuPopup = Unstable_Menu2SubmenuPopup; -const SubmenuRoot = Unstable_Menu2SubmenuRoot; -const SubmenuTrigger = Unstable_Menu2SubmenuTrigger; -const Trigger = Unstable_Menu2Trigger; - interface MenuSettings { modal: boolean; disabled: boolean; @@ -284,7 +267,7 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove }; return ( - { if (!open) { setActiveItemId(null); @@ -292,42 +275,42 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove } }} > - }> + }> Help cards - - - + + {rootPreviewCardItems[0].label} - - - + + Version history - - + + {versionHistoryPreviewCardItems.map((item) => ( - + {item.label} - + ))} - - - + + {rootPreviewCardItems[1].label} - - + + - + ); } @@ -338,115 +321,115 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { }, []); return ( - - }> + + }> File - - - New document - Open… - Template gallery - Recent documents - Docs help center - Make a copy - - + + + New document + Open… + Template gallery + Recent documents + Docs help center + Make a copy + + Rename document - - + + Offline editing unavailable - - + + - - + + View options - - - - Document display - - - + + + + Document display + + + 100% - - - + + + Fit - - - + + + Page width - - - + + + Custom zoom unavailable - - - + + + - + - - Show - - + + Show + + Ruler - - - + + + Document outline - - - + + + Line numbers - - - + + + Page breaks unavailable - - + + - + - - + + More tools - - - Word count - Dictionary - Accessibility settings - - - - - - - + + + Word count + Dictionary + Accessibility settings + + + + + + + Download - - - Microsoft Word (.docx) - PDF document (.pdf) - Plain text (.txt) - - - - - + + + Microsoft Word (.docx) + PDF document (.pdf) + Plain text (.txt) + + + + + Add-ons unavailable - - - Marketplace - - - - + + + Marketplace + + + + ); } @@ -474,75 +457,75 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b ); return ( - - }> + + }> Tools - - + + - New document + New document - Open recent + Open recent - Make a copy + Make a copy - Import from Drive + Import from Drive - Share with people + Share with people - + - + - + View options - + - - - Show + + + Show - - + + Comments - + - - + + Page breaks - + - + - + - - Zoom - + + Zoom + - - + + Fit - + - - + + Custom - + - - - - - - + + + + + + ); } @@ -576,7 +559,7 @@ function Menu2ContextMenuRecipe() { setAnchor(null); }; - const handleOpenChange: React.ComponentProps['onOpenChange'] = ( + const handleOpenChange: React.ComponentProps['onOpenChange'] = ( nextOpen, eventDetails, ) => { @@ -607,14 +590,14 @@ function Menu2ContextMenuRecipe() { facilisis neque enim sed neque. Quisque accumsan metus vel maximus consequat. Suspendisse lacinia tellus a libero volutpat maximus. - - - Copy - Print - Highlight - Email - - + + + Copy + Print + Highlight + Email + + ); } @@ -634,7 +617,7 @@ export default function Menu2Experiment() { return ( - + @@ -673,20 +656,18 @@ export default function Menu2Experiment() {
- +

Material UI Tooltip integrated with every menu item.

- +

Material UI Popover used as a PreviewCard-style menu item help card.

- -

Right-click the text to open a cursor-positioned Unstable_Menu2 popup.

+ +

Right-click the text to open a cursor-positioned Menu2 popup.

Base UI Menu API diff --git a/docs/pages/experiments/menu-rfc.tsx b/docs/pages/experiments/menu-rfc.tsx index f802a9981d3217..98c78990fd1931 100644 --- a/docs/pages/experiments/menu-rfc.tsx +++ b/docs/pages/experiments/menu-rfc.tsx @@ -13,45 +13,28 @@ import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRigh import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; import { ThemeProvider, createTheme, type SxProps, type Theme } from '@mui/material/styles'; import { DirectionProvider } from '@base-ui/react/direction-provider'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; -import Unstable_Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; -import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; -import Unstable_Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; -import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; -import Unstable_Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; -import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; -import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Unstable_Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; -import Unstable_Menu2Separator from '@mui/material/Unstable_Menu2Separator'; -import Unstable_Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; -import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Unstable_Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +// The Unstable_ subpaths use default exports, so the local bindings drop the +// prefix and the JSX mirrors the future stable names. +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; -// Local aliases for demo readability; the import lines above reflect the real -// one-component-per-subpath ergonomics (no Base UI-style short aliases). -const Menu = Unstable_Menu2; -const CheckboxItem = Unstable_Menu2CheckboxItem; -const CheckboxItemIndicator = Unstable_Menu2CheckboxItemIndicator; -const Group = Unstable_Menu2Group; -const GroupLabel = Unstable_Menu2GroupLabel; -const Item = Unstable_Menu2Item; -const LinkItem = Unstable_Menu2LinkItem; -const Popup = Unstable_Menu2Popup; -const RadioGroup = Unstable_Menu2RadioGroup; -const RadioItem = Unstable_Menu2RadioItem; -const RadioItemIndicator = Unstable_Menu2RadioItemIndicator; -const Separator = Unstable_Menu2Separator; -const SubmenuPopup = Unstable_Menu2SubmenuPopup; -const SubmenuRoot = Unstable_Menu2SubmenuRoot; -const SubmenuTrigger = Unstable_Menu2SubmenuTrigger; -const Trigger = Unstable_Menu2Trigger; - -type MenuProps = React.ComponentProps; -type PopupProps = React.ComponentProps; +type MenuProps = React.ComponentProps; +type PopupProps = React.ComponentProps; type PopupSide = NonNullable; type PopupAlign = NonNullable; @@ -164,21 +147,21 @@ function PlaygroundDemo({ }; return ( - - } > Project - - + - - Actions - + + Actions + New file - - + + Duplicate - - + + Archive (disabled) - - - + + + - - + + Share - - - + + + Email - - + + Copy link - - - + + + Export as - - - - - + + + + + PDF document - - - + + + EPUB publication - - - + + + Markdown - - - - - - - - - + + + + + + + + + View - - - - + + + + Show ruler - - - + + + Show outline - - - - + + + + - + Selected item (visual-only) - - + + Menu documentation - - - + + + ); } @@ -308,23 +291,26 @@ function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings ))} - - }> + + } + > Successor - - + + {parityItems.map((item) => ( - {item.label} - + ))} - - + +
); } @@ -348,13 +334,13 @@ function ControlledAnchorDemo() { > Open (controlled) - - - setAnchorEl(null)}>Profile - setAnchorEl(null)}>My account - setAnchorEl(null)}>Logout - - + + + setAnchorEl(null)}>Profile + setAnchorEl(null)}>My account + setAnchorEl(null)}>Logout + + ); } @@ -396,18 +382,21 @@ const typeaheadEntries = [ function TypeaheadScrollDemo() { return ( - - }> + + }> Country - - + + {typeaheadEntries.map((entry) => ( - + {entry} - + ))} - - + + ); } @@ -557,8 +546,8 @@ export default function MenuRfcExperiment() { Companion experiment for the Menu successor RFC. Every knob maps to a prop or an RFC open question. See also the{' '} - Unstable_Menu2 experiment for - Tooltip, PreviewCard, and ContextMenu recipes. + Menu2 experiment for Tooltip, + PreviewCard, and ContextMenu recipes. @@ -612,7 +601,7 @@ export default function MenuRfcExperiment() {

Classic-style controlled usage

- No Trigger part: external anchor element plus controlled open /{' '} + No Menu2Trigger part: external anchor element plus controlled open /{' '} onOpenChange, approximating the classic anchorEl pattern.

diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx index d312b26a197e23..012e77f1fd923b 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -1,28 +1,28 @@ import * as React from 'react'; import { expectType } from '@mui/types'; -import Menu from '@mui/material/Unstable_Menu2'; -import CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; -import CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; -import Group from '@mui/material/Unstable_Menu2Group'; -import GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; -import Item from '@mui/material/Unstable_Menu2Item'; -import LinkItem from '@mui/material/Unstable_Menu2LinkItem'; -import Popup from '@mui/material/Unstable_Menu2Popup'; -import RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; -import RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; -import Separator from '@mui/material/Unstable_Menu2Separator'; -import SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; -import SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Trigger from '@mui/material/Unstable_Menu2Trigger'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; import { createTheme } from '@mui/material/styles'; // @ts-expect-error Menu2 is intentionally not exported from the root barrel for this POC. import { Menu2 as RootBarrelMenu2 } from '@mui/material'; function Menu2Composition() { return ( - { @@ -31,10 +31,10 @@ function Menu2Composition() { eventDetails.preventUnmountOnClose(); }} > - + Options - - + - - Group - - Item - - Profile - + Menu2Group + + Menu2Item + + Profile + { @@ -69,10 +69,10 @@ function Menu2Composition() { eventDetails.cancel(); }} > - + Checkbox - - + { expectType(event); @@ -80,28 +80,28 @@ function Menu2Composition() { eventDetails.cancel(); }} > - - + + One - - - - + + + { expectType(open); eventDetails.cancel(); }} > - + More - - - Nested - - - - - + + + Nested + + + + + ); } @@ -167,33 +167,33 @@ createTheme({ }, }); -; -; -} > Options -; +; - Options -; +; - Date: Fri, 24 Jul 2026 17:42:27 +0300 Subject: [PATCH 18/62] Fix context menu recipe restoring focus to a stale menu trigger A detached (virtual-anchor) menu has no trigger to return focus to on close, and right-clicking text blurs the previously focused element to body, so Base UI's focus manager fell back to its internal previously- focused-element record -- typically the trigger of whichever menu was used earlier on the page. Closing the context menu with Escape then focused an unrelated demo's trigger. Follow the APG context-menu pattern instead: the invoked surface gets tabIndex={-1} and is passed as the popup's finalFocus. The regression test seeds the stale record with another menu, opens the detached menu via contextmenu, and asserts Escape restores focus to the invoked surface (fails without finalFocus). Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu-preview.tsx | 14 +++- .../src/Unstable_Menu2/Menu2.test.tsx | 78 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx index 0498d6f2ec4f9c..2690587f232e10 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu-preview.tsx @@ -532,6 +532,7 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b function Menu2ContextMenuRecipe() { const [anchor, setAnchor] = React.useState | null>(null); const open = anchor !== null; + const contextAreaRef = React.useRef(null); const handleContextMenu = (event: React.MouseEvent) => { event.preventDefault(); @@ -580,7 +581,16 @@ function Menu2ContextMenuRecipe() { }; return ( -
+ // tabIndex={-1} makes the invoked surface a valid focus-restore target. A + // detached menu has no trigger to return focus to, and Base UI's fallback + // is its internal "previously focused element" record, which can point at + // an unrelated menu trigger from an earlier interaction. +
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ipsum purus, bibendum sit amet vulputate eget, porta semper ligula. Donec bibendum vulputate erat, ac fringilla mi @@ -591,7 +601,7 @@ function Menu2ContextMenuRecipe() { lacinia tellus a libero volutpat maximus. - + Copy Print Highlight diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index f1288788fe2785..9cff470240995f 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; -import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; +import { createRenderer, fireEvent, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; import { listClasses } from '@mui/material/List'; import { paperClasses } from '@mui/material/Paper'; import Tooltip from '@mui/material/Tooltip'; @@ -844,6 +844,82 @@ describe('', () => { expect(screen.getByRole('menuitem', { name: 'Archive' })).to.not.equal(null); }); + it.skipIf(isJsdom())( + 'restores focus to finalFocus when a detached context menu closes', + async () => { + function ContextMenuHarness() { + const [anchor, setAnchor] = React.useState<{ getBoundingClientRect: () => DOMRect } | null>( + null, + ); + const areaRef = React.useRef(null); + + return ( +
{ + event.preventDefault(); + const { clientX, clientY } = event; + setAnchor({ + getBoundingClientRect: () => + DOMRect.fromRect({ x: clientX, y: clientY, width: 0, height: 0 }), + }); + }} + > + Context area + { + if (!nextOpen) { + setAnchor(null); + } + }} + > + + Copy + + +
+ ); + } + + const { user } = render( + + + Other menu + + Other item + + + + , + ); + + // Seed Base UI's internal previously-focused record with an unrelated + // trigger by opening and closing that menu first. + const otherTrigger = screen.getByRole('button', { name: 'Other menu' }); + await user.click(otherTrigger); + await screen.findByRole('menuitem', { name: 'Other item' }); + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(otherTrigger).toHaveFocus(); + }); + + // A detached menu has no trigger; without finalFocus, closing it restores + // focus to that stale record instead of the invoked surface. + const area = screen.getByTestId('context-area'); + fireEvent.contextMenu(area, { clientX: 100, clientY: 100 }); + await screen.findByRole('menuitem', { name: 'Copy' }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(area).toHaveFocus(); + }); + }, + ); + it.skipIf(isJsdom())('keeps separator spacing stable while a submenu is open', async () => { const { user } = render( From b6b2b2b28876834c15ec5f427219afea5cc19be7 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 24 Jul 2026 23:17:52 +0300 Subject: [PATCH 19/62] Track the Menu successor RFC draft in the PR Versions the living RFC draft next to the component so revisions land atomically with the discoveries that motivate them and reviewers can comment line-by-line. Formatted per repo markdown conventions (vale clean); the file is removed when the RFC is posted publicly. Co-Authored-By: Claude Fable 5 --- .../mui-material/src/Unstable_Menu2/RFC.md | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 packages/mui-material/src/Unstable_Menu2/RFC.md diff --git a/packages/mui-material/src/Unstable_Menu2/RFC.md b/packages/mui-material/src/Unstable_Menu2/RFC.md new file mode 100644 index 00000000000000..4d8258ea7c8e51 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/RFC.md @@ -0,0 +1,344 @@ +# RFC draft: Menu successor with submenu support + +Living draft tracked in this PR until the RFC is posted publicly; review comments welcome on this file. + +Suggested issue title: `[RFC] Menu: Base UI-based successor with submenu support` + +Structured for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below into the matching form field. + +--- + +## What's the problem? + +Material UI's `Menu` cannot express submenus (nested menus): + +- It is one of the oldest and most-requested features: https://github.com/mui/material-ui/issues/11723 has been open since 2018 with 120+ reactions (plus duplicates such as https://github.com/mui/material-ui/issues/8152). +- Material UI v0.x supported nested menus (https://github.com/mui/material-ui/pull/2148); the capability was lost in the v1 rewrite and never recovered. +- Community workarounds (`material-ui-nested-menu-item`, `mui-nested-menu`, `material-ui-popup-state` recipes, many sandboxes) are consistently incomplete on keyboard navigation and ARIA, which maintainers have called out repeatedly in the issue threads. +- The Menubar docs page already ships Base UI-composed submenus as copy-paste code, and users immediately asked for a maintained, in-package component (https://github.com/mui/material-ui/issues/48336). Copy-paste code is not versioned, tested, or theme-integrated. + +Desired outcome: first-class, accessible submenu support in `@mui/material` -- with Material visuals and full theming -- without destabilizing the existing `Menu`, and on a path to becoming the default `Menu` in the next major. + +Beyond the Menu itself, this RFC pilots the standards for how future Material UI components are built on top of Base UI (customization contract, styling reuse, dependency shape, testing, tooling). Menu is the reference implementation; the cross-cutting decisions below are meant to apply to every Base UI-backed component that follows. + +## What are the requirements? + +1. Correct WAI-ARIA menu pattern behavior across nesting levels: trigger semantics (`aria-haspopup`/`aria-expanded`), RTL-aware ArrowRight/ArrowLeft submenu navigation, Escape close ordering, focus restored to the parent trigger item on close, typeahead scoped per level, arbitrary nesting depth. +2. Production-grade pointer UX: safe-polygon hover intent ("safe triangle") and hover-open with configurable delays -- explicitly the bar that past attempts failed to clear. +3. Collision-aware positioning with automatic anchor tracking: submenus flip at viewport edges instead of clipping. +4. Pixel parity with the existing `Menu`/`MenuItem` visuals, and full theming integration: `sx`, `classes`, `component`, `slots`/`slotProps`, theme `defaultProps`/`styleOverrides`/`variants`. +5. Zero cost and zero risk for existing users: the classic `Menu` keeps working unchanged, and apps that do not import the new component pay no bundle or behavior cost. +6. API continuity with the classic `Menu` where the underlying model allows (item-level props, `container`, `keepMounted`), with deliberate and documented divergence where it does not (open/close control, positioning, transitions). +7. Cover the adjacent long-requested menu capabilities in the same API so it does not need reshaping later: checkbox/radio items, groups, hover-open menus, context-menu (cursor) positioning. +8. A credible graduation path: the component becomes `Menu` in the next major with a migration guide and codemods where feasible, so preview adopters are not stranded. +9. Sustainable maintenance: reuse a maintained primitive rather than re-implementing focus, dismissal, and positioning machinery in this repo. +10. Cross-cutting indistinguishability: the component must look indistinguishable from a normal Material UI component in tooling, theming, imports, and tests. The only new thing is the Base UI behavior substrate underneath. Users should not need to know Base UI is involved, nor install anything extra. + +## What are our options? + +### Option A: Add submenus to the existing Menu stack + +This has been attempted three times over eight years; each attempt got further than the last and hit the same walls: + +- https://github.com/mui/material-ui/pull/14700 (2019, +267 lines): recursive Menu-in-Menu. Closed with "It's something we will want to solve at the core level. I'm pretty sure we need to change the menu implementation and to expose new objects to make it happen." +- https://github.com/mui/material-ui/pull/20591 (2020-2022, +1333 lines, ~22 months of review): `subMenu` prop on `MenuItem` implemented inside core via `cloneElement`. Stalled on a compound of blockers: a double-digit relative gzip increase to the core bundle, hard UX requirements (safe-triangle hover intent, collision-aware placement -- blocked on Popover internals), test-infrastructure churn, and the risk of destabilizing a core component right before v5. The closing review rejected the `cloneElement` approach and concluded the path forward was to rebuild on headless menu primitives. +- https://github.com/mui/material-ui/pull/37570 (2023-2024): docs-demo-only approach. Closed after an accessibility review found fundamental gaps (Escape handling, `aria-expanded`, screen reader announcements, close ordering), with the explicit direction: "I think it would make more sense to focus on bringing this to Base UI." + +The failures are structural, not incidental. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal -> FocusTrap/Backdrop/ModalManager`), and nesting two of them fights the stack in at least six places: + +1. Dismissal model: each menu renders an invisible full-screen backdrop that captures clicks. A submenu's backdrop stacks above the parent's paper, making parent items non-interactive and closing the child on any parent click. Fixing this means replacing backdrop dismissal with a coordinated click-away model across the whole menu tree. +2. `ModalManager` sets `aria-hidden="true"` on all body children except the top-most modal -- opening a submenu removes the parent menu from the accessibility tree. +3. Keyboard: the roving tabindex handler treats ArrowRight/ArrowLeft as no-ops for vertical lists, and `MenuList`/`MenuItem` expose no hook point for "open submenu on ArrowRight" or trigger semantics. +4. Focus: each modal has its own focus trap and per-trap restore target; closing a submenu must restore focus to the parent trigger item, which the per-trap model does not coordinate. +5. Positioning: `Popover` has no collision flipping -- a right-opening submenu near the viewport edge clips instead of flipping to the other side. +6. Each `MenuList` owns an isolated keyboard/typeahead registry; nested lists share no active-item model. + +Meeting requirements 1-3 this way means touching `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`, and replacing two load-bearing models (backdrop dismissal, per-modal focus trapping) shared with `Dialog` and every `Popover` consumer. That is a re-implementation of exactly the machinery Base UI's `Menu` already ships, with disproportionate regression risk, as throwaway work ahead of the next major. Rejected. + +### Option B: Keep it as copy-paste docs composition (like the Menubar page) + +Rejected as the end state: unversioned and untested code with no theming contract cannot be the first-class answer to an 8-year-old feature request, and users have already asked for the packaged component (https://github.com/mui/material-ui/issues/48336). + +### Option C: Wait for the next major rewrite + +Rejected: demand has waited since 2018, and shipping a public unstable component now battle-tests the API so the next major's `Menu` lands already validated instead of freshly designed. + +### Option D: Successor component built on Base UI, shipped as public unstable (proposed) + +Base UI's `Menu` (`@base-ui/react`, stable 1.x since early 2026, maintained by the same organization) covers requirements 1-3 out of the box, verified against its source and test suites: safe-polygon hover intent on submenu triggers (`openOnHover` default `true`, `delay` 100ms, `safePolygon` close handler), RTL-aware submenu keyboard navigation (parametrized ltr/rtl open/close key tests), Escape closing the innermost submenu by default (`closeParentOnEsc`, default `false`), focus returned to the parent trigger item on close (asserted in tests), per-level typeahead, and collision avoidance defaulting to flip with automatic anchor tracking. Material UI's job reduces to styling, theming, and API surface -- detailed below. + +## Proposed solution + +Introduce a Base UI-based successor to `Menu`, positioned as "Menu v2" and following the Grid lifecycle precedent. A proof of concept validates feasibility: https://github.com/mui/material-ui/pull/48663 (live demo: https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/), and a companion playground exercises the open questions: https://github.com/mui/material-ui/pull/48823. + +### Positioning and lifecycle (decided) + +The new component is a successor, not an in-place reimplementation of the legacy internals and not a permanently parallel namespace: + +| Phase | Component name | What happens | +| --------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Now (v9 minors) | `Unstable_Menu2` | Public incubation -- a real release, not docs-only. Theme keys and classes are `MuiMenu2*` (see the naming note below). | +| Later in v9 | `Menu2` | Stabilized under the interim name; users adopt. Legacy `Menu` untouched; theme keys unchanged. | +| Next major | `Menu` | `Menu2` promoted to the canonical name. | +| Next major | `MenuLegacy` | Old `Menu` renamed, deprecated; codemod provided. | + +Precedent: Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, with the old component renamed `GridLegacy`, see https://github.com/mui/material-ui/pull/45363). The rename at each step is a breaking change for early adopters, but it is codemoddable and Material UI has accepted this trade before. The `2`-suffixed interim name is what makes a stable pre-major phase possible at all: an unsuffixed name would collide with the still-shipping legacy `Menu` until the major. + +Naming note: only directories, subpaths, and export names carry the `Unstable_` prefix. Internal identifiers use clean `Menu2*` names and theme keys/classes use `MuiMenu2*` -- both enforced by the repo's naming-convention and name-matches-component lint rules, and both matching the Grid2 precedent (`Unstable_Grid2` used `MuiGrid2` keys). A side benefit: theme keys and classes written against the unstable component survive the `Unstable_Menu2` -> `Menu2` stabilization unchanged; only the final `Menu2` -> `Menu` promotion renames them (codemod). + +Import ergonomics follow the existing convention -- flat-named components, one component per subpath, no Base UI-style short aliases (`Root`/`Item`/`Trigger`): + +```jsx +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +``` + +Since the subpaths use default exports, adopters can locally drop the `Unstable_` prefix, as above -- the JSX then already reads like the future stable API. Root barrel exports are added at graduation (the experiment is deliberately subpath-only). + +Graduation is gated by a fixed checklist, not judgment calls: the legacy `describeConformance` suite passing minus a documented skip list, theme-registration parity, the pinned `data-*` conformance boundary (see dependency riders below), and design/API sign-off. + +### Standards for Base UI-backed components (Menu is the pilot) + +Meta-principle: a Base UI-backed component must be indistinguishable from a normal Material UI component in every cross-cutting concern. Decided: + +- Customization contract: `slots`/`slotProps` is the user-facing mechanism, as in every existing component. Base UI's `render` prop is used internally by Material UI to inject its styled elements (for example the trigger rendering a Material `Button`), and remains a last-resort escape hatch -- not the documented contract. +- Slot plumbing: reuse the `@mui/utils` slot utilities (`useSlotProps`/`mergeSlotProps`/`appendOwnerState`); extend them in place if genuine Base UI `render`-bridging gaps appear. No bespoke per-component bridge layers. +- Styling reuse with the legacy component: share the actual styled element by passing it through Base UI's `render` internally (the model used by the Menubar docs components), falling back to a shared style function where element injection is impractical. The experiment already implements the fallback: classic `Menu`/`MenuItem` and the new parts consume the same extracted style modules, so there is a single source of visual truth. The remaining work is upgrading to element-level sharing where it fits; free-floating copies are not acceptable. Caution learned from two shipped regressions: extracted classic styles can embed DOM-context assumptions that silently change meaning under the Base UI structure -- a `maxHeight: calc(100% - 96px)` whose `100%` meant the viewport inside the classic full-screen Modal but resolved against the content-sized popup, and an `[item] + divider` adjacency margin that broke when Base UI mounted inline focus-guard nodes next to an open submenu trigger. Shared style functions need a per-consumer audit for positional values and structural selectors, and parts should own their own spacing instead of relying on sibling combinators. +- Material presentational props are preserved (`dense`, `disableGutters`, `divider`, `inset`, `selected`): Material UI owns presentation, Base UI owns behavior. The line is styling-vs-functionality, not old-vs-new; individual long-tail props can still be dropped case-by-case. +- Dependency shape: `@base-ui/react` becomes a direct dependency of `@mui/material` (caret range), like `@popperjs/core` -- an implementation detail users never install or import directly. Two riders: (a) Base UI version bumps are deliberate, reviewed events, never auto-merged; (b) conformance tests pin the `data-*` attribute surface we consume, so an upstream rename or removal fails CI instead of silently regressing styles. +- Docs and API tooling: existing infrastructure is a fixed constraint; the component conforms to it. If a component cannot be documented without tooling changes, that is a signal about the component. +- Theme registration: standard `defaultProps`/`styleOverrides`/`variants` registration per part, under standard keys (`MuiMenu2*`, see the naming note above) -- no special rule for Base UI parts. + +Proposed, awaiting team reaction: + +- Styling state source of truth (hybrid): `Mui-*` classes + `ownerState` remain the public contract that `styleOverrides`/`variants`/`sx` are written against; internal styles may read Base UI `data-*` attributes for positional or transient state (precedent: Tooltip's `[data-popper-placement]` selectors). Rule of thumb: state users theme or that appears in the documented API gets a class; purely positional/transient internal state stays `data-*`-only. The experiment already exercises the internal side of this rule: the popup surface consumes the positioner-provided `--available-height` variable for its collision-aware max-height. +- Prop surface typing: `extends` the Base UI prop types with `Omit` for curated or renamed props -- inheritance by default (no drift as Base UI evolves), curation as an explicit, documented list. Consequence: callback signatures follow Base UI by default, for example `onOpenChange(open, eventDetails)` rather than the legacy event-first `onClose(event, reason)`. Validated in the experiment (renderless roots, flattened popup): the pattern type-checks, the spec's negative assertions hold, and the flattened container inherits its hoisted positioner/portal surface via `Pick`. One confirmed limitation: the proptypes generator does not expand members declared in `node_modules`, so runtime PropTypes on inherited props degrade to the locally declared ones (types still carry the full contract; `remove-proptypes` strips them in production anyway). Teaching the generator to expand external heritage is a shared-infra follow-up; until then the trade is inherited types + reduced dev-mode runtime validation. +- Testing: reuse the existing harnesses on two fronts -- `describeConformance` for the Material UI contract (ref, className, `sx`, theme `styleOverrides`), and the legacy Menu behavior suite (keyboard navigation, open/close, focus) rerun against the successor. Every skip is annotated with why it is incompatible under the Base UI model. Parity is proven by the same tests passing, not by new bespoke tests. + +### API shape (pending a dedicated design phase) + +The agreed rules for the shape, replacing a global flat-vs-compound choice with a per-part split: + +- Keep the public API as close to the legacy `Menu` as the substrate allows; users should not need to know Base UI is involved. +- Structural/plumbing parts (Portal, Positioner, Popup, Paper, List) are bundled into a flat container component and exposed via `slots`/`slotProps` -- they exist for wiring, not day-to-day composition. +- Customization-heavy parts stay standalone components (`MenuItem`-like parts, submenu triggers, checkbox/radio items) -- they need per-instance children and props and cannot be buried in a container. + +Hard precondition before the design is finalized: a behavior benchmark diffing the Material UI Menu against the Base UI Menu from the user's perspective (open/close semantics, focus behavior, keyboard model, dismissal, positioning defaults). The benchmark gates the design -- how close to drop-in the successor API can be -- not the positioning, which is decided above. + +Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): + +```jsx + + Cut + {/* strawman: submenu as a nested container owned by a standalone trigger item */} + + + Email + Copy link + + + +``` + +Behavior notes worth stating explicitly regardless of final shape: + +- Submenus open on hover by default in Base UI (`openOnHover` default `true`, `delay` 100ms, safe-polygon close). This is new behavior the classic Menu never had; it matches native OS menus, and it is configurable. +- Escape closes the innermost submenu and returns focus to its trigger item (APG behavior); closing the whole tree is opt-in. +- While a submenu is open, inline focus-guard nodes sit next to its trigger inside the parent popup; see open question 7 for the styling-contract implications. +- The menu surface constrains itself to `min(calc(100vh - 96px), var(--available-height))` and scrolls internally -- the classic viewport-only clamp replaced by a collision-aware one. + +### Compatibility posture + +Continuity where it matters, honesty where it does not: + +- Kept as-is: item-level presentational props (`dense`, `disableGutters`, `divider`, `inset`, `selected` -- visual-only, as today), `disabled`, visual design, theming entry points, `keepMounted`, portal `container`. +- Changed deliberately (Base UI model replaces the old one): open/close control (`open`/`defaultOpen` + `onOpenChange(open, eventDetails)` instead of controlled-only `open` + `onClose(event, reason)`), positioning (`anchor`/`side`/`align`/offsets instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS `data-starting-style`/`data-ending-style` + `onOpenChangeComplete` instead of `TransitionComponent`/`Grow`). +- Dropped intentionally: + - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that degrade accessibility; `modal` and `finalFocus` cover the legitimate cases. + - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: listbox-style selection behavior on `role="menu"`; initial focus is handled internally per the WAI-ARIA menu pattern. + - `anchorOrigin`/`transformOrigin`/`anchorReference`/`anchorPosition`, `PopoverClasses`, `transitionDuration`, `slots.transition`, `action.updatePosition`: superseded by Base UI's Floating-UI-based positioning with automatic anchor tracking and collision handling. + - `disablePortal`: Base UI popups are always portalled. + +A full old-to-new prop mapping is in the collapsible appendix at the end. + +### New capabilities (vs classic Menu) + +- Submenus with correct keyboard, hover-intent, and ARIA behavior. +- Checkbox and radio items (`role="menuitemcheckbox"`/`menuitemradio"` with `aria-checked` and indicators). +- Groups with automatically associated labels (`role="group"` + `aria-labelledby`). +- Trigger wiring for `aria-haspopup`/`aria-expanded`/`aria-controls` out of the box. +- Typeahead with per-item `label` override. +- Hover-open with configurable delays; reason-rich, cancelable `onOpenChange`. + +### Reference implementation and known deltas + +The PoC (https://github.com/mui/material-ui/pull/48663) proves feasibility end-to-end: submenus, checkbox/radio items, groups, pixel parity with classic `MenuItem`, full theme registration, and a comprehensive test suite -- at +77 B gzip on the `@mui/material` barrel (Base UI code is only paid when importing the component). The companion experiment (https://github.com/mui/material-ui/pull/48823) iterates on it toward the standards above. + +Resolved in the experiment branch: + +- `MenuPreview` naming -> `Unstable_Menu2` lifecycle naming, per-part subpaths, Base UI-style short aliases dropped. +- Docs tooling special-casing -> removed; the experiment is exercised via a non-public playground page instead of generated API docs. +- Style sharing: classic and successor consume the same extracted style modules (single source of visual truth). +- Prop surfaces on the renderless roots and the flattened popup inherit Base UI types via `Omit`/`Pick` (the item parts already followed the pattern); the roots gain `actionsRef` and future Base UI props for free (hover-open with delays lives on the trigger parts, already exposed). +- Top-level `elevation` convenience prop exists on the popup (default 8, forwards to the Paper slot). + +Remaining: + +- Fully compound API -> per-part flat/standalone split per the design phase. +- Style sharing is at the shared-style-function level (option 1) -> upgrade to shared styled elements via internal `render` where practical. +- Bespoke slot-bridging layer -> `@mui/utils` slot utilities. +- Bespoke test suite -> `describeConformance` + the legacy Menu behavior suite rerun with annotated skips (piloted on the item and popup; remaining parts and the legacy rerun pending). +- `inset` item prop not yet implemented (decided as preserved). +- No default open/close animation and no ripple -> open questions below. + +### Open questions + +1. Ripple. Items are Base UI divs, so there is no `TouchRipple`. Either add `TouchRipple` to the item root slot for Material fidelity (restoring `disableRipple` and friends) or formally drop ripple on menu items. No accessibility stake; this is a design-identity decision. +2. Default open/close animation. Classic Menu animates with `Grow` by default; the experiment ships none by default but demonstrates a CSS approximation of `Grow` via `data-starting-style`/`data-ending-style` behind a toggle. Proposal: ship a default CSS transition so the component does not feel like a visual regression, overridable via plain CSS. +3. Convenience `elevation` prop on the container: implemented in the experiment (default 8, forwards to the Paper slot) -- confirm we keep it. +4. Backdrop. Base UI has `Menu.Backdrop`; the experiment does not surface it. Expose a `backdrop` slot now or wait for demand? +5. Imperative actions. Under the typing standard, Base UI's `actionsRef` (`close()`, `unmount()`) is inherited by default -- the question is whether to curate it away, not whether to add it. +6. Context menu (right-click / cursor positioning). Classic `anchorPosition` use cases are covered by a virtual-element `anchor` recipe in the experiment. Base UI also has a dedicated `ContextMenu` component. Recipe now, dedicated component later? The recipe surfaced a focus-restore hole that sharpens this question: a detached menu has no trigger to restore focus to, and on close Base UI falls back to an internal previously-focused-element record -- which can be a stale, unrelated menu trigger from an earlier interaction on the same page. The recipe must pass `finalFocus` (the invoked surface, per the APG context-menu pattern) to behave correctly; a wrapped `ContextMenu` component would remove that footgun entirely, since its trigger is the right-click surface itself. +7. Sibling-structure styling contract around submenu triggers. While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them. Any consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens: this is the first place a Base UI implementation detail reaches the consumer styling contract, and it is invisible until runtime (the experiment shipped exactly this bug -- the separator following a trigger lost its adjacency-based margins). The built-in parts now avoid it by owning their own spacing. Options: (a) accept and document the constraint as part of the styling contract ("do not style through sibling combinators around triggers"; the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must), (b) raise upstream whether guard placement could avoid interleaving trigger siblings (for example positioning the guards at the popup boundary), (c) both. Proposal: (c) -- document now, pursue upstream hardening. +8. Bundle-size governance. Base UI adds real weight per component family; do we add a size-snapshot gate per Base UI-backed component? +9. Residual accessibility obligation. Base UI owns the interaction a11y; what does Material UI still verify on top (an axe pass in conformance, screen-reader smoke tests, contrast of the styled surfaces)? +10. Behavior defaults divergence: do we ratify Base UI's defaults where they differ from the classic Menu (hover-open submenus, non-modal scroll behavior), or re-tune them for continuity? To be fed by the behavior benchmark. +11. SSR, `'use client'` boundaries, and ref typing are expected to follow the existing patterns with no divergence -- to confirm during implementation, not expected to be contentious. + +### Rollout plan + +1. Behavior benchmark (hard precondition): diff Material UI Menu vs Base UI Menu from the user's perspective; publish the results in this RFC to set how close to drop-in the API can be. Include the structure-sensitive cases the experiment surfaced: parent-menu layout stability while submenus open and close, and height-constrained menus near the viewport edge. +2. Design phase for the API shape under the rules above (container boundaries, submenu shape), validated in the companion experiment -- each open question resolved against a deploy preview rather than in the abstract. +3. Land `Unstable_Menu2` in a v9 minor: conformance + legacy behavior suites with annotated skips, API reference docs, and a docs section on the Menu page (submenu, checkbox/radio, context-menu demos). +4. Iterate on feedback; stabilize as `Menu2` once the graduation checklist passes (conformance minus documented skips, theme-registration parity, pinned `data-*` boundary, design sign-off). +5. Next major: promote `Menu2` to `Menu`, rename legacy to `MenuLegacy` (deprecated), ship the migration guide and codemods for the renames and the mechanical parts of the mapping below (Grid precedent). + +### Appendix: full prop mapping (classic Menu -> successor) + +
+1. Open / close and control + +| Classic Menu | New equivalent | Notes | +| ---------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` (required, controlled-only) | `open` + `defaultOpen` | uncontrolled becomes possible | +| `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | signature inherits Base UI types per the typing standard; reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, `item-press`; supports `cancel()` and exposes the native event | +| n/a | `onOpenChangeComplete(open)` | replaces `onTransitionExited`-style hooks | + +
+ +
+2. Positioning + +| Classic Menu / Popover | New equivalent | Notes | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------- | +| `anchorEl` | `anchor` | also accepts refs and virtual elements | +| `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | finer control | +| `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | see open question 6 | +| `marginThreshold` (default 16) | `collisionPadding` (default 5) | equivalent concept | +| `anchorReference="none"` | omit `anchor`, position via CSS | equivalent | +| `action.updatePosition()` | automatic anchor tracking | `disableAnchorTracking` to opt out | +| -- | `collisionBoundary`, `sticky`, `collisionAvoidance`, `positionMethod`, `arrowPadding` | new capabilities | + +
+ +
+3. Focus and modality + +| Classic Menu | New equivalent | Notes | +| ----------------------------------------- | --------------------- | ------------------------------------------------------------- | +| `autoFocus`, `disableAutoFocusItem` | internal | per WAI-ARIA menu pattern | +| `variant` (`menu`/`selectedMenu`) | dropped | selection state is invalid on menu items | +| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` prop covers modality | +| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | +| `disableEscapeKeyDown` | dropped | contradicts the menu pattern; use `onKeyDown` if truly needed | +| `disableScrollLock` | `modal` | non-modal menus do not lock scroll | +| `hideBackdrop` | partially via `modal` | see open question 4 | +| `disablePortal` | dropped | always portalled | +| `keepMounted`, `container` | same | identical semantics | + +
+ +
+4. Transitions + +| Classic Menu | New equivalent | +| ------------------------------------------------------------------- | --------------------------------------------------- | +| `TransitionComponent` / `slots.transition` (default `Grow`) | CSS via `data-starting-style` / `data-ending-style` | +| `transitionDuration` | CSS `transition-duration` on the popup | +| `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | +| default `Grow` animation | open question 2 (proposal: default CSS transition) | + +
+ +
+5. Styling / slots + +| Classic Menu | New equivalent | Notes | +| ------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------- | +| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list` | no transition slot (CSS-based) | +| `elevation` (default 8) | `elevation` (default 8, forwards to the Paper slot) | implemented; see open question 3 | +| paper `maxHeight: calc(100% - 96px)` (viewport clamp via the Modal) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | collision-aware | +| `slots.backdrop` + `BackdropProps` | not surfaced | see open question 4 | +| `PopoverClasses` | n/a | no Popover underneath | + +
+ +
+6. Item-level props + +| Classic MenuItem / MenuList | New equivalent | Notes | +| ------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dense`, `disableGutters`, `divider`, `inset` | same | preserved (decided): Material UI owns presentation; `inset` pending in the experiment | +| `` between items | `Separator` part | owns its 8px margins (stable while submenus are open); classic spacing relied on item-adjacency selectors | +| `selected` | same (visual-only, as today) | preserved (decided); checkbox/radio items cover real selection semantics. Note: classic `MenuItem` now derives `aria-checked` from `selected` for `menuitemcheckbox`/`menuitemradio` roles (https://github.com/mui/material-ui/pull/48651) -- the successor's dedicated checkbox/radio items own this instead | +| `disabled` | same | `aria-disabled`, item stays focusable | +| `href` / `LinkComponent` | link item variant | real `` | +| `autoFocus` (item) | dropped | initial focus is internal | +| ripple props | none currently | see open question 1 | +| `focusVisibleClassName`, `onFocusVisible`, `action.focusVisible()` | `highlighted` state class / data attributes | style via CSS | +| `MenuList.disableListWrap` | `loopFocus` (default true) | inverse | +| `MenuList.autoFocus`/`autoFocusItem`/`variant` | dropped | internal / legacy | +| `MenuList.disablePadding`, `subheader` | `slotProps.list`, group + group label parts | groups get proper ARIA association | + +
+ +## Resources and benchmarks + +Proof of concept and experiment: + +- PoC PR: https://github.com/mui/material-ui/pull/48663 (live demo: https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/) +- RFC companion playground (use case demos + knobs for the open questions): https://github.com/mui/material-ui/pull/48823 +- Measured bundle impact of the PoC on `@mui/material`: +160 B parsed / +77 B gzip (~0.03%) -- the style-extraction refactor only; Base UI code is paid only when importing the new component. + +Demand: + +- https://github.com/mui/material-ui/issues/11723 (canonical request, open since 2018, 120+ reactions) +- https://github.com/mui/material-ui/issues/8152 (closed as duplicate) +- https://github.com/mui/material-ui/issues/48336 (packaged Menubar/submenu component request) +- https://github.com/mui/material-ui/issues/45790 (nested menu docs demo request) + +Prior attempts on the legacy stack: + +- https://github.com/mui/material-ui/pull/14700 (2019, closed) +- https://github.com/mui/material-ui/pull/20591 (2020-2022, closed) +- https://github.com/mui/material-ui/pull/37570 (2023-2024, closed) +- v0.x nested menu support: https://github.com/mui/material-ui/pull/2148, https://github.com/mui/material-ui/pull/3265 + +Direction and precedent: + +- Maintainer statement (Dec 2024): https://github.com/mui/material-ui/issues/11723#issuecomment-2556390056 -- "Material UI will adopt (this new) Base UI component in its next major release." +- Grid lifecycle precedent (legacy rename): https://github.com/mui/material-ui/pull/45363 +- Menubar docs page composed from Base UI with submenus: https://mui.com/material-ui/react-menubar/ (added in https://github.com/mui/material-ui/pull/47616) +- Base UI Menu (submenu anatomy, keyboard model): https://base-ui.com/react/components/menu +- Base UI releases (1.x stable): https://base-ui.com/react/overview/releases + +Community workarounds discussed in the threads: + +- https://github.com/azmenak/material-ui-nested-menu-item (and its successor `mui-nested-menu`) +- https://jcoreio.github.io/material-ui-popup-state/ +- https://www.npmjs.com/package/better-mui-menu From 4b0248b0e1127efcb517096d12f89143f8e96d27 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 27 Jul 2026 12:29:11 +0300 Subject: [PATCH 20/62] Render the RFC draft as an experiment and align names on Menu2 - Move the RFC draft to docs/pages/experiments/menu2-rfc.md and render it through the docs markdown loader (?muiMarkdown + MarkdownElement), so headings, tables, code blocks and the collapsible appendices are parsed instead of shown as source. Follows the docs/pages/customers precedent (colocated .md, .js page, no new ambient types). - Rename the experiments for one vocabulary: menu-rfc -> menu2-playground, menu-preview -> menu2-recipes, plus page titles, headings and section anchor ids. - Cross-link the three pages. Note for future docs markdown: the frontmatter regex is greedy, so a standalone `---` horizontal rule in the body makes the parser treat the rest of the document as headers. Verified: pnpm docs:build exits 0 with all three pages generated; the RFC page renders 7 tables, 6 details blocks and no raw markdown; prettier, vale, eslint and the docs typecheck are clean. Co-Authored-By: Claude Fable 5 --- .../{menu-rfc.tsx => menu2-playground.tsx} | 16 ++++++----- .../{menu-preview.tsx => menu2-recipes.tsx} | 21 ++++++++++----- docs/pages/experiments/menu2-rfc.js | 27 +++++++++++++++++++ .../pages/experiments/menu2-rfc.md | 11 +++++--- 4 files changed, 59 insertions(+), 16 deletions(-) rename docs/pages/experiments/{menu-rfc.tsx => menu2-playground.tsx} (97%) rename docs/pages/experiments/{menu-preview.tsx => menu2-recipes.tsx} (96%) create mode 100644 docs/pages/experiments/menu2-rfc.js rename packages/mui-material/src/Unstable_Menu2/RFC.md => docs/pages/experiments/menu2-rfc.md (98%) diff --git a/docs/pages/experiments/menu-rfc.tsx b/docs/pages/experiments/menu2-playground.tsx similarity index 97% rename from docs/pages/experiments/menu-rfc.tsx rename to docs/pages/experiments/menu2-playground.tsx index 98c78990fd1931..c036d10a92d718 100644 --- a/docs/pages/experiments/menu-rfc.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -537,17 +537,21 @@ export default function MenuRfcExperiment() { return ( - + - Menu RFC playground + Menu2 playground - Companion experiment for the Menu successor RFC. Every knob maps to a prop or an RFC - open question. See also the{' '} - Menu2 experiment for Tooltip, - PreviewCard, and ContextMenu recipes. + Companion experiment for the{' '} + Menu2 RFC draft. Every knob maps to + a prop or an RFC open question. See also{' '} + Menu2 recipes for Tooltip, + PreviewCard, and ContextMenu integrations. diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu2-recipes.tsx similarity index 96% rename from docs/pages/experiments/menu-preview.tsx rename to docs/pages/experiments/menu2-recipes.tsx index 2690587f232e10..5db3480fbf4bb0 100644 --- a/docs/pages/experiments/menu-preview.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import NextLink from 'next/link'; import Container from '@mui/material/Container'; import CssBaseline from '@mui/material/CssBaseline'; import Popover from '@mui/material/Popover'; @@ -627,11 +628,19 @@ export default function Menu2Experiment() { return ( - + - Menu Preview + Menu2 recipes + + + Integration recipes for Menu2. See also the{' '} + Menu2 RFC draft and the{' '} + Menu2 playground.
Demo controls @@ -661,22 +670,22 @@ export default function Menu2Experiment() {
- +

Fully-featured menu with submenus, links, radio groups, and checkbox items.

- +

Material UI Tooltip integrated with every menu item.

- +

Material UI Popover used as a PreviewCard-style menu item help card.

- +

Right-click the text to open a cursor-positioned Menu2 popup.

diff --git a/docs/pages/experiments/menu2-rfc.js b/docs/pages/experiments/menu2-rfc.js new file mode 100644 index 00000000000000..ba401dc2727f40 --- /dev/null +++ b/docs/pages/experiments/menu2-rfc.js @@ -0,0 +1,27 @@ +import * as React from 'react'; +import Container from '@mui/material/Container'; +import CssBaseline from '@mui/material/CssBaseline'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { MarkdownElement } from '@mui/internal-core-docs/MarkdownDocs'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; +import { docs } from './menu2-rfc.md?muiMarkdown'; + +const theme = createTheme({}); + +export default function Menu2RfcPage() { + const localizedDoc = docs.en; + + return ( + + + + + {localizedDoc.rendered.map((chunk, index) => + typeof chunk === 'string' ? ( + + ) : null, + )} + + + ); +} diff --git a/packages/mui-material/src/Unstable_Menu2/RFC.md b/docs/pages/experiments/menu2-rfc.md similarity index 98% rename from packages/mui-material/src/Unstable_Menu2/RFC.md rename to docs/pages/experiments/menu2-rfc.md index 4d8258ea7c8e51..8a8e822f2ea5cc 100644 --- a/packages/mui-material/src/Unstable_Menu2/RFC.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -1,12 +1,15 @@ +--- +title: 'RFC draft: Menu successor with submenu support' +description: Living draft of the Menu2 RFC, tracked next to the Menu2 experiments until it is posted publicly. +--- + # RFC draft: Menu successor with submenu support -Living draft tracked in this PR until the RFC is posted publicly; review comments welcome on this file. +

Living draft tracked in this PR until the RFC is posted publicly; review comments welcome on this file.

Suggested issue title: `[RFC] Menu: Base UI-based successor with submenu support` -Structured for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below into the matching form field. - ---- +Structured for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below into the matching form field. Companion experiments: [playground](/experiments/menu2-playground/), [recipes](/experiments/menu2-recipes/). ## What's the problem? From b51592259d7e85e2e112b1562456af177de2c9f8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 27 Jul 2026 17:36:51 +0300 Subject: [PATCH 21/62] Resolve the inset question: composition already covers it `inset` is a ListItemText/ListSubheader prop, not a menu item prop -- the earlier RFC rows listing it beside dense/divider/disableGutters were wrong. Composing list primitives inside Menu2Item already aligns inset text with the icon column (36px) through the shared item styles, so nothing needs implementing. Adds a regression test pinning that alignment and corrects the RFC. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 8 ++--- .../src/Unstable_Menu2/Menu2.test.tsx | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 8a8e822f2ea5cc..e16a4d85bfca99 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -107,7 +107,7 @@ Meta-principle: a Base UI-backed component must be indistinguishable from a nor - Customization contract: `slots`/`slotProps` is the user-facing mechanism, as in every existing component. Base UI's `render` prop is used internally by Material UI to inject its styled elements (for example the trigger rendering a Material `Button`), and remains a last-resort escape hatch -- not the documented contract. - Slot plumbing: reuse the `@mui/utils` slot utilities (`useSlotProps`/`mergeSlotProps`/`appendOwnerState`); extend them in place if genuine Base UI `render`-bridging gaps appear. No bespoke per-component bridge layers. - Styling reuse with the legacy component: share the actual styled element by passing it through Base UI's `render` internally (the model used by the Menubar docs components), falling back to a shared style function where element injection is impractical. The experiment already implements the fallback: classic `Menu`/`MenuItem` and the new parts consume the same extracted style modules, so there is a single source of visual truth. The remaining work is upgrading to element-level sharing where it fits; free-floating copies are not acceptable. Caution learned from two shipped regressions: extracted classic styles can embed DOM-context assumptions that silently change meaning under the Base UI structure -- a `maxHeight: calc(100% - 96px)` whose `100%` meant the viewport inside the classic full-screen Modal but resolved against the content-sized popup, and an `[item] + divider` adjacency margin that broke when Base UI mounted inline focus-guard nodes next to an open submenu trigger. Shared style functions need a per-consumer audit for positional values and structural selectors, and parts should own their own spacing instead of relying on sibling combinators. -- Material presentational props are preserved (`dense`, `disableGutters`, `divider`, `inset`, `selected`): Material UI owns presentation, Base UI owns behavior. The line is styling-vs-functionality, not old-vs-new; individual long-tail props can still be dropped case-by-case. +- Material presentational props are preserved (`dense`, `disableGutters`, `divider`, `selected`): Material UI owns presentation, Base UI owns behavior. The line is styling-vs-functionality, not old-vs-new; individual long-tail props can still be dropped case-by-case. - Dependency shape: `@base-ui/react` becomes a direct dependency of `@mui/material` (caret range), like `@popperjs/core` -- an implementation detail users never install or import directly. Two riders: (a) Base UI version bumps are deliberate, reviewed events, never auto-merged; (b) conformance tests pin the `data-*` attribute surface we consume, so an upstream rename or removal fails CI instead of silently regressing styles. - Docs and API tooling: existing infrastructure is a fixed constraint; the component conforms to it. If a component cannot be documented without tooling changes, that is a signal about the component. - Theme registration: standard `defaultProps`/`styleOverrides`/`variants` registration per part, under standard keys (`MuiMenu2*`, see the naming note above) -- no special rule for Base UI parts. @@ -159,7 +159,7 @@ Behavior notes worth stating explicitly regardless of final shape: Continuity where it matters, honesty where it does not: -- Kept as-is: item-level presentational props (`dense`, `disableGutters`, `divider`, `inset`, `selected` -- visual-only, as today), `disabled`, visual design, theming entry points, `keepMounted`, portal `container`. +- Kept as-is: item-level presentational props (`dense`, `disableGutters`, `divider`, `selected` -- visual-only, as today), `disabled`, visual design, theming entry points, `keepMounted`, portal `container`. - Changed deliberately (Base UI model replaces the old one): open/close control (`open`/`defaultOpen` + `onOpenChange(open, eventDetails)` instead of controlled-only `open` + `onClose(event, reason)`), positioning (`anchor`/`side`/`align`/offsets instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS `data-starting-style`/`data-ending-style` + `onOpenChangeComplete` instead of `TransitionComponent`/`Grow`). - Dropped intentionally: - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that degrade accessibility; `modal` and `finalFocus` cover the legitimate cases. @@ -196,7 +196,7 @@ Remaining: - Style sharing is at the shared-style-function level (option 1) -> upgrade to shared styled elements via internal `render` where practical. - Bespoke slot-bridging layer -> `@mui/utils` slot utilities. - Bespoke test suite -> `describeConformance` + the legacy Menu behavior suite rerun with annotated skips (piloted on the item and popup; remaining parts and the legacy rerun pending). -- `inset` item prop not yet implemented (decided as preserved). +- Composed list primitives keep working inside items: `ListItemText inset` aligns with the icon column as it does in the classic menu (verified in the experiment). Note `inset` is a `ListItemText`/`ListSubheader` prop, not a menu item prop. - No default open/close animation and no ripple -> open questions below. ### Open questions @@ -296,7 +296,7 @@ Remaining: | Classic MenuItem / MenuList | New equivalent | Notes | | ------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dense`, `disableGutters`, `divider`, `inset` | same | preserved (decided): Material UI owns presentation; `inset` pending in the experiment | +| `dense`, `disableGutters`, `divider` | same | preserved (decided): Material UI owns presentation | | `` between items | `Separator` part | owns its 8px margins (stable while submenus are open); classic spacing relied on item-adjacency selectors | | `selected` | same (visual-only, as today) | preserved (decided); checkbox/radio items cover real selection semantics. Note: classic `MenuItem` now derives `aria-checked` from `selected` for `menuitemcheckbox`/`menuitemradio` roles (https://github.com/mui/material-ui/pull/48651) -- the successor's dedicated checkbox/radio items own this instead | | `disabled` | same | `aria-disabled`, item stays focusable | diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index 9cff470240995f..d1545853e89080 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -3,6 +3,8 @@ import { expect } from 'chai'; import { spy } from 'sinon'; import { createRenderer, fireEvent, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; import { listClasses } from '@mui/material/List'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; import { paperClasses } from '@mui/material/Paper'; import Tooltip from '@mui/material/Tooltip'; import Menu2 from '@mui/material/Unstable_Menu2'; @@ -920,6 +922,33 @@ describe('', () => { }, ); + it.skipIf(isJsdom())('supports inset list text composed inside items', async () => { + const { user } = render( + + Options + + + i + Cut + + + + Paste + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + // `inset` is a ListItemText prop, not an item prop: the shared item styles + // align it with the icon column so icon-less items line up. + const insetText = await screen.findByTestId('inset-text'); + expect(window.getComputedStyle(insetText).paddingLeft).to.equal('36px'); + expect(window.getComputedStyle(screen.getByTestId('icon')).minWidth).to.equal('36px'); + }); + it.skipIf(isJsdom())('keeps separator spacing stable while a submenu is open', async () => { const { user } = render( From 1147bde31c62c06d8b35a335c9c33c47cf5a7a8e Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 27 Jul 2026 18:13:28 +0300 Subject: [PATCH 22/62] Migrate Menu2 slot plumbing to the @mui/utils utilities Applies the agreed slot-plumbing standard: - replace the bespoke slot-props resolver with @mui/utils resolveComponentProps across all 13 parts - build the Material surfaces (paper, list) with useSlotProps, which brings shared className merging, ref forking and host-aware ownerState handling What stays bespoke is Base UI-specific and belongs in the shared utilities eventually: host-slot prop omission (a host element must not receive a Base UI part's own props), state-callback className bridging, and nativeButton inference from the resolved root slot. The RFC now names these three as the concrete escalation candidates. Verified: Menu2 suite 119 passed / 17 skipped (the host-slot hygiene tests and both conformance suites cover this refactor directly), package and docs typechecks clean, proptypes churn-free, eslint clean. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 4 +- .../src/Unstable_Menu2/menu2PopupShared.tsx | 50 +++++++++++-------- .../src/Unstable_Menu2/menu2Utils.ts | 9 ---- .../Menu2CheckboxItem.tsx | 4 +- .../Menu2CheckboxItemIndicator.tsx | 9 ++-- .../src/Unstable_Menu2Group/Menu2Group.tsx | 9 ++-- .../Menu2GroupLabel.tsx | 9 ++-- .../src/Unstable_Menu2Item/Menu2Item.tsx | 4 +- .../Unstable_Menu2LinkItem/Menu2LinkItem.tsx | 9 ++-- .../Menu2RadioGroup.tsx | 9 ++-- .../Menu2RadioItem.tsx | 4 +- .../Menu2RadioItemIndicator.tsx | 9 ++-- .../Menu2Separator.tsx | 9 ++-- .../Menu2SubmenuTrigger.tsx | 4 +- .../Unstable_Menu2Trigger/Menu2Trigger.tsx | 4 +- 15 files changed, 61 insertions(+), 85 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index e16a4d85bfca99..37d865e91cc7e4 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -105,7 +105,7 @@ Graduation is gated by a fixed checklist, not judgment calls: the legacy `descri Meta-principle: a Base UI-backed component must be indistinguishable from a normal Material UI component in every cross-cutting concern. Decided: - Customization contract: `slots`/`slotProps` is the user-facing mechanism, as in every existing component. Base UI's `render` prop is used internally by Material UI to inject its styled elements (for example the trigger rendering a Material `Button`), and remains a last-resort escape hatch -- not the documented contract. -- Slot plumbing: reuse the `@mui/utils` slot utilities (`useSlotProps`/`mergeSlotProps`/`appendOwnerState`); extend them in place if genuine Base UI `render`-bridging gaps appear. No bespoke per-component bridge layers. +- Slot plumbing: reuse the `@mui/utils` slot utilities (`useSlotProps`/`mergeSlotProps`/`appendOwnerState`/`resolveComponentProps`); extend them in place if genuine Base UI `render`-bridging gaps appear. No bespoke per-component bridge layers. Migrated in the experiment: the Material UI surfaces (paper, list) build their props with `useSlotProps`, and the bespoke slot-props resolver was replaced by `resolveComponentProps` across every part. Three Base UI-specific helpers remain, and they are the concrete candidates for graduating into the shared utilities: (a) host-slot prop omission -- when a user swaps a Base UI part for a host element, the part's own props (`anchor`, `side`, `keepMounted`, ...) must not reach the DOM, and `appendOwnerState` only handles `ownerState`; (b) state-callback className bridging -- Base UI accepts `className={(state) => string}`, which has to compose with Material UI utility classes; (c) `nativeButton` inference from the resolved root slot. - Styling reuse with the legacy component: share the actual styled element by passing it through Base UI's `render` internally (the model used by the Menubar docs components), falling back to a shared style function where element injection is impractical. The experiment already implements the fallback: classic `Menu`/`MenuItem` and the new parts consume the same extracted style modules, so there is a single source of visual truth. The remaining work is upgrading to element-level sharing where it fits; free-floating copies are not acceptable. Caution learned from two shipped regressions: extracted classic styles can embed DOM-context assumptions that silently change meaning under the Base UI structure -- a `maxHeight: calc(100% - 96px)` whose `100%` meant the viewport inside the classic full-screen Modal but resolved against the content-sized popup, and an `[item] + divider` adjacency margin that broke when Base UI mounted inline focus-guard nodes next to an open submenu trigger. Shared style functions need a per-consumer audit for positional values and structural selectors, and parts should own their own spacing instead of relying on sibling combinators. - Material presentational props are preserved (`dense`, `disableGutters`, `divider`, `selected`): Material UI owns presentation, Base UI owns behavior. The line is styling-vs-functionality, not old-vs-new; individual long-tail props can still be dropped case-by-case. - Dependency shape: `@base-ui/react` becomes a direct dependency of `@mui/material` (caret range), like `@popperjs/core` -- an implementation detail users never install or import directly. Two riders: (a) Base UI version bumps are deliberate, reviewed events, never auto-merged; (b) conformance tests pin the `data-*` attribute surface we consume, so an upstream rename or removal fails CI instead of silently regressing styles. @@ -194,7 +194,7 @@ Remaining: - Fully compound API -> per-part flat/standalone split per the design phase. - Style sharing is at the shared-style-function level (option 1) -> upgrade to shared styled elements via internal `render` where practical. -- Bespoke slot-bridging layer -> `@mui/utils` slot utilities. +- Slot plumbing is migrated to `@mui/utils`; what remains is the Base UI-specific residue listed in the standards section above, which should graduate into the shared utilities rather than stay per-component. - Bespoke test suite -> `describeConformance` + the legacy Menu behavior suite rerun with annotated skips (piloted on the item and popup; remaining parts and the legacy rerun pending). - Composed list primitives keep working inside items: `ListItemText inset` aligns with the icon column as it does in the classic menu (verified in the experiment). Note `inset` is a `ListItemText`/`ListSubheader` prop, not a menu item prop. - No default open/close animation and no ripple -> open questions below. diff --git a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx index e1b5794fb39315..ff688a1f64a982 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -2,13 +2,15 @@ import * as React from 'react'; import clsx from 'clsx'; import { Menu as BaseMenu } from '@base-ui/react/menu'; +import resolveComponentProps from '@mui/utils/resolveComponentProps'; +import useSlotProps from '@mui/utils/useSlotProps'; import appendOwnerState from '@mui/utils/appendOwnerState'; import isHostComponent from '@mui/utils/isHostComponent'; import { SxProps } from '@mui/system'; import { Theme } from '../styles'; import { PaperProps } from '../Paper'; import { ListProps } from '../List'; -import { resolveSlotProps, SlotProps } from './menu2Utils'; +import { SlotProps } from './menu2Utils'; type ExternalSlotProps = Omit, 'className' | 'render' | 'style'> & { className?: string | undefined; @@ -245,11 +247,11 @@ export const Menu2PopupBase = React.forwardRef(function Menu2PopupBase diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts index 72843f75b39976..abd2bcaa7cbfef 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts @@ -22,15 +22,6 @@ export function mergeStateClassName( export type SlotProps = SlotPropsValue | ((ownerState: OwnerState) => SlotPropsValue) | undefined; -export function resolveSlotProps( - slotProps: SlotProps, - ownerState: OwnerState, -): SlotPropsValue | undefined { - return typeof slotProps === 'function' - ? (slotProps as (ownerState: OwnerState) => SlotPropsValue)(ownerState) - : slotProps; -} - export interface Menu2RootSlots { root?: React.ElementType | undefined; } diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx index 43face4999bb44..d37c964704f978 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import resolveComponentProps from '@mui/utils/resolveComponentProps'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { Menu as BaseMenu } from '@base-ui/react/menu'; @@ -12,7 +13,6 @@ import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps, - resolveSlotProps, } from '../Unstable_Menu2/menu2Utils'; import { getMenu2ItemClassName, @@ -179,7 +179,7 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( Date: Tue, 28 Jul 2026 10:52:55 +0300 Subject: [PATCH 23/62] Roll describeConformance out to every rendering Menu2 part Completes the testing standard: all 14 rendering parts now run the shared conformance suite (the two renderless roots have no DOM surface), so the Material contract is proven by the same tests every other component passes. With that in place the bespoke suite drops its hand-written theming, classes, slots and component-prop coverage (-158 lines); what remains there is Base UI-specific behavior and Material integration. Two adaptations, both candidates for the shared harness and recorded in the RFC: - portalled roots have no parent whose firstChild is the part root, so a small test helper supplies a stand-in container; a getRootElement option in describeConformance would remove it - the nested submenu popup only mounts with real layout, so its suite runs in the browser project only Verified: 358 passed / 108 skipped across the jsdom and browser projects, package typecheck and eslint clean. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 4 +- .../src/Unstable_Menu2/Menu2.test.tsx | 161 +----------------- .../Menu2CheckboxItem.test.tsx | 30 ++++ .../Menu2CheckboxItemIndicator.test.tsx | 33 ++++ .../Unstable_Menu2Group/Menu2Group.test.tsx | 30 ++++ .../Menu2GroupLabel.test.tsx | 33 ++++ .../Menu2LinkItem.test.tsx | 32 ++++ .../Menu2RadioGroup.test.tsx | 30 ++++ .../Menu2RadioItem.test.tsx | 33 ++++ .../Menu2RadioItemIndicator.test.tsx | 36 ++++ .../Menu2Separator.test.tsx | 30 ++++ .../Menu2SubmenuPopup.test.tsx | 55 ++++++ .../Menu2SubmenuTrigger.test.tsx | 33 ++++ .../Menu2Trigger.test.tsx | 20 +++ .../mui-material/test/menu2Conformance.tsx | 22 +++ 15 files changed, 422 insertions(+), 160 deletions(-) create mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx create mode 100644 packages/mui-material/test/menu2Conformance.tsx diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 37d865e91cc7e4..75868e086aaedf 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -116,7 +116,7 @@ Proposed, awaiting team reaction: - Styling state source of truth (hybrid): `Mui-*` classes + `ownerState` remain the public contract that `styleOverrides`/`variants`/`sx` are written against; internal styles may read Base UI `data-*` attributes for positional or transient state (precedent: Tooltip's `[data-popper-placement]` selectors). Rule of thumb: state users theme or that appears in the documented API gets a class; purely positional/transient internal state stays `data-*`-only. The experiment already exercises the internal side of this rule: the popup surface consumes the positioner-provided `--available-height` variable for its collision-aware max-height. - Prop surface typing: `extends` the Base UI prop types with `Omit` for curated or renamed props -- inheritance by default (no drift as Base UI evolves), curation as an explicit, documented list. Consequence: callback signatures follow Base UI by default, for example `onOpenChange(open, eventDetails)` rather than the legacy event-first `onClose(event, reason)`. Validated in the experiment (renderless roots, flattened popup): the pattern type-checks, the spec's negative assertions hold, and the flattened container inherits its hoisted positioner/portal surface via `Pick`. One confirmed limitation: the proptypes generator does not expand members declared in `node_modules`, so runtime PropTypes on inherited props degrade to the locally declared ones (types still carry the full contract; `remove-proptypes` strips them in production anyway). Teaching the generator to expand external heritage is a shared-infra follow-up; until then the trade is inherited types + reduced dev-mode runtime validation. -- Testing: reuse the existing harnesses on two fronts -- `describeConformance` for the Material UI contract (ref, className, `sx`, theme `styleOverrides`), and the legacy Menu behavior suite (keyboard navigation, open/close, focus) rerun against the successor. Every skip is annotated with why it is incompatible under the Base UI model. Parity is proven by the same tests passing, not by new bespoke tests. +- Testing: reuse the existing harnesses on two fronts -- `describeConformance` for the Material UI contract (ref, className, `sx`, theme `styleOverrides`), and the legacy Menu behavior suite (keyboard navigation, open/close, focus) rerun against the successor. Every skip is annotated with why it is incompatible under the Base UI model. Parity is proven by the same tests passing, not by new bespoke tests. Rolled out in the experiment: all 14 rendering parts now run `describeConformance` (the two renderless roots have no DOM surface), which let the bespoke suite drop its hand-written theming, classes, slots and `component`-prop coverage. Two adaptations are needed and both are candidates for the shared harness: portalled roots have no parent whose `firstChild` is the part root (a `getRootElement` option would fix it), and suites whose part only mounts with real layout -- the nested submenu popup -- run in the browser project only. ### API shape (pending a dedicated design phase) @@ -195,7 +195,7 @@ Remaining: - Fully compound API -> per-part flat/standalone split per the design phase. - Style sharing is at the shared-style-function level (option 1) -> upgrade to shared styled elements via internal `render` where practical. - Slot plumbing is migrated to `@mui/utils`; what remains is the Base UI-specific residue listed in the standards section above, which should graduate into the shared utilities rather than stay per-component. -- Bespoke test suite -> `describeConformance` + the legacy Menu behavior suite rerun with annotated skips (piloted on the item and popup; remaining parts and the legacy rerun pending). +- The legacy Menu behavior suite has not been rerun against the successor yet; it is written against the `anchorEl` API, so it needs the flat container from the design phase before it can run. - Composed list primitives keep working inside items: `ListItemText inset` aligns with the icon column as it does in the classic menu (verified in the experiment). Note `inset` is a `ListItemText`/`ListSubheader` prop, not a menu item prop. - No default open/close animation and no ripple -> open questions below. diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index d1545853e89080..c8cc5fc5eff9f7 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -25,7 +25,6 @@ import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; import Menu2Trigger, { menu2TriggerClasses } from '@mui/material/Unstable_Menu2Trigger'; -import { ThemeProvider, createTheme } from '@mui/material/styles'; describe('', () => { const { render } = createRenderer(); @@ -89,163 +88,9 @@ describe('', () => { expect(await screen.findByRole('menu')).not.to.equal(null); }); - it('supports component props, slotProps, classes, styleOverrides, and variants', async () => { - const theme = createTheme({ - components: { - MuiMenu2Trigger: { - defaultProps: { - variant: 'outlined', - }, - }, - MuiMenu2Popup: { - styleOverrides: { - paper: { - minWidth: 128, - }, - }, - variants: [ - { - props: { align: 'start' }, - style: { - '--Menu2Popup-variant': '"applied"', - }, - }, - ], - }, - MuiMenu2Item: { - variants: [ - { - props: { selected: true }, - style: { - fontWeight: 700, - }, - }, - { - props: { disabled: true }, - style: { - '--Menu2Item-disabledVariant': '"applied"', - }, - }, - ], - }, - MuiMenu2CheckboxItem: { - variants: [ - { - props: { checked: true }, - style: { - '--Menu2CheckboxItem-checkedVariant': '"applied"', - }, - }, - ], - }, - MuiMenu2RadioItem: { - variants: [ - { - props: { value: 'small' }, - style: { - '--Menu2RadioItem-valueVariant': '"applied"', - }, - }, - ], - }, - MuiMenu2LinkItem: { - variants: [ - { - props: { href: '/profile' }, - style: { - '--Menu2LinkItem-hrefVariant': '"applied"', - }, - }, - ], - }, - }, - }); - - const { user } = render( - - - Options - - - Profile - - Disabled profile - Checked profile - - Small - - Link profile - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Options' })); - - expect(screen.getByRole('button', { name: 'Options' })).to.have.class('custom-trigger'); - expect(await screen.findByTestId('list')).to.have.class('custom-list'); - expect( - window.getComputedStyle(screen.getByRole('menu')).getPropertyValue('--Menu2Popup-variant'), - ).to.equal('"applied"'); - expect(await screen.findByRole('menuitem', { name: 'Profile' })).to.have.class('custom-item'); - expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( - menu2ItemClasses.selected, - ); - expect(screen.getByRole('menuitem', { name: 'Disabled profile' })).to.have.class( - menu2ItemClasses.disabled, - ); - expect( - window - .getComputedStyle(screen.getByRole('menuitem', { name: 'Disabled profile' })) - .getPropertyValue('--Menu2Item-disabledVariant'), - ).to.equal('"applied"'); - expect(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })).to.have.class( - menu2CheckboxItemClasses.checked, - ); - expect( - window - .getComputedStyle(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })) - .getPropertyValue('--Menu2CheckboxItem-checkedVariant'), - ).to.equal('"applied"'); - expect( - window - .getComputedStyle(screen.getByRole('menuitemradio', { name: 'Small' })) - .getPropertyValue('--Menu2RadioItem-valueVariant'), - ).to.equal('"applied"'); - expect( - window - .getComputedStyle(screen.getByRole('menuitem', { name: 'Link profile' })) - .getPropertyValue('--Menu2LinkItem-hrefVariant'), - ).to.equal('"applied"'); - }); - - it('composes popup class names', async () => { - const { user } = render( - - Options - - Profile - - , - ); - - await user.click(screen.getByRole('button', { name: 'Options' })); - - const menu = await screen.findByRole('menu'); - expect(menu).to.have.class('popup-open'); - expect(menu).to.have.class('popup-side-bottom'); - expect(menu).to.have.class(menu2PopupClasses.root); - }); + // Theming, classes, slots and the `component` prop are covered per part by + // the describeConformance suites next to each component; what stays here is + // Base UI-specific behavior and Material integration. it('does not pass ownerState to host popup slots', async () => { const { user } = render( diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx new file mode 100644 index 00000000000000..e8fe9bfac71aae --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2CheckboxItem, { + menu2CheckboxItemClasses as classes, +} from '@mui/material/Unstable_Menu2CheckboxItem'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(Ruler, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + {node} + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'span', + muiName: 'MuiMenu2CheckboxItem', + testVariantProps: { checked: true }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx new file mode 100644 index 00000000000000..a939573f6ebc6f --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Unstable_Menu2CheckboxItemIndicator, { + menu2CheckboxItemIndicatorClasses as classes, +} from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + + {node}Ruler + + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLSpanElement, + testComponentPropWith: 'i', + muiName: 'MuiMenu2CheckboxItemIndicator', + testVariantProps: { 'data-variant': 'probe' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx new file mode 100644 index 00000000000000..cd8fbb4f2128b0 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2Group, { + menu2GroupClasses as classes, +} from '@mui/material/Unstable_Menu2Group'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(Group, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + {node} + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'section', + muiName: 'MuiMenu2Group', + testVariantProps: { 'data-variant': 'probe' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx new file mode 100644 index 00000000000000..80b4f4efdc7e9c --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Unstable_Menu2GroupLabel, { + menu2GroupLabelClasses as classes, +} from '@mui/material/Unstable_Menu2GroupLabel'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(Section, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + + {node} + + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'h3', + muiName: 'MuiMenu2GroupLabel', + testVariantProps: { 'data-variant': 'probe' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx new file mode 100644 index 00000000000000..058eb5efcf9909 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2LinkItem, { + menu2LinkItemClasses as classes, +} from '@mui/material/Unstable_Menu2LinkItem'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance( + Profile, + () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + {node} + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLAnchorElement, + muiName: 'MuiMenu2LinkItem', + testVariantProps: { 'data-variant': 'probe' }, + }), + ); +}); diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx new file mode 100644 index 00000000000000..3252ab0a589be0 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2RadioGroup, { + menu2RadioGroupClasses as classes, +} from '@mui/material/Unstable_Menu2RadioGroup'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(Group, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + {node} + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'section', + muiName: 'MuiMenu2RadioGroup', + testVariantProps: { 'data-variant': 'probe' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx new file mode 100644 index 00000000000000..986eca3c3a61f5 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Unstable_Menu2RadioItem, { + menu2RadioItemClasses as classes, +} from '@mui/material/Unstable_Menu2RadioItem'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(One, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + + {node} + + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'span', + muiName: 'MuiMenu2RadioItem', + testVariantProps: { 'data-variant': 'probe' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx new file mode 100644 index 00000000000000..1f5e96a0662de6 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Unstable_Menu2RadioItemIndicator, { + menu2RadioItemIndicatorClasses as classes, +} from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + + + {node}One + + + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLSpanElement, + testComponentPropWith: 'i', + muiName: 'MuiMenu2RadioItemIndicator', + testVariantProps: { 'data-variant': 'probe' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx new file mode 100644 index 00000000000000..6427e977d16c16 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2Separator, { + menu2SeparatorClasses as classes, +} from '@mui/material/Unstable_Menu2Separator'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + {node} + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'span', + muiName: 'MuiMenu2Separator', + testVariantProps: { orientation: 'vertical' }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx new file mode 100644 index 00000000000000..2d55d188c1238e --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { createRenderer, isJsdom } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Unstable_Menu2SubmenuPopup, { + menu2SubmenuPopupClasses as classes, +} from '@mui/material/Unstable_Menu2SubmenuPopup'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +// Base UI submenus need layout to open, which jsdom does not provide, so the +// nested popup never mounts there; run this suite in the browser project. +describe.skipIf(isJsdom())('', () => { + const { render } = createRenderer(); + + describeConformance( + + Nested + , + () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + + + More + {node} + + + , + ), + `.${classes.root}`, + ), + // The popup root is the Base UI Popup element; swapping the host goes + // through slots.popup rather than the component prop. + skip: ['componentProp'], + refInstanceof: window.HTMLDivElement, + muiName: 'MuiMenu2SubmenuPopup', + testVariantProps: { align: 'center' }, + slots: { + paper: { + expectedClassName: classes.paper, + }, + list: { + expectedClassName: classes.list, + }, + }, + }), + ); +}); diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx new file mode 100644 index 00000000000000..dae4c4f03915a5 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Unstable_Menu2SubmenuTrigger, { + menu2SubmenuTriggerClasses as classes, +} from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import describeConformance from '../../test/describeConformance'; +import withPortalledRoot from '../../test/menu2Conformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(More, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + + {node} + + , + ), + `.${classes.root}`, + ), + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'span', + muiName: 'MuiMenu2SubmenuTrigger', + testVariantProps: { dense: true }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx b/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx new file mode 100644 index 00000000000000..f3ce7116a3a501 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Trigger, { + menu2TriggerClasses as classes, +} from '@mui/material/Unstable_Menu2Trigger'; +import describeConformance from '../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance(Options, () => ({ + classes, + render: (node) => render({node}), + refInstanceof: window.HTMLButtonElement, + testComponentPropWith: 'a', + muiName: 'MuiMenu2Trigger', + testVariantProps: { disabled: true }, + })); +}); diff --git a/packages/mui-material/test/menu2Conformance.tsx b/packages/mui-material/test/menu2Conformance.tsx new file mode 100644 index 00000000000000..5b84c33e707e9f --- /dev/null +++ b/packages/mui-material/test/menu2Conformance.tsx @@ -0,0 +1,22 @@ +/** + * Menu2 parts render inside a portal, surrounded by Base UI focus-guard nodes, + * so no real parent element has the part's root as its `firstChild` -- the + * contract `describeConformance` relies on. Hand the harness a stand-in + * container pointing at the part's root instead. + * + * A `getRootElement` option in the shared harness would remove the need for + * this; see the Menu2 RFC draft. + */ +export default function withPortalledRoot( + result: Result, + selector: string, +) { + const { container, ...other } = result; + const root = document.querySelector(selector); + + if (!root) { + throw new Error(`menu2Conformance: no element matched "${selector}".`); + } + + return { ...other, container: { firstChild: root } as unknown as HTMLElement }; +} From a552e06e1c9a47fc902dc3b92da05c2f2f8b056c Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Jul 2026 11:09:56 +0300 Subject: [PATCH 24/62] Run the behavior benchmark and record its results in the RFC Satisfies the RFC's hard precondition for the API design phase: the classic Menu and the successor are diffed from the user's perspective by an executable benchmark next to the component, so every row of the findings table is an assertion in real Chromium rather than a reading of the source. Findings: parity on Escape focus restore and scroll locking; the successor adds keyboard opening from its trigger; and six behavioral divergences -- initial focus (classic pre-focuses the selected item), disabled items (classic skips them, the successor keeps them focusable per the menu pattern), Tab (classic traps, the successor closes), backdrop, sibling aria-hidden, and default placement. Consequence recorded in the RFC: a flat container can reproduce the classic surface but not the classic interaction model, so the migration story is "same component, new behavior", which supports keeping the successor under its own name through a full major. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 22 +- .../Unstable_Menu2/Menu2Benchmark.test.tsx | 238 ++++++++++++++++++ 2 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 75868e086aaedf..c3b5e6907bbf34 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -126,7 +126,25 @@ The agreed rules for the shape, replacing a global flat-vs-compound choice with - Structural/plumbing parts (Portal, Positioner, Popup, Paper, List) are bundled into a flat container component and exposed via `slots`/`slotProps` -- they exist for wiring, not day-to-day composition. - Customization-heavy parts stay standalone components (`MenuItem`-like parts, submenu triggers, checkbox/radio items) -- they need per-instance children and props and cannot be buried in a container. -Hard precondition before the design is finalized: a behavior benchmark diffing the Material UI Menu against the Base UI Menu from the user's perspective (open/close semantics, focus behavior, keyboard model, dismissal, positioning defaults). The benchmark gates the design -- how close to drop-in the successor API can be -- not the positioning, which is decided above. +Hard precondition before the design is finalized: a behavior benchmark diffing the Material UI Menu against the Base UI Menu from the user's perspective. This has now run -- it is executable, lives next to the component as `Menu2Benchmark.test.tsx`, and every row below is an assertion in real Chromium rather than a reading of the source. The benchmark gates the design -- how close to drop-in the successor API can be -- not the positioning, which is decided above. + +#### Benchmark results + +| Dimension | Classic `Menu` | Successor | Verdict | +| ----------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------------- | +| Opening from the trigger | no trigger part; the consumer wires `onClick` by hand | `Trigger` opens on click and on ArrowDown | successor adds behavior | +| Focus after opening | moves onto the selected item (`variant="selectedMenu"` is the classic default) | lands on the popup; nothing is highlighted until the user navigates | **divergence** | +| Disabled items during keyboard navigation | skipped entirely | focusable, per the WAI-ARIA menu pattern | **divergence** (successor is the more correct one) | +| Escape | closes and restores focus to the trigger | same | parity | +| Tab while open | menu stays open (focus is trapped) | menu closes | **divergence** | +| Body scrolling while open | locked | locked | parity | +| Backdrop element | rendered | none; dismissal uses an outside-press listener | **divergence** | +| Sibling content while open | marked `aria-hidden` | left in the accessibility tree | **divergence** (successor is the more correct one) | +| Default placement | surface overlays the trigger | surface sits below the trigger (`side="bottom"`) | **divergence** | + +Reading: the successor is not drop-in, and the gaps are behavioral rather than cosmetic -- they cannot be closed by prop naming alone. Three of them are deliberate Base UI decisions the RFC should ratify rather than re-tune (disabled items stay focusable, sibling content stays in the accessibility tree, no backdrop), one is a straightforward default to align if we want continuity (placement), and two need an explicit decision because they change muscle memory: initial focus and Tab handling. `variant="selectedMenu"` is the sharpest of these -- it is already slated for removal on a11y grounds, and the benchmark shows it is also what makes classic focus behavior look different out of the box. + +Consequence for the API shape: a flat container can reproduce the classic _surface_ (props, slots, theming) but not the classic _behavior_, so the migration story has to be "same component, new interaction model" rather than "drop-in replacement". That argues for keeping the successor under its own name through a full major, which is what the lifecycle above already does. Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): @@ -215,7 +233,7 @@ Remaining: ### Rollout plan -1. Behavior benchmark (hard precondition): diff Material UI Menu vs Base UI Menu from the user's perspective; publish the results in this RFC to set how close to drop-in the API can be. Include the structure-sensitive cases the experiment surfaced: parent-menu layout stability while submenus open and close, and height-constrained menus near the viewport edge. +1. Behavior benchmark (hard precondition): **done** -- the classic Menu and the successor are diffed by an executable benchmark (`Menu2Benchmark.test.tsx`), and the results are in the API shape section above. Structure-sensitive cases found along the way (parent-menu layout stability while submenus open, height-constrained menus near the viewport edge) are pinned by their own regression tests. 2. Design phase for the API shape under the rules above (container boundaries, submenu shape), validated in the companion experiment -- each open question resolved against a deploy preview rather than in the abstract. 3. Land `Unstable_Menu2` in a v9 minor: conformance + legacy behavior suites with annotated skips, API reference docs, and a docs section on the Menu page (submenu, checkbox/radio, context-menu demos). 4. Iterate on feedback; stabilize as `Menu2` once the graduation checklist passes (conformance minus documented skips, theme-registration parity, pinned `data-*` boundary, design sign-off). diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx new file mode 100644 index 00000000000000..f48aff7a3884ed --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -0,0 +1,238 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; + +/** + * Behavior benchmark: the classic `Menu` against the Base UI-backed successor, + * from the user's perspective. It is the RFC's precondition for finalizing the + * API shape -- every assertion here is a difference (or a parity) the design + * phase has to accept or design around, so a failure means the benchmark needs + * re-reading, not silencing. + * + * Both harnesses use a host `button` as the trigger so that ButtonBase's + * focus-visible and ripple state updates -- which this benchmark does not + * measure -- stay out of the measurements. + */ +function ClassicMenuHarness(props: { withSelected?: boolean; variant?: 'menu' | 'selectedMenu' }) { + const { withSelected = false, variant } = props; + const [anchorEl, setAnchorEl] = React.useState(null); + + return ( +
+ +

sibling content

+ setAnchorEl(null)} + variant={variant} + > + Alpha + Beta + Gamma + +
+ ); +} + +function Menu2Harness(props: { withSelected?: boolean }) { + const { withSelected = false } = props; + + return ( +
+ + Options + + Alpha + Beta + Gamma + + +

sibling content

+
+ ); +} + +const menuEl = () => document.querySelector('[role="menu"]'); +const openTrigger = () => screen.getByRole('button', { name: 'Options' }); +const waitForOpen = () => waitFor(() => expect(menuEl()).not.to.equal(null)); + +describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { + const { render } = createRenderer(); + + describe('opening', () => { + it('classic needs the trigger wired by hand; Menu2 opens from the keyboard', async () => { + const { user } = render(); + openTrigger().focus(); + await user.keyboard('{ArrowDown}'); + // The classic Menu has no trigger part: an anchor button only opens it + // through whatever the consumer wired to onClick. + expect(menuEl()).to.equal(null); + }); + + it('Menu2 opens on ArrowDown from its trigger', async () => { + const { user } = render(); + openTrigger().focus(); + await user.keyboard('{ArrowDown}'); + await waitForOpen(); + expect(menuEl()).not.to.equal(null); + }); + }); + + describe('initial focus', () => { + it('classic moves focus onto the selected item', async () => { + const { user } = render(); + await user.click(openTrigger()); + await waitForOpen(); + // `variant="selectedMenu"` is the classic default. + expect(screen.getByRole('menuitem', { name: 'Gamma' })).toHaveFocus(); + }); + + it('Menu2 focuses the popup, leaving no item highlighted', async () => { + const { user } = render(); + await user.click(openTrigger()); + await waitForOpen(); + // Focus lands on the popup a tick after it mounts, and stays there: the + // successor highlights nothing until the user navigates. + await waitFor(() => expect(menuEl()).toHaveFocus()); + expect(screen.getByRole('menuitem', { name: 'Gamma' })).not.toHaveFocus(); + }); + }); + + describe('disabled items', () => { + it('classic never lets a disabled item take focus', async () => { + const { user } = render(); + await user.click(openTrigger()); + await waitForOpen(); + + const disabled = screen.getByRole('menuitem', { name: 'Beta' }); + // Walk the whole list twice over; classic hops over disabled entries. + for (let step = 0; step < 4; step += 1) { + // eslint-disable-next-line no-await-in-loop + await user.keyboard('{ArrowDown}'); + expect(disabled).not.toHaveFocus(); + } + }); + + it('Menu2 keeps disabled items focusable, per the WAI-ARIA menu pattern', async () => { + const { user } = render(); + await user.click(openTrigger()); + await waitForOpen(); + + const disabled = screen.getByRole('menuitem', { name: 'Beta' }); + const focused: boolean[] = []; + for (let step = 0; step < 3; step += 1) { + // eslint-disable-next-line no-await-in-loop + await user.keyboard('{ArrowDown}'); + focused.push(disabled === document.activeElement); + } + expect(focused.some(Boolean), 'the disabled item takes focus while navigating').to.equal( + true, + ); + }); + }); + + describe('dismissal', () => { + it('both restore focus to the trigger on Escape', async () => { + const { user: classicUser, unmount: unmountClassic } = render(); + const classicTrigger = openTrigger(); + await classicUser.click(classicTrigger); + await waitForOpen(); + await classicUser.keyboard('{Escape}'); + await waitFor(() => expect(menuEl()).to.equal(null)); + expect(classicTrigger).toHaveFocus(); + unmountClassic(); + + const { user: successorUser } = render(); + const successorTrigger = openTrigger(); + await successorUser.click(successorTrigger); + await waitForOpen(); + await successorUser.keyboard('{Escape}'); + await waitFor(() => expect(menuEl()).to.equal(null)); + expect(successorTrigger).toHaveFocus(); + }); + + it('classic traps Tab inside the menu; Menu2 closes on Tab', async () => { + const { user: classicUser, unmount: unmountClassic } = render(); + await classicUser.click(openTrigger()); + await waitForOpen(); + await classicUser.tab(); + expect(menuEl()).not.to.equal(null); + unmountClassic(); + + const { user: successorUser } = render(); + await successorUser.click(openTrigger()); + await waitForOpen(); + await successorUser.tab(); + await waitFor(() => expect(menuEl()).to.equal(null)); + }); + }); + + describe('page treatment while open', () => { + it('both lock body scrolling in their default modal state', async () => { + const { user: classicUser, unmount: unmountClassic } = render(); + await classicUser.click(openTrigger()); + await waitForOpen(); + expect(window.getComputedStyle(document.body).overflow).to.equal('hidden'); + unmountClassic(); + + const { user: successorUser } = render(); + await successorUser.click(openTrigger()); + await waitForOpen(); + expect(window.getComputedStyle(document.body).overflow).to.equal('hidden'); + }); + + it('classic renders a backdrop and hides siblings; Menu2 does neither', async () => { + const { user: classicUser, unmount: unmountClassic } = render(); + await classicUser.click(openTrigger()); + await waitForOpen(); + expect(document.querySelector('.MuiBackdrop-root')).not.to.equal(null); + expect( + screen.getByTestId('sibling').closest('[aria-hidden="true"]'), + 'classic marks sibling content aria-hidden', + ).not.to.equal(null); + unmountClassic(); + + const { user: successorUser } = render(); + await successorUser.click(openTrigger()); + await waitForOpen(); + expect(document.querySelector('.MuiBackdrop-root')).to.equal(null); + expect( + screen.getByTestId('sibling').closest('[aria-hidden="true"]'), + 'Menu2 leaves sibling content in the accessibility tree', + ).to.equal(null); + }); + }); + + describe('placement', () => { + it('classic overlays the trigger; Menu2 sits below it', async () => { + const { user: classicUser, unmount: unmountClassic } = render(); + const classicTrigger = openTrigger(); + const classicAnchor = classicTrigger.getBoundingClientRect(); + await classicUser.click(classicTrigger); + await waitForOpen(); + const classicSurface = document.querySelector('.MuiPaper-root')!.getBoundingClientRect(); + // Default anchorOrigin/transformOrigin put the surface over the anchor; + // it only moves to stay inside the viewport. + expect(classicSurface.top).to.be.lessThan(classicAnchor.bottom + 8); + unmountClassic(); + + const { user: successorUser } = render(); + const successorTrigger = openTrigger(); + const successorAnchor = successorTrigger.getBoundingClientRect(); + await successorUser.click(successorTrigger); + await waitForOpen(); + const successorSurface = document.querySelector('.MuiPaper-root')!.getBoundingClientRect(); + // `side="bottom"` places the surface under the trigger instead. + expect(successorSurface.top).to.be.at.least(successorAnchor.bottom - 1); + }); + }); +}); From aa9d4e7c81fa0db13634b16eb27b271e6f615cda Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Jul 2026 17:09:05 +0300 Subject: [PATCH 25/62] Correct the behavior benchmark: Tab and placement were false divergences Re-measured the three findings that looked most surprising, and two of them were measurement artifacts: - Tab: the classic Menu does close on Tab (onClose reason tabKeyDown). The first pass read the DOM before the Grow exit transition finished and recorded "stays open". Both menus close; they differ only in where focus lands afterwards (classic preventDefaults and returns to the trigger, the successor lets focus advance). - Placement: both put the surface flush under the trigger, left aligned -- classic defaults anchorOrigin to bottom/left, matching side="bottom" align="start". The earlier offsets were viewport collision handling in a cramped test layout, not a semantic difference. The benchmark now positions the trigger away from the edges and asserts the coordinates match exactly. - Initial focus: the divergence is pointer-only. Opened from the keyboard the successor highlights the first item, like classic; opened by pointer it highlights nothing so Enter cannot activate an unchosen item. Base UI exposes no initialFocus prop, so this is the one open decision that touches the API. The RFC table and its reading are rewritten accordingly: the successor is closer to drop-in than the first pass suggested. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 37 +++++---- .../Unstable_Menu2/Menu2Benchmark.test.tsx | 82 +++++++++++++------ 2 files changed, 81 insertions(+), 38 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index c3b5e6907bbf34..d2ff3e23909643 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -130,21 +130,28 @@ Hard precondition before the design is finalized: a behavior benchmark diffing t #### Benchmark results -| Dimension | Classic `Menu` | Successor | Verdict | -| ----------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------------- | -| Opening from the trigger | no trigger part; the consumer wires `onClick` by hand | `Trigger` opens on click and on ArrowDown | successor adds behavior | -| Focus after opening | moves onto the selected item (`variant="selectedMenu"` is the classic default) | lands on the popup; nothing is highlighted until the user navigates | **divergence** | -| Disabled items during keyboard navigation | skipped entirely | focusable, per the WAI-ARIA menu pattern | **divergence** (successor is the more correct one) | -| Escape | closes and restores focus to the trigger | same | parity | -| Tab while open | menu stays open (focus is trapped) | menu closes | **divergence** | -| Body scrolling while open | locked | locked | parity | -| Backdrop element | rendered | none; dismissal uses an outside-press listener | **divergence** | -| Sibling content while open | marked `aria-hidden` | left in the accessibility tree | **divergence** (successor is the more correct one) | -| Default placement | surface overlays the trigger | surface sits below the trigger (`side="bottom"`) | **divergence** | - -Reading: the successor is not drop-in, and the gaps are behavioral rather than cosmetic -- they cannot be closed by prop naming alone. Three of them are deliberate Base UI decisions the RFC should ratify rather than re-tune (disabled items stay focusable, sibling content stays in the accessibility tree, no backdrop), one is a straightforward default to align if we want continuity (placement), and two need an explicit decision because they change muscle memory: initial focus and Tab handling. `variant="selectedMenu"` is the sharpest of these -- it is already slated for removal on a11y grounds, and the benchmark shows it is also what makes classic focus behavior look different out of the box. - -Consequence for the API shape: a flat container can reproduce the classic _surface_ (props, slots, theming) but not the classic _behavior_, so the migration story has to be "same component, new interaction model" rather than "drop-in replacement". That argues for keeping the successor under its own name through a full major, which is what the lifecycle above already does. +| Dimension | Classic `Menu` | Successor | Verdict | +| ----------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------- | +| Opening from the trigger | no trigger part; the consumer wires `onClick` by hand | `Trigger` opens on click and on ArrowDown | successor adds behavior | +| Initial focus, opened by keyboard | n/a (no trigger part) | first item highlighted | matches the menu button pattern | +| Initial focus, opened by pointer | selected item highlighted, or the first item when none is selected | nothing highlighted; focus rests on the popup | **divergence** | +| Disabled items during keyboard navigation | skipped entirely | focusable, per the WAI-ARIA menu pattern | **divergence** | +| Escape | closes, focus returns to the trigger | same | parity | +| Tab while open | closes (`onClose` reason `tabKeyDown`), and `preventDefault` sends focus back to the trigger | closes, and focus advances to the next element in the tab sequence | parity on closing; differs on where focus lands | +| Body scrolling while open | locked | locked | parity | +| Backdrop element | rendered | none; dismissal uses an outside-press listener | **divergence** | +| Sibling content while open | marked `aria-hidden` | left in the accessibility tree | **divergence** | +| Default placement | flush under the trigger, left aligned (`anchorOrigin` bottom/left) | flush under the trigger, left aligned (`side="bottom" align="start"`) | parity | + +Reading: the successor is closer to drop-in than expected. Placement, scroll locking, Escape handling and Tab-closes-the-menu are already identical, and keyboard-opened menus highlight the first item in both. Four differences remain, and they split cleanly: + +- **Accessibility-driven, ratify them.** Disabled items stay focusable and sibling content stays in the accessibility tree; both follow the WAI-ARIA menu pattern, and the classic behavior is the outlier. The missing backdrop belongs here too -- dismissal now uses an outside-press listener, and a backdrop can be reintroduced as a slot if a design calls for it. +- **Needs a product decision.** Initial focus on pointer-opened menus. Base UI deliberately highlights nothing so that Enter cannot activate an item the user never chose, which is also what native desktop menus do; classic highlights the selected item (or the first). The menu button pattern only prescribes focus for _keyboard_ opening, where the two already agree, so this one is genuinely ours to choose. Note Base UI exposes no `initialFocus` prop, so aligning to classic means focusing an item ourselves after open -- fighting the substrate, and re-introducing the accidental-activation risk. +- **Cosmetic, worth aligning.** Where focus lands after Tab. Classic swallows the Tab and returns focus to the trigger; the successor lets it through. The successor's behavior is what a user pressing Tab is asking for, so this is a documentation note rather than a fix. + +The one behavior that has to be dropped either way is `variant="selectedMenu"`: it is already slated for removal on accessibility grounds, and the benchmark shows it is also the source of most of the initial-focus difference. + +Consequence for the API shape: the interaction model is close enough that a flat container really can carry the classic surface, so the migration story is "same component, a handful of documented behavior changes" rather than a rewrite. The remaining decision that touches the API is initial focus on pointer open; everything else is defaults and documentation. Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx index f48aff7a3884ed..a9c62fe71d5d15 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -88,20 +88,31 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { }); describe('initial focus', () => { - it('classic moves focus onto the selected item', async () => { + it('classic highlights an item as soon as it opens', async () => { const { user } = render(); await user.click(openTrigger()); await waitForOpen(); - // `variant="selectedMenu"` is the classic default. + // `variant="selectedMenu"` is the classic default: the selected item is + // focused, and without one the first item is. expect(screen.getByRole('menuitem', { name: 'Gamma' })).toHaveFocus(); }); - it('Menu2 focuses the popup, leaving no item highlighted', async () => { + it('Menu2 highlights the first item when opened from the keyboard', async () => { + const { user } = render(); + openTrigger().focus(); + await user.keyboard('{ArrowDown}'); + await waitForOpen(); + // Matches the WAI-ARIA menu button pattern, and matches classic's intent. + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Alpha' })).toHaveFocus()); + }); + + it('Menu2 highlights nothing when opened by pointer', async () => { const { user } = render(); await user.click(openTrigger()); await waitForOpen(); - // Focus lands on the popup a tick after it mounts, and stays there: the - // successor highlights nothing until the user navigates. + // Focus settles on the popup itself, so Enter cannot activate an item the + // user never chose. This is the one initial-focus divergence, and it only + // applies to pointer-opened menus. await waitFor(() => expect(menuEl()).toHaveFocus()); expect(screen.getByRole('menuitem', { name: 'Gamma' })).not.toHaveFocus(); }); @@ -160,19 +171,34 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { expect(successorTrigger).toHaveFocus(); }); - it('classic traps Tab inside the menu; Menu2 closes on Tab', async () => { + it('both close on Tab, but classic keeps focus on the trigger', async () => { const { user: classicUser, unmount: unmountClassic } = render(); - await classicUser.click(openTrigger()); + const classicTrigger = openTrigger(); + await classicUser.click(classicTrigger); await waitForOpen(); await classicUser.tab(); - expect(menuEl()).not.to.equal(null); + // The classic Menu closes on Tab (`onClose` reason `tabKeyDown`); the + // element lingers only while the Grow transition plays out. + await waitFor(() => expect(menuEl()).to.equal(null)); + // It also calls preventDefault, so focus returns to the trigger instead + // of advancing through the tab sequence. + expect(classicTrigger).toHaveFocus(); unmountClassic(); - const { user: successorUser } = render(); + const { user: successorUser } = render( + + + + , + ); await successorUser.click(openTrigger()); await waitForOpen(); await successorUser.tab(); await waitFor(() => expect(menuEl()).to.equal(null)); + // The successor lets the Tab through, so focus advances as the user asked. + expect(screen.getByTestId('next')).toHaveFocus(); }); }); @@ -213,26 +239,36 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { }); describe('placement', () => { - it('classic overlays the trigger; Menu2 sits below it', async () => { - const { user: classicUser, unmount: unmountClassic } = render(); - const classicTrigger = openTrigger(); - const classicAnchor = classicTrigger.getBoundingClientRect(); - await classicUser.click(classicTrigger); + it('both put the surface flush under the trigger, left aligned', async () => { + // Classic defaults to anchorOrigin bottom/left; the successor defaults to + // side="bottom" align="start". Keep the trigger away from the viewport + // edges so neither is nudged by collision handling. + const offscreenSafe = { marginLeft: 200, marginTop: 200 }; + + const { user: classicUser, unmount: unmountClassic } = render( +
+ +
, + ); + const classicAnchor = openTrigger().getBoundingClientRect(); + await classicUser.click(openTrigger()); await waitForOpen(); const classicSurface = document.querySelector('.MuiPaper-root')!.getBoundingClientRect(); - // Default anchorOrigin/transformOrigin put the surface over the anchor; - // it only moves to stay inside the viewport. - expect(classicSurface.top).to.be.lessThan(classicAnchor.bottom + 8); + expect(Math.round(classicSurface.left)).to.equal(Math.round(classicAnchor.left)); + expect(Math.round(classicSurface.top)).to.equal(Math.round(classicAnchor.bottom)); unmountClassic(); - const { user: successorUser } = render(); - const successorTrigger = openTrigger(); - const successorAnchor = successorTrigger.getBoundingClientRect(); - await successorUser.click(successorTrigger); + const { user: successorUser } = render( +
+ +
, + ); + const successorAnchor = openTrigger().getBoundingClientRect(); + await successorUser.click(openTrigger()); await waitForOpen(); const successorSurface = document.querySelector('.MuiPaper-root')!.getBoundingClientRect(); - // `side="bottom"` places the surface under the trigger instead. - expect(successorSurface.top).to.be.at.least(successorAnchor.bottom - 1); + expect(Math.round(successorSurface.left)).to.equal(Math.round(successorAnchor.left)); + expect(Math.round(successorSurface.top)).to.equal(Math.round(successorAnchor.bottom)); }); }); }); From 526edffe5e1c8f9e14828c516312ceffd6ad4411 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Jul 2026 17:38:50 +0300 Subject: [PATCH 26/62] Decide pointer-open focus: keep the Base UI behavior Opening by pointer highlights no item and rests focus on the popup, so Enter cannot activate an item the user never chose -- matching native desktop menus. Keyboard opening already highlights the first item in both menus, and the WAI-ARIA menu button pattern only prescribes focus for that case, so this was ours to choose. Aligning to the classic behavior was rejected: Base UI exposes no initialFocus prop, so it would mean focusing an item ourselves after open, fighting the substrate and reinstating the accidental-activation risk. Recorded as a documented behavior change for migrating users in the benchmark reading, the behavior notes, the compatibility posture, and open question 10. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index d2ff3e23909643..e27882ad84f258 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -146,12 +146,12 @@ Hard precondition before the design is finalized: a behavior benchmark diffing t Reading: the successor is closer to drop-in than expected. Placement, scroll locking, Escape handling and Tab-closes-the-menu are already identical, and keyboard-opened menus highlight the first item in both. Four differences remain, and they split cleanly: - **Accessibility-driven, ratify them.** Disabled items stay focusable and sibling content stays in the accessibility tree; both follow the WAI-ARIA menu pattern, and the classic behavior is the outlier. The missing backdrop belongs here too -- dismissal now uses an outside-press listener, and a backdrop can be reintroduced as a slot if a design calls for it. -- **Needs a product decision.** Initial focus on pointer-opened menus. Base UI deliberately highlights nothing so that Enter cannot activate an item the user never chose, which is also what native desktop menus do; classic highlights the selected item (or the first). The menu button pattern only prescribes focus for _keyboard_ opening, where the two already agree, so this one is genuinely ours to choose. Note Base UI exposes no `initialFocus` prop, so aligning to classic means focusing an item ourselves after open -- fighting the substrate, and re-introducing the accidental-activation risk. +- **Decided: keep the Base UI behavior.** Initial focus on pointer-opened menus stays as it is -- nothing highlighted, focus resting on the popup -- so Enter cannot activate an item the user never chose, matching native desktop menus. The menu button pattern only prescribes focus for _keyboard_ opening, where the two already agree, so this was ours to choose. Aligning to classic was the alternative and was rejected: Base UI exposes no `initialFocus` prop, so it would mean focusing an item ourselves after open -- fighting the substrate and reinstating the accidental-activation risk. This is a documented behavior change for migrating users, not a bug. - **Cosmetic, worth aligning.** Where focus lands after Tab. Classic swallows the Tab and returns focus to the trigger; the successor lets it through. The successor's behavior is what a user pressing Tab is asking for, so this is a documentation note rather than a fix. The one behavior that has to be dropped either way is `variant="selectedMenu"`: it is already slated for removal on accessibility grounds, and the benchmark shows it is also the source of most of the initial-focus difference. -Consequence for the API shape: the interaction model is close enough that a flat container really can carry the classic surface, so the migration story is "same component, a handful of documented behavior changes" rather than a rewrite. The remaining decision that touches the API is initial focus on pointer open; everything else is defaults and documentation. +Consequence for the API shape: the interaction model is close enough that a flat container really can carry the classic surface, so the migration story is "same component, a handful of documented behavior changes" rather than a rewrite. With pointer-open focus settled, none of the remaining differences change the API -- they are defaults to ratify and notes to document. Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): @@ -177,6 +177,7 @@ Behavior notes worth stating explicitly regardless of final shape: - Submenus open on hover by default in Base UI (`openOnHover` default `true`, `delay` 100ms, safe-polygon close). This is new behavior the classic Menu never had; it matches native OS menus, and it is configurable. - Escape closes the innermost submenu and returns focus to its trigger item (APG behavior); closing the whole tree is opt-in. +- Opening by pointer highlights no item; focus rests on the popup until the user navigates. Opening from the keyboard highlights the first item, as the menu button pattern prescribes. The classic Menu highlights an item in both cases, so this is a deliberate, documented change (decided). - While a submenu is open, inline focus-guard nodes sit next to its trigger inside the parent popup; see open question 7 for the styling-contract implications. - The menu surface constrains itself to `min(calc(100vh - 96px), var(--available-height))` and scrolls internally -- the classic viewport-only clamp replaced by a collision-aware one. @@ -185,7 +186,7 @@ Behavior notes worth stating explicitly regardless of final shape: Continuity where it matters, honesty where it does not: - Kept as-is: item-level presentational props (`dense`, `disableGutters`, `divider`, `selected` -- visual-only, as today), `disabled`, visual design, theming entry points, `keepMounted`, portal `container`. -- Changed deliberately (Base UI model replaces the old one): open/close control (`open`/`defaultOpen` + `onOpenChange(open, eventDetails)` instead of controlled-only `open` + `onClose(event, reason)`), positioning (`anchor`/`side`/`align`/offsets instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS `data-starting-style`/`data-ending-style` + `onOpenChangeComplete` instead of `TransitionComponent`/`Grow`). +- Changed deliberately (Base UI model replaces the old one): initial focus (pointer-opened menus highlight nothing, keyboard-opened menus highlight the first item), open/close control (`open`/`defaultOpen` + `onOpenChange(open, eventDetails)` instead of controlled-only `open` + `onClose(event, reason)`), positioning (`anchor`/`side`/`align`/offsets instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS `data-starting-style`/`data-ending-style` + `onOpenChangeComplete` instead of `TransitionComponent`/`Grow`). - Dropped intentionally: - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that degrade accessibility; `modal` and `finalFocus` cover the legitimate cases. - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: listbox-style selection behavior on `role="menu"`; initial focus is handled internally per the WAI-ARIA menu pattern. @@ -235,7 +236,7 @@ Remaining: 7. Sibling-structure styling contract around submenu triggers. While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them. Any consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens: this is the first place a Base UI implementation detail reaches the consumer styling contract, and it is invisible until runtime (the experiment shipped exactly this bug -- the separator following a trigger lost its adjacency-based margins). The built-in parts now avoid it by owning their own spacing. Options: (a) accept and document the constraint as part of the styling contract ("do not style through sibling combinators around triggers"; the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must), (b) raise upstream whether guard placement could avoid interleaving trigger siblings (for example positioning the guards at the popup boundary), (c) both. Proposal: (c) -- document now, pursue upstream hardening. 8. Bundle-size governance. Base UI adds real weight per component family; do we add a size-snapshot gate per Base UI-backed component? 9. Residual accessibility obligation. Base UI owns the interaction a11y; what does Material UI still verify on top (an axe pass in conformance, screen-reader smoke tests, contrast of the styled surfaces)? -10. Behavior defaults divergence: do we ratify Base UI's defaults where they differ from the classic Menu (hover-open submenus, non-modal scroll behavior), or re-tune them for continuity? To be fed by the behavior benchmark. +10. Behavior defaults divergence: initial focus on pointer-opened menus is **decided** -- keep the Base UI behavior (see the benchmark reading above). Still open for the other defaults: hover-open submenus (`openOnHover` on the submenu trigger, 100ms delay), and whether the non-modal scroll behavior should be the default. 11. SSR, `'use client'` boundaries, and ref typing are expected to follow the existing patterns with no divergence -- to confirm during implementation, not expected to be contentious. ### Rollout plan From 573a7fa912f94e69f2e4da86a5315abd3c127d34 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Jul 2026 18:00:54 +0300 Subject: [PATCH 27/62] Fix RFC and playground issues found by an audit pass An adversarial audit of the RFC (four independent reviewers, each finding verified by a separate skeptic) confirmed 18 issues. The substantive ones: - "dropping variant=selectedMenu removes most of the initial-focus difference" was wrong. MenuList.getDefaultActiveItemId and Menu.js autoFocusItem do not branch on variant, so classic auto-highlights an item on pointer open under either variant; selectedMenu only decides which item. Dropping it narrows the divergence without closing it. - "keyboard-opened menus highlight the first item in both" claimed a measurement the benchmark does not make: it records the classic side as not applicable, since classic has no trigger part. - the reading said "four differences" and then listed five. - the Tab bullet's heading ("worth aligning") contradicted its own conclusion (no change planned). - the appendix and compatibility posture still justified initial focus as "per WAI-ARIA menu pattern", which after the decision is only true for keyboard opening. - requirement 5 promised "zero cost" while the document reports a measured +77 B gzip from the shared style extraction. - "already slated for removal" for variant=selectedMenu had no in-repo evidence; this RFC is what proposes it. - the inset entry sat under "Remaining" though the work is closed, plus a malformed code span and a dangling "(option 1)" reference. Playground: - the classic-vs-successor parity demo hardcoded sideOffset={2} on the successor while the classic menu sits flush, inventing a placement difference in the one comparison the benchmark proves is identical. - the appearance knobs were labelled "RFC open questions" although only the default animation still is. Verified: Menu2 suite 370 passed / 120 skipped, pnpm docs:build exits 0 with all three experiment pages generated, eslint and vale clean. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-playground.tsx | 8 ++--- docs/pages/experiments/menu2-rfc.md | 40 ++++++++++----------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index c036d10a92d718..360ec90bf5f1b5 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -55,7 +55,7 @@ interface PlaygroundSettings { sideOffset: number; alignOffset: number; keepMounted: boolean; - // Appearance / RFC open questions + // Appearance; only the default animation is still an open question elevation: number; animation: 'none' | 'grow'; dense: boolean; @@ -298,7 +298,7 @@ function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings > Successor
- + {parityItems.map((item) => (
- Appearance (RFC open questions) + Appearance ` without passing `variant`, so it inherits `selectedMenu` and depends on the selected option being focused on open (it also overrides the list `role` to `listbox`). The successor cannot serve that, and by the reasoning above it should not: a `Select` built on Base UI's `Select` is the counterpart, and it needs its own design work. Promoting the successor to `Menu` in the next major therefore does not automatically migrate `Select` -- either `Select` moves to Base UI `Select` in the same major, or the classic `Menu`/`MenuList` internals stay alive to back it. + Consequence for the API shape: the interaction model is close enough that a flat container really can carry the classic surface, so the migration story is "same component, a handful of documented behavior changes" rather than a rewrite. With pointer-open focus settled, none of the remaining differences change the API -- they are defaults to ratify and notes to document. Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): From 6dba4b043bbdc72cead82f407934159a8420c004 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Jul 2026 15:02:36 +0300 Subject: [PATCH 30/62] Record why animation cannot simply be wrapped Base UI waits for animations on the popup element itself before unmounting -- useAnimationsFinished calls element.getAnimations() with no subtree option -- so an animated wrapper inside the popup gets an enter animation but a clipped exit, since the subtree is removed as soon as the popup has nothing to wait for. Animations that need a working exit must live on the popup, or use keepMounted plus actionsRef.unmount(). Recorded in open question 2 so the ship-a-default-versus-leave-it-to- userland choice is made with the constraint visible. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index e3dd7f3c1383af..52bc739e84d146 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -232,7 +232,7 @@ Remaining: ### Open questions 1. Ripple. Items are Base UI divs, so there is no `TouchRipple`. Either add `TouchRipple` to the item root slot for Material fidelity (restoring `disableRipple` and friends) or formally drop ripple on menu items. No accessibility stake; this is a design-identity decision. -2. Default open/close animation. Classic Menu animates with `Grow` by default; the experiment ships none by default but demonstrates a CSS approximation of `Grow` via `data-starting-style`/`data-ending-style` behind a toggle. Proposal: ship a default CSS transition so the component does not feel like a visual regression, overridable via plain CSS. +2. Default open/close animation. Classic Menu animates with `Grow` by default; the experiment ships none by default but demonstrates a CSS approximation of `Grow` behind a playground toggle, applied to the popup through `slotProps.popup.sx`. Leaving it entirely to userland is viable, with one constraint worth knowing before choosing: Base UI waits for animations on the **popup element itself** before unmounting -- `useAnimationsFinished` calls `element.getAnimations()` with no `subtree` -- so wrapping the menu content in an animated element gives an enter animation but a clipped exit, because the whole subtree is removed as soon as the popup has nothing to wait for. Any animation that needs a working exit has to live on the popup (or use `keepMounted` plus `actionsRef.unmount()`). Proposal: ship a default CSS transition on the popup slot, overridable through `slotProps.popup`, `styleOverrides` or the theme, so migrating apps do not silently lose the animation the classic Menu had. 3. Convenience `elevation` prop on the container: implemented in the experiment (default 8, forwards to the Paper slot) -- confirm we keep it. 4. Backdrop. Base UI has `Menu.Backdrop`; the experiment does not surface it. Expose a `backdrop` slot now or wait for demand? 5. Imperative actions. Under the typing standard, Base UI's `actionsRef` (`close()`, `unmount()`) is inherited by default -- the question is whether to curate it away, not whether to add it. From 2910cbf1167cdb8cfcadb5fa471adbc83edfb633 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Jul 2026 15:36:35 +0300 Subject: [PATCH 31/62] Ship a default menu animation and surface the backdrop slot Animation (decided): the popup surface now carries a CSS transition matching the classic Grow -- scale(0.75, 0.5625), the theme's entering/leaving durations, and the transform running at two thirds of the opacity duration -- so migrating apps keep the animation the classic Menu had. It opts out under prefers-reduced-motion and is overridable through slotProps.popup, styleOverrides or the theme. It has to sit on the popup element: Base UI waits for animations on the popup itself before unmounting (useAnimationsFinished calls element.getAnimations() with no subtree), so a transition on a descendant would animate in and be clipped on exit. Two consequences handled here: geometry read right after opening is mid-animation, so the benchmark now waits for the transition to settle, and Base UI suppresses the transition for the frame it applies the starting style, so the assertion waits too. Backdrop (decided): slots.backdrop and slotProps.backdrop now exist on the popup, mirroring the classic Menu, with a default element that is transparent and click-through like the classic invisible backdrop -- dismissal stays with Base UI's outside-press listener. It renders only when the consumer opts in: rendering it unconditionally would hand non-modal menus a full-screen layer they never had, and modal menus already get Base UI's own inert backdrop. The playground gains a backdrop knob and its animation knob now demonstrates overriding the new default away. Verified: 378 passed / 124 skipped, package typecheck and proptypes clean, and the emitted CSS asserted directly since the test runner emulates reduced motion. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-playground.tsx | 50 +++++---- docs/pages/experiments/menu2-rfc.md | 8 +- .../src/Unstable_Menu2/Menu2.test.tsx | 100 ++++++++++++++++++ .../Unstable_Menu2/Menu2Benchmark.test.tsx | 14 ++- .../src/Unstable_Menu2/menu2Classes.ts | 3 + .../src/Unstable_Menu2/menu2PopupShared.tsx | 21 +++- .../src/Unstable_Menu2/menu2SharedStyles.ts | 41 +++++++ .../src/Unstable_Menu2Popup/Menu2Popup.tsx | 28 ++++- .../Menu2SubmenuPopup.tsx | 11 +- 9 files changed, 239 insertions(+), 37 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index 360ec90bf5f1b5..281574421f2198 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -55,9 +55,10 @@ interface PlaygroundSettings { sideOffset: number; alignOffset: number; keepMounted: boolean; - // Appearance; only the default animation is still an open question + // Appearance elevation: number; - animation: 'none' | 'grow'; + backdrop: 'none' | 'dimmed'; + animation: 'default' | 'off'; dense: boolean; dividers: boolean; rtl: boolean; @@ -78,7 +79,8 @@ const defaultSettings: PlaygroundSettings = { alignOffset: 0, keepMounted: false, elevation: 8, - animation: 'none', + backdrop: 'none', + animation: 'default', dense: false, dividers: false, rtl: false, @@ -91,29 +93,21 @@ const ELEVATIONS = [0, 1, 4, 8, 16, 24]; const theme = createTheme({}); const rtlTheme = createTheme({ direction: 'rtl' }); -// RFC open question "default open/close animation": CSS approximation of the -// classic Grow transition, driven by Base UI's data-starting/ending-style. -const growPopupSx: SxProps = { - transformOrigin: 'var(--transform-origin)', - transition: - 'opacity 225ms cubic-bezier(0.4, 0, 0.2, 1), transform 225ms cubic-bezier(0.4, 0, 0.2, 1)', - '&[data-starting-style], &[data-ending-style]': { - opacity: 0, - transform: 'scale(0.8, 0.6)', - }, - '&[data-ending-style]': { - transitionDuration: '195ms, 195ms', - }, -}; +// The successor animates by default (a CSS match for the classic Grow); this +// demonstrates overriding that default away through the popup slot. +const noAnimationSx: SxProps = { transition: 'none' }; function usePopupKnobProps(settings: PlaygroundSettings) { return React.useMemo( () => ({ // Top-level convenience prop (forwards to the Paper slot). elevation: settings.elevation, - ...(settings.animation === 'grow' ? { slotProps: { popup: { sx: growPopupSx } } } : null), + ...(settings.backdrop === 'dimmed' + ? { slotProps: { backdrop: { sx: { backgroundColor: 'rgba(0, 0, 0, 0.5)' } } } } + : null), + ...(settings.animation === 'off' ? { slotProps: { popup: { sx: noAnimationSx } } } : null), }), - [settings.elevation, settings.animation], + [settings.elevation, settings.animation, settings.backdrop], ); } @@ -507,13 +501,23 @@ function SettingsPanel({ + {renderCheckbox('dense', 'dense items')} diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 52bc739e84d146..d9e0da13df749e 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -26,13 +26,13 @@ Beyond the Menu itself, this RFC pilots the standards for how future Material U ## What are the requirements? -1. Correct WAI-ARIA menu pattern behavior across nesting levels: trigger semantics (`aria-haspopup`/`aria-expanded`), RTL-aware ArrowRight/ArrowLeft submenu navigation, Escape close ordering, focus restored to the parent trigger item on close, typeahead scoped per level, arbitrary nesting depth. -2. Production-grade pointer UX: safe-polygon hover intent ("safe triangle") and hover-open with configurable delays -- explicitly the bar that past attempts failed to clear. +1. Ripple. Items render as Base UI divs, so there is no `TouchRipple` by default. Measured: consumers can get ripple today by swapping the item root slot -- `slots={{ root: ButtonBase }}` renders a real `MuiTouchRipple` and produces a ripple on press -- but they lose the Material UI item styling with it, because that CSS lives on the default styled root. Wrapping a `ButtonBase` _inside_ an item is not an option: it puts a focusable element inside a `role="menuitem"`, which auto-highlights the item on open (https://github.com/mui/base-ui/issues/2622) and is the pattern Base UI maintainers explicitly rule out. So the question is whether to ship ripple by default (make the default item root a styled `ButtonBase`, restoring `disableRipple` and friends), or leave it to the root slot and document the recipe. Design-identity call, no accessibility stake. +2. Default open/close animation. **Decided: ship one.** The popup surface now carries a CSS transition matching the classic `Grow` -- same scale ramp (`scale(0.75, 0.5625)`), the theme's entering/leaving durations, and the transform running at two thirds of the opacity duration -- so migrating apps do not silently lose the animation the classic Menu had. It opts out under `prefers-reduced-motion`, and is overridable through `slotProps.popup`, `styleOverrides` or the theme. It has to live on the popup element: Base UI waits for animations on the popup itself before unmounting (`useAnimationsFinished` calls `element.getAnimations()` with no `subtree`), so a transition on a descendant would animate in and then be clipped on exit. Consequence worth knowing: geometry read immediately after opening is now mid-animation, so measurements have to wait for the transition to settle. 3. Collision-aware positioning with automatic anchor tracking: submenus flip at viewport edges instead of clipping. -4. Pixel parity with the existing `Menu`/`MenuItem` visuals, and full theming integration: `sx`, `classes`, `component`, `slots`/`slotProps`, theme `defaultProps`/`styleOverrides`/`variants`. +4. Backdrop. **Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` now exist on the popup, mirroring the classic Menu's backdrop slot, with a default styled element that is transparent and click-through -- the classic backdrop is invisible too, and dismissal stays with Base UI's outside-press listener rather than the backdrop. Dimming is `slotProps={{ backdrop: {{ sx: {{ bgcolor: ... }} }} }}`. One deliberate difference from classic: it renders only when the consumer opts in through those props. Rendering it unconditionally would hand _non-modal_ menus a full-screen layer they never had, and modal menus already receive Base UI's own inert backdrop. 5. Near-zero cost and zero risk for existing users: the classic `Menu` keeps working unchanged, and apps that do not import the new component pay no behavior cost and no Base UI bundle cost -- only the one-time ~77 B gzip from extracting the shared styles, which the classic components now consume too. 6. API continuity with the classic `Menu` where the underlying model allows (item-level props, `container`, `keepMounted`), with deliberate and documented divergence where it does not (open/close control, positioning, transitions). -7. Cover the adjacent long-requested menu capabilities in the same API so it does not need reshaping later: checkbox/radio items, groups, hover-open menus, context-menu (cursor) positioning. +7. Sibling-structure styling contract around submenu triggers. **Decided: do both.** While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them -- so consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens. The experiment shipped exactly this bug, and the built-in parts now avoid it by owning their spacing. We document the constraint as part of the styling contract (the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must target them), and separately raise upstream whether guard placement could avoid interleaving trigger siblings, since that would benefit every Base UI consumer. 8. A credible graduation path: the component becomes `Menu` in the next major with a migration guide and codemods where feasible, so preview adopters are not stranded. 9. Sustainable maintenance: reuse a maintained primitive rather than re-implementing focus, dismissal, and positioning machinery in this repo. 10. Cross-cutting indistinguishability: the component must look indistinguishable from a normal Material UI component in tooling, theming, imports, and tests. The only new thing is the Base UI behavior substrate underneath. Users should not need to know Base UI is involved, nor install anything extra. diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index c8cc5fc5eff9f7..693c829d08bb77 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -300,6 +300,106 @@ describe('', () => { expect(await screen.findByTestId('paper')).to.have.class(paperClasses.elevation4); }); + it.skipIf(isJsdom())('animates the popup surface by default', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + const popup = await screen.findByRole('menu'); + + // Assert the emitted rule rather than the computed style: the test runner + // emulates `prefers-reduced-motion`, under which the default deliberately + // resolves to `transition: none`. + const emitted = Array.from(document.styleSheets) + .flatMap((sheet) => { + try { + return Array.from(sheet.cssRules); + } catch { + return []; + } + }) + .map((rule) => rule.cssText) + .join('\n'); + + expect(emitted).to.contain('scale(0.75, 0.5625)'); + expect(emitted).to.contain('data-starting-style'); + expect(emitted).to.contain('prefers-reduced-motion'); + expect(popup).to.have.class(menu2PopupClasses.root); + + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + // Base UI suppresses the transition for the frame in which it applies the + // starting style, so this settles a tick after the popup appears. + await waitFor(() => { + const { transitionProperty } = window.getComputedStyle(popup); + expect(transitionProperty).to.contain('opacity'); + expect(transitionProperty).to.contain('transform'); + }); + } + }); + + it.skipIf(isJsdom())('lets the default animation be overridden', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const popup = await screen.findByRole('menu'); + expect(window.getComputedStyle(popup).transitionProperty).to.equal('none'); + }); + + it('renders an invisible backdrop that does not swallow clicks', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const backdrop = await screen.findByTestId('backdrop'); + expect(backdrop).to.have.class(menu2PopupClasses.backdrop); + // Invisible and inert by default, like the classic Menu's backdrop; + // dismissal stays with Base UI's outside-press listener. + const { backgroundColor, pointerEvents } = window.getComputedStyle(backdrop); + expect(backgroundColor).to.equal('rgba(0, 0, 0, 0)'); + expect(pointerEvents).to.equal('none'); + }); + + it('supports dimming through the backdrop slot', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const backdrop = await screen.findByTestId('backdrop'); + expect(window.getComputedStyle(backdrop).backgroundColor).to.equal('rgb(0, 0, 0)'); + }); + it.skipIf(isJsdom())('constrains the popup surface to the collision-aware height', async () => { const { user } = render( diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx index c73ab398a97989..af3d3c7031ddd5 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -67,6 +67,16 @@ const menuEl = () => document.querySelector('[role="menu"]'); const openTrigger = () => screen.getByRole('button', { name: 'Options' }); const waitForOpen = () => waitFor(() => expect(menuEl()).not.to.equal(null)); +// The successor animates its surface by default, so geometry has to be read +// after the open transition settles. +async function waitForSettled() { + await waitForOpen(); + const popup = menuEl()!; + if (typeof popup.getAnimations === 'function') { + await Promise.all(popup.getAnimations().map((animation) => animation.finished.catch(() => {}))); + } +} + describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { const { render } = createRenderer(); @@ -292,7 +302,7 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { ); const classicAnchor = openTrigger().getBoundingClientRect(); await classicUser.click(openTrigger()); - await waitForOpen(); + await waitForSettled(); const classicSurface = document.querySelector('.MuiPaper-root')!.getBoundingClientRect(); expect(Math.round(classicSurface.left)).to.equal(Math.round(classicAnchor.left)); expect(Math.round(classicSurface.top)).to.equal(Math.round(classicAnchor.bottom)); @@ -305,7 +315,7 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { ); const successorAnchor = openTrigger().getBoundingClientRect(); await successorUser.click(openTrigger()); - await waitForOpen(); + await waitForSettled(); const successorSurface = document.querySelector('.MuiPaper-root')!.getBoundingClientRect(); expect(Math.round(successorSurface.left)).to.equal(Math.round(successorAnchor.left)); expect(Math.round(successorSurface.top)).to.equal(Math.round(successorAnchor.bottom)); diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts index 8c2ee021bb0e00..1547d56b3aab8f 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts @@ -25,6 +25,8 @@ export const menu2TriggerClasses: Menu2TriggerClasses = generateUtilityClasses(' export interface Menu2PopupClasses { /** Styles applied to the root element. */ root: string; + /** Styles applied to the backdrop element. */ + backdrop: string; /** Styles applied to the Material Paper element. */ paper: string; /** Styles applied to the Material List element. */ @@ -39,6 +41,7 @@ export function getMenu2PopupUtilityClass(slot: string): string { export const menu2PopupClasses: Menu2PopupClasses = generateUtilityClasses('MuiMenu2Popup', [ 'root', + 'backdrop', 'paper', 'list', ]); diff --git a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx index ff688a1f64a982..70cd3b9ddd6a76 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -91,6 +91,12 @@ export interface Menu2PopupSharedSlots { * @default BaseMenu.Portal */ portal?: React.ElementType | undefined; + /** + * The component used for the backdrop rendered beneath the menu. + * Only rendered by menus that provide a backdrop; it is transparent and + * click-through by default, matching the classic Menu's invisible backdrop. + */ + backdrop?: React.ElementType | undefined; /** * The component used for the positioner. * @default BaseMenu.Positioner @@ -115,6 +121,7 @@ export interface Menu2PopupSharedSlots { export interface Menu2PopupSharedSlotProps { portal?: SlotProps, OwnerState> | undefined; + backdrop?: SlotProps, OwnerState> | undefined; positioner?: SlotProps, OwnerState> | undefined; popup?: SlotProps, OwnerState> | undefined; paper?: SlotProps, OwnerState> | undefined; @@ -193,7 +200,7 @@ export interface Menu2PopupSharedProps extends Omit, Menu2PopupPublicProps { - classes?: Partial> | undefined; + classes?: Partial> | undefined; ownerState: OwnerState; slots?: Menu2PopupSharedSlots | undefined; slotProps?: Menu2PopupSharedSlotProps | undefined; @@ -201,6 +208,7 @@ export interface Menu2PopupSharedProps popup: React.ElementType; paper: React.ElementType; list: React.ElementType; + backdrop?: React.ElementType | undefined; }; defaultPositionerProps?: Partial | undefined; sx?: SxProps | undefined; @@ -242,12 +250,16 @@ export const Menu2PopupBase = React.forwardRef(function Menu2PopupBase + {BackdropSlot ? ( + + ) : null} ({ + transformOrigin: 'var(--transform-origin)', + transition: [ + theme.transitions.create('opacity', { + duration: theme.transitions.duration.enteringScreen, + }), + theme.transitions.create('transform', { + duration: theme.transitions.duration.enteringScreen * 0.666, + }), + ].join(','), + '&[data-starting-style], &[data-ending-style]': { + opacity: 0, + transform: 'scale(0.75, 0.5625)', + }, + '&[data-ending-style]': { + transition: [ + theme.transitions.create('opacity', { + duration: theme.transitions.duration.leavingScreen, + }), + theme.transitions.create('transform', { + duration: theme.transitions.duration.leavingScreen * 0.666, + }), + ].join(','), + }, + '@media (prefers-reduced-motion: reduce)': { + '&, &[data-ending-style]': { + transition: 'none', + }, + }, +})); + export const menu2IndicatorStyles = memoTheme(({ theme }) => ({ display: 'inline-flex', alignItems: 'center', diff --git a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx index 231213e0b52040..d224e4418f38ed 100644 --- a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx +++ b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; import HTMLElementType from '@mui/utils/HTMLElementType'; import { SxProps } from '@mui/system'; import Paper from '../Paper'; @@ -15,7 +16,11 @@ import { Menu2PopupSharedProps, Menu2PopupSharedSlotProps, } from '../Unstable_Menu2/menu2PopupShared'; -import { menu2PopupListStyles, menu2PopupPaperStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + menu2PopupListStyles, + menu2PopupPaperStyles, + menu2PopupTransitionStyles, +} from '../Unstable_Menu2/menu2SharedStyles'; import { getMenu2PopupUtilityClass, Menu2PopupClasses } from '../Unstable_Menu2/menu2Classes'; export interface Menu2PopupProps extends Omit< @@ -167,6 +172,7 @@ const useUtilityClasses = (ownerState: Menu2PopupOwnerState) => { const slots = { root: ['root'], + backdrop: ['backdrop'], paper: ['paper'], list: ['list'], }; @@ -178,9 +184,23 @@ const Menu2PopupRoot = styled('div', { name: 'MuiMenu2Popup', slot: 'Root', overridesResolver: (props, styles) => styles.root, +})({ outline: 0 }, menu2PopupTransitionStyles); + +const Menu2PopupBackdrop = styled(BaseMenu.Backdrop, { + name: 'MuiMenu2Popup', + slot: 'Backdrop', + overridesResolver: (props, styles) => styles.backdrop, })({ - outline: 0, -}); + position: 'fixed', + inset: 0, + // Invisible and inert by default, matching the classic Menu's backdrop. + // Dismissal is handled by Base UI's outside-press listener, so the backdrop + // does not need to capture clicks; set `pointerEvents` in `slotProps` to + // change that when dimming. + backgroundColor: 'transparent', + pointerEvents: 'none', + WebkitTapHighlightColor: 'transparent', +}) as any; const Menu2PopupPaper = styled(Paper, { name: 'MuiMenu2Popup', @@ -226,6 +246,7 @@ const Menu2Popup = React.forwardRef(function Menu2Popup( popup: Menu2PopupRoot, paper: Menu2PopupPaper, list: Menu2PopupList, + backdrop: Menu2PopupBackdrop, }} defaultPositionerProps={{ side: 'bottom', @@ -372,6 +393,7 @@ Menu2Popup.propTypes /* remove-proptypes */ = { * The props used for each slot inside. */ slotProps: PropTypes.shape({ + backdrop: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), list: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), popup: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx index eaabffb3f8b019..540b1268037129 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx @@ -15,7 +15,11 @@ import { Menu2PopupSharedProps, Menu2PopupSharedSlotProps, } from '../Unstable_Menu2/menu2PopupShared'; -import { menu2PopupListStyles, menu2PopupPaperStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + menu2PopupListStyles, + menu2PopupPaperStyles, + menu2PopupTransitionStyles, +} from '../Unstable_Menu2/menu2SharedStyles'; import { getMenu2SubmenuPopupUtilityClass, Menu2SubmenuPopupClasses, @@ -181,9 +185,7 @@ const Menu2SubmenuPopupRoot = styled('div', { name: 'MuiMenu2SubmenuPopup', slot: 'Root', overridesResolver: (props, styles) => styles.root, -})({ - outline: 0, -}); +})({ outline: 0 }, menu2PopupTransitionStyles); const Menu2SubmenuPopupPaper = styled(Paper, { name: 'MuiMenu2SubmenuPopup', @@ -375,6 +377,7 @@ Menu2SubmenuPopup.propTypes /* remove-proptypes */ = { * The props used for each slot inside. */ slotProps: PropTypes.shape({ + backdrop: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), list: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), popup: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), From fb44442138681a37a68a4c79e906ae9b1ec41135 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Jul 2026 15:46:38 +0300 Subject: [PATCH 32/62] Record four confirmations and repair the requirements list Decisions: - elevation: keep the top-level convenience prop (default 8, forwards to the Paper slot) - actionsRef: inherit Base UI's as-is -- no Material-flavoured rename and no hand-rolled action ref, since renaming creates permanent wrapper drift and hand-rolling duplicates machinery Base UI owns. The classic action ref's updatePosition has no counterpart because the positioner tracks the anchor automatically - submenus keep opening on hover by default - context menu: reframed as an open question about whether a dedicated component should own the focus-restore quirk internally, rather than shipping a recipe whose finalFocus requirement is invisible until a second menu exists on the page Correction: the previous draft asked whether non-modal should be the default. There was never a question there -- Base UI's modal defaults to true, the classic Menu is always modal, and the benchmark measured scroll locking as parity. The successor merely adds the ability to opt out with modal={false}. Repair: the previous commit's edits matched the numbered requirements list instead of the open questions, overwriting requirements 1, 2, 4 and 7 (WAI-ARIA behavior, pointer UX, pixel parity, adjacent capabilities). Rebuilt from the last good revision and re-applied every open-question edit scoped to that section. Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index d9e0da13df749e..dff87ff63b5d33 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -26,13 +26,13 @@ Beyond the Menu itself, this RFC pilots the standards for how future Material U ## What are the requirements? -1. Ripple. Items render as Base UI divs, so there is no `TouchRipple` by default. Measured: consumers can get ripple today by swapping the item root slot -- `slots={{ root: ButtonBase }}` renders a real `MuiTouchRipple` and produces a ripple on press -- but they lose the Material UI item styling with it, because that CSS lives on the default styled root. Wrapping a `ButtonBase` _inside_ an item is not an option: it puts a focusable element inside a `role="menuitem"`, which auto-highlights the item on open (https://github.com/mui/base-ui/issues/2622) and is the pattern Base UI maintainers explicitly rule out. So the question is whether to ship ripple by default (make the default item root a styled `ButtonBase`, restoring `disableRipple` and friends), or leave it to the root slot and document the recipe. Design-identity call, no accessibility stake. -2. Default open/close animation. **Decided: ship one.** The popup surface now carries a CSS transition matching the classic `Grow` -- same scale ramp (`scale(0.75, 0.5625)`), the theme's entering/leaving durations, and the transform running at two thirds of the opacity duration -- so migrating apps do not silently lose the animation the classic Menu had. It opts out under `prefers-reduced-motion`, and is overridable through `slotProps.popup`, `styleOverrides` or the theme. It has to live on the popup element: Base UI waits for animations on the popup itself before unmounting (`useAnimationsFinished` calls `element.getAnimations()` with no `subtree`), so a transition on a descendant would animate in and then be clipped on exit. Consequence worth knowing: geometry read immediately after opening is now mid-animation, so measurements have to wait for the transition to settle. +1. Correct WAI-ARIA menu pattern behavior across nesting levels: trigger semantics (`aria-haspopup`/`aria-expanded`), RTL-aware ArrowRight/ArrowLeft submenu navigation, Escape close ordering, focus restored to the parent trigger item on close, typeahead scoped per level, arbitrary nesting depth. +2. Production-grade pointer UX: safe-polygon hover intent ("safe triangle") and hover-open with configurable delays -- explicitly the bar that past attempts failed to clear. 3. Collision-aware positioning with automatic anchor tracking: submenus flip at viewport edges instead of clipping. -4. Backdrop. **Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` now exist on the popup, mirroring the classic Menu's backdrop slot, with a default styled element that is transparent and click-through -- the classic backdrop is invisible too, and dismissal stays with Base UI's outside-press listener rather than the backdrop. Dimming is `slotProps={{ backdrop: {{ sx: {{ bgcolor: ... }} }} }}`. One deliberate difference from classic: it renders only when the consumer opts in through those props. Rendering it unconditionally would hand _non-modal_ menus a full-screen layer they never had, and modal menus already receive Base UI's own inert backdrop. +4. Pixel parity with the existing `Menu`/`MenuItem` visuals, and full theming integration: `sx`, `classes`, `component`, `slots`/`slotProps`, theme `defaultProps`/`styleOverrides`/`variants`. 5. Near-zero cost and zero risk for existing users: the classic `Menu` keeps working unchanged, and apps that do not import the new component pay no behavior cost and no Base UI bundle cost -- only the one-time ~77 B gzip from extracting the shared styles, which the classic components now consume too. 6. API continuity with the classic `Menu` where the underlying model allows (item-level props, `container`, `keepMounted`), with deliberate and documented divergence where it does not (open/close control, positioning, transitions). -7. Sibling-structure styling contract around submenu triggers. **Decided: do both.** While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them -- so consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens. The experiment shipped exactly this bug, and the built-in parts now avoid it by owning their spacing. We document the constraint as part of the styling contract (the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must target them), and separately raise upstream whether guard placement could avoid interleaving trigger siblings, since that would benefit every Base UI consumer. +7. Cover the adjacent long-requested menu capabilities in the same API so it does not need reshaping later: checkbox/radio items, groups, hover-open menus, context-menu (cursor) positioning. 8. A credible graduation path: the component becomes `Menu` in the next major with a migration guide and codemods where feasible, so preview adopters are not stranded. 9. Sustainable maintenance: reuse a maintained primitive rather than re-implementing focus, dismissal, and positioning machinery in this repo. 10. Cross-cutting indistinguishability: the component must look indistinguishable from a normal Material UI component in tooling, theming, imports, and tests. The only new thing is the Base UI behavior substrate underneath. Users should not need to know Base UI is involved, nor install anything extra. @@ -231,16 +231,16 @@ Remaining: ### Open questions -1. Ripple. Items are Base UI divs, so there is no `TouchRipple`. Either add `TouchRipple` to the item root slot for Material fidelity (restoring `disableRipple` and friends) or formally drop ripple on menu items. No accessibility stake; this is a design-identity decision. -2. Default open/close animation. Classic Menu animates with `Grow` by default; the experiment ships none by default but demonstrates a CSS approximation of `Grow` behind a playground toggle, applied to the popup through `slotProps.popup.sx`. Leaving it entirely to userland is viable, with one constraint worth knowing before choosing: Base UI waits for animations on the **popup element itself** before unmounting -- `useAnimationsFinished` calls `element.getAnimations()` with no `subtree` -- so wrapping the menu content in an animated element gives an enter animation but a clipped exit, because the whole subtree is removed as soon as the popup has nothing to wait for. Any animation that needs a working exit has to live on the popup (or use `keepMounted` plus `actionsRef.unmount()`). Proposal: ship a default CSS transition on the popup slot, overridable through `slotProps.popup`, `styleOverrides` or the theme, so migrating apps do not silently lose the animation the classic Menu had. -3. Convenience `elevation` prop on the container: implemented in the experiment (default 8, forwards to the Paper slot) -- confirm we keep it. -4. Backdrop. Base UI has `Menu.Backdrop`; the experiment does not surface it. Expose a `backdrop` slot now or wait for demand? -5. Imperative actions. Under the typing standard, Base UI's `actionsRef` (`close()`, `unmount()`) is inherited by default -- the question is whether to curate it away, not whether to add it. -6. Context menu (right-click / cursor positioning). Classic `anchorPosition` use cases are covered by a virtual-element `anchor` recipe in the experiment. Base UI also has a dedicated `ContextMenu` component. Recipe now, dedicated component later? The recipe surfaced a focus-restore hole that sharpens this question: a detached menu has no trigger to restore focus to, and on close Base UI falls back to an internal previously-focused-element record -- which can be a stale, unrelated menu trigger from an earlier interaction on the same page. The recipe must pass `finalFocus` (the invoked surface, per the APG context-menu pattern) to behave correctly; a wrapped `ContextMenu` component would remove that footgun entirely, since its trigger is the right-click surface itself. -7. Sibling-structure styling contract around submenu triggers. While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them. Any consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens: this is the first place a Base UI implementation detail reaches the consumer styling contract, and it is invisible until runtime (the experiment shipped exactly this bug -- the separator following a trigger lost its adjacency-based margins). The built-in parts now avoid it by owning their own spacing. Options: (a) accept and document the constraint as part of the styling contract ("do not style through sibling combinators around triggers"; the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must), (b) raise upstream whether guard placement could avoid interleaving trigger siblings (for example positioning the guards at the popup boundary), (c) both. Proposal: (c) -- document now, pursue upstream hardening. +1. Ripple. Items render as Base UI divs, so there is no `TouchRipple` by default. Measured: consumers can get ripple today by swapping the item root slot -- `slots={{ root: ButtonBase }}` renders a real `MuiTouchRipple` and produces a ripple on press -- but they lose the Material UI item styling with it, because that CSS lives on the default styled root. Wrapping a `ButtonBase` _inside_ an item is not an option: it puts a focusable element inside a `role="menuitem"`, which auto-highlights the item on open (https://github.com/mui/base-ui/issues/2622) and is the pattern Base UI maintainers explicitly rule out. So the question is whether to ship ripple by default (make the default item root a styled `ButtonBase`, restoring `disableRipple` and friends), or leave it to the root slot and document the recipe. Design-identity call, no accessibility stake. +2. Default open/close animation. **Decided: ship one.** The popup surface carries a CSS transition matching the classic `Grow` -- same scale ramp (`scale(0.75, 0.5625)`), the theme's entering/leaving durations, and the transform running at two thirds of the opacity duration -- so migrating apps do not silently lose the animation the classic Menu had. It opts out under `prefers-reduced-motion` and is overridable through `slotProps.popup`, `styleOverrides` or the theme. It has to live on the popup element: Base UI waits for animations on the popup itself before unmounting (`useAnimationsFinished` calls `element.getAnimations()` with no `subtree`), so a transition on a descendant would animate in and then be clipped on exit. Two consequences: geometry read immediately after opening is mid-animation, and Base UI suppresses the transition for the frame in which it applies the starting style -- both mean measurements have to wait for it to settle. +3. Convenience `elevation` prop on the container. **Decided: keep it.** The popup takes a top-level `elevation` (default 8, matching classic) and forwards it to the Paper slot, so the common case does not have to go through `slotProps.paper`. +4. Backdrop. **Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` exist on the popup, mirroring the classic Menu's backdrop slot, with a default element that is transparent and click-through -- the classic backdrop is invisible too, and dismissal stays with Base UI's outside-press listener rather than the backdrop. Dimming is `slotProps={{ backdrop: {{ sx: {{ bgcolor: ... }} }} }}`. One deliberate difference from classic: it renders only when the consumer opts in through those props, because rendering it unconditionally would hand _non-modal_ menus a full-screen layer they never had, and modal menus already receive Base UI's own inert backdrop. +5. Imperative actions. **Decided: inherit Base UI's `actionsRef` as-is.** It arrives through the prop-typing standard (`close()`, `unmount()`), and we neither rename it to something Material UI-flavoured nor hand-roll a parallel `action` ref like the classic Menu had. Renaming would create permanent wrapper drift for no gain, and a hand-rolled ref would duplicate machinery Base UI already owns. The classic `action` ref's other job, `updatePosition()`, has no counterpart because the positioner tracks the anchor automatically. +6. Context menu (right-click / cursor positioning): does this need its own component? Classic `anchorPosition` use cases work today through a virtual-element `anchor` recipe, but the recipe carries a focus-restore quirk that bit us in the experiment: a detached menu has no trigger to return focus to, so on close Base UI falls back to an internal previously-focused-element record, which can be a stale trigger from an unrelated menu elsewhere on the page. The recipe has to pass `finalFocus` (the invoked surface, per the APG context-menu pattern) to behave correctly, and nothing in the API makes that obvious -- it looks fine until a second menu exists on the page. A component wrapping Base UI's dedicated `ContextMenu` would own that internally, since its trigger is the right-click surface itself. So: ship the recipe with a documented caveat, or absorb the quirk into a component? +7. Sibling-structure styling contract around submenu triggers. **Decided: do both.** While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them -- so consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens. The experiment shipped exactly this bug, and the built-in parts now avoid it by owning their spacing. We document the constraint as part of the styling contract (the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must target them), and separately raise upstream whether guard placement could avoid interleaving trigger siblings, since that would benefit every Base UI consumer. 8. Bundle-size governance. Base UI adds real weight per component family; do we add a size-snapshot gate per Base UI-backed component? 9. Residual accessibility obligation. Base UI owns the interaction a11y; what does Material UI still verify on top (an axe pass in conformance, screen-reader smoke tests, contrast of the styled surfaces)? -10. Behavior defaults divergence: initial focus on pointer-opened menus is **decided** -- keep the Base UI behavior (see the benchmark reading above). Still open for the other defaults: hover-open submenus (`openOnHover` on the submenu trigger, 100ms delay), and whether the non-modal scroll behavior should be the default. +10. Behavior defaults divergence. Two are now **decided**: initial focus on pointer-opened menus keeps the Base UI behavior (see the benchmark reading), and submenus keep opening on hover by default (`openOnHover` on the submenu trigger, 100ms delay, safe-polygon close) -- behavior the classic Menu never had, matching native desktop menus, and configurable per trigger. Correction to an earlier draft of this question: there is no open question about modality. Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as parity. What the successor adds is the ability to opt out with `modal={false}`, a new capability rather than a changed default. 11. SSR, `'use client'` boundaries, and ref typing are expected to follow the existing patterns with no divergence -- to confirm during implementation, not expected to be contentious. ### Rollout plan From 942e7de5e9768a5b27b2d1e6761b9b25ddad3e4f Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Jul 2026 17:32:22 +0300 Subject: [PATCH 33/62] Shorten the RFC and use plainer wording Cuts the draft from ~6,400 to ~4,800 words without dropping decisions, facts or links. Most of the reduction is redundancy rather than content: the "here is how the experiment did it" tails inside the standards bullets, the four-paragraph selectedMenu discussion, the per-blocker prose in Option A, and the paragraph-length justifications on questions that are already decided. Bare URLs became named links. Wording is plainer throughout: "the rule" instead of "meta-principle", "keep, they are accessibility fixes" instead of "ratify them", "our API drifts from Base UI's" instead of "wrapper drift", shorter sentences and fewer em-dash chains. Three appendix rows were stale and are corrected: the backdrop is surfaced (not "not surfaced"), hideBackdrop maps to the backdrop slot, and the default animation ships rather than being a proposal. An audit pass over the rewrite caught two mistakes of mine, both fixed: the differences count said four while five were listed, and the #48651 link had been re-pointed at a claim that PR does not make. It also flagged seven places where trimming had removed something a reader needs -- that dropping selectedMenu narrows the pointer-focus difference without closing it, the mid-animation measurement caveat, the scoping caveat on presentational props, the measured bundle number behind #20591, what finalFocus points at, the parity criterion for testing, and that onOpenChange exposes the native event -- all restored in short form. Verified: pnpm docs:build exits 0 with the page generated, vale clean, and the structure checked (20 headings, 10 requirements, 11 questions, 6 balanced details blocks). Co-Authored-By: Claude Fable 5 --- docs/pages/experiments/menu2-rfc.md | 420 ++++++++++++++-------------- 1 file changed, 210 insertions(+), 210 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index dff87ff63b5d33..caaab97e277ec4 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -13,151 +13,155 @@ Structured for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below in ## What's the problem? -Material UI's `Menu` cannot express submenus (nested menus): +Material UI's `Menu` cannot do submenus. -- It is one of the oldest and most-requested features: https://github.com/mui/material-ui/issues/11723 has been open since 2018 with 120+ reactions (plus duplicates such as https://github.com/mui/material-ui/issues/8152). -- Material UI v0.x supported nested menus (https://github.com/mui/material-ui/pull/2148); the capability was lost in the v1 rewrite and never recovered. -- Community workarounds (`material-ui-nested-menu-item`, `mui-nested-menu`, `material-ui-popup-state` recipes, many sandboxes) are consistently incomplete on keyboard navigation and ARIA, which maintainers have called out repeatedly in the issue threads. -- The Menubar docs page already ships Base UI-composed submenus as copy-paste code, and users immediately asked for a maintained, in-package component (https://github.com/mui/material-ui/issues/48336). Copy-paste code is not versioned, tested, or theme-integrated. +- It is one of our oldest requests: [#11723](https://github.com/mui/material-ui/issues/11723) has been open since 2018 with 120+ reactions. +- Material UI v0.x had nested menus ([#2148](https://github.com/mui/material-ui/pull/2148)). The v1 rewrite dropped them. +- Community packages (`mui-nested-menu`, `material-ui-popup-state`, many sandboxes) all fall short on keyboard support and ARIA, which maintainers have pointed out repeatedly. +- The Menubar docs page already ships Base UI submenus as copy-paste code, and people immediately asked for a real component ([#48336](https://github.com/mui/material-ui/issues/48336)). Copy-paste code is not versioned, tested, or themed. -Desired outcome: first-class, accessible submenu support in `@mui/material` -- with Material visuals and full theming -- without destabilizing the existing `Menu`, and on a path to becoming the default `Menu` in the next major. +We want submenus in `@mui/material` with Material visuals and full theming, without destabilizing today's `Menu`, on a path to becoming the default `Menu` in the next major. -Beyond the Menu itself, this RFC pilots the standards for how future Material UI components are built on top of Base UI (customization contract, styling reuse, dependency shape, testing, tooling). Menu is the reference implementation; the cross-cutting decisions below are meant to apply to every Base UI-backed component that follows. +This RFC also sets the rules for building future Material UI components on Base UI: customization, styling reuse, dependencies, testing, and tooling. Menu is the first one; the decisions here are meant to apply to the rest. ## What are the requirements? -1. Correct WAI-ARIA menu pattern behavior across nesting levels: trigger semantics (`aria-haspopup`/`aria-expanded`), RTL-aware ArrowRight/ArrowLeft submenu navigation, Escape close ordering, focus restored to the parent trigger item on close, typeahead scoped per level, arbitrary nesting depth. -2. Production-grade pointer UX: safe-polygon hover intent ("safe triangle") and hover-open with configurable delays -- explicitly the bar that past attempts failed to clear. -3. Collision-aware positioning with automatic anchor tracking: submenus flip at viewport edges instead of clipping. -4. Pixel parity with the existing `Menu`/`MenuItem` visuals, and full theming integration: `sx`, `classes`, `component`, `slots`/`slotProps`, theme `defaultProps`/`styleOverrides`/`variants`. -5. Near-zero cost and zero risk for existing users: the classic `Menu` keeps working unchanged, and apps that do not import the new component pay no behavior cost and no Base UI bundle cost -- only the one-time ~77 B gzip from extracting the shared styles, which the classic components now consume too. -6. API continuity with the classic `Menu` where the underlying model allows (item-level props, `container`, `keepMounted`), with deliberate and documented divergence where it does not (open/close control, positioning, transitions). -7. Cover the adjacent long-requested menu capabilities in the same API so it does not need reshaping later: checkbox/radio items, groups, hover-open menus, context-menu (cursor) positioning. -8. A credible graduation path: the component becomes `Menu` in the next major with a migration guide and codemods where feasible, so preview adopters are not stranded. -9. Sustainable maintenance: reuse a maintained primitive rather than re-implementing focus, dismissal, and positioning machinery in this repo. -10. Cross-cutting indistinguishability: the component must look indistinguishable from a normal Material UI component in tooling, theming, imports, and tests. The only new thing is the Base UI behavior substrate underneath. Users should not need to know Base UI is involved, nor install anything extra. +1. Correct menu behavior at every nesting level: trigger semantics, RTL-aware arrow keys, Escape closing one level at a time, focus returning to the parent item, typeahead per level, any nesting depth. +2. Good pointer behavior: a submenu must stay open while the pointer moves diagonally toward it (the "safe triangle"), plus hover-open delays. This is the bar earlier attempts failed to clear. +3. Collision-aware positioning: submenus flip at screen edges instead of getting cut off. +4. Same look as today's `Menu`/`MenuItem`, and full theming: `sx`, `classes`, `component`, `slots`/`slotProps`, and theme `defaultProps`/`styleOverrides`/`variants`. +5. Near-zero cost for existing users. Today's `Menu` keeps working. Apps that do not import the new component get no behavior change and none of Base UI's bundle cost. They do pay a one-time ~77 B gzip, because the classic components now read the styles the new ones share. +6. Keep the current API where the new foundation allows, and document the places where it cannot. +7. Cover the other long-requested menu features at the same time, so the API does not need reshaping later: checkbox and radio items, groups, hover-open, context menus. +8. A clear path to becoming `Menu` in the next major, with a migration guide and codemods, so early adopters are not stranded. +9. Reuse a maintained library instead of rebuilding focus, dismissal, and positioning ourselves. +10. It should look like any other Material UI component in tooling, theming, imports, and tests. Users should not need to know Base UI is involved or install anything extra. ## What are our options? -### Option A: Add submenus to the existing Menu stack +### Option A: Add submenus to the existing Menu -This has been attempted three times over eight years; each attempt got further than the last and hit the same walls: +Tried three times in eight years, always blocked by the same things: -- https://github.com/mui/material-ui/pull/14700 (2019, +267 lines): recursive Menu-in-Menu. Closed with "It's something we will want to solve at the core level. I'm pretty sure we need to change the menu implementation and to expose new objects to make it happen." -- https://github.com/mui/material-ui/pull/20591 (2020-2022, +1333 lines, ~22 months of review): `subMenu` prop on `MenuItem` implemented inside core via `cloneElement`. Stalled on a compound of blockers: a double-digit relative gzip increase to the core bundle, hard UX requirements (safe-triangle hover intent, collision-aware placement -- blocked on Popover internals), test-infrastructure churn, and the risk of destabilizing a core component right before v5. The closing review rejected the `cloneElement` approach and concluded the path forward was to rebuild on headless menu primitives. -- https://github.com/mui/material-ui/pull/37570 (2023-2024): docs-demo-only approach. Closed after an accessibility review found fundamental gaps (Escape handling, `aria-expanded`, screen reader announcements, close ordering), with the explicit direction: "I think it would make more sense to focus on bringing this to Base UI." +- [#14700](https://github.com/mui/material-ui/pull/14700) (2019): recursive Menu-in-Menu. Closed with "we need to change the menu implementation and to expose new objects to make it happen". +- [#20591](https://github.com/mui/material-ui/pull/20591) (2020-2022, +1333 lines, ~22 months of review): a `subMenu` prop on `MenuItem` using `cloneElement`. Blocked by a double-digit percentage gzip increase to the core bundle, hover intent, missing collision handling in `Popover`, test churn, and the risk of touching a core component before v5. The final review rejected `cloneElement` and suggested rebuilding on headless primitives. +- [#37570](https://github.com/mui/material-ui/pull/37570) (2023-2024): docs demo only. Closed after an accessibility review found gaps in Escape handling, `aria-expanded`, and screen reader support, with the note: "it would make more sense to focus on bringing this to Base UI". -The failures are structural, not incidental. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal -> FocusTrap/Backdrop/ModalManager`), and nesting two of them fights the stack in at least six places: +The blockers are structural. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal`), and nesting two of them breaks in six places: -1. Dismissal model: each menu renders an invisible full-screen backdrop that captures clicks. A submenu's backdrop stacks above the parent's paper, making parent items non-interactive and closing the child on any parent click. Fixing this means replacing backdrop dismissal with a coordinated click-away model across the whole menu tree. -2. `ModalManager` sets `aria-hidden="true"` on all body children except the top-most modal -- opening a submenu removes the parent menu from the accessibility tree. -3. Keyboard: the roving tabindex handler treats ArrowRight/ArrowLeft as no-ops for vertical lists, and `MenuList`/`MenuItem` expose no hook point for "open submenu on ArrowRight" or trigger semantics. -4. Focus: each modal has its own focus trap and per-trap restore target; closing a submenu must restore focus to the parent trigger item, which the per-trap model does not coordinate. -5. Positioning: `Popover` has no collision flipping -- a right-opening submenu near the viewport edge clips instead of flipping to the other side. -6. Each `MenuList` owns an isolated keyboard/typeahead registry; nested lists share no active-item model. +1. Each menu renders a full-screen backdrop that captures clicks, so a submenu's backdrop covers its parent and closes the child when you click the parent. +2. `ModalManager` sets `aria-hidden` on everything except the top modal, so opening a submenu hides the parent from screen readers. +3. ArrowRight/ArrowLeft do nothing in a vertical list, and there is no hook for "open the submenu". +4. Each modal has its own focus trap, and they do not coordinate when a submenu closes. +5. `Popover` has no collision flipping, so a submenu near the screen edge is cut off. +6. Each `MenuList` keeps its own keyboard state, so nested lists share nothing. -Meeting requirements 1-3 this way means touching `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`, and replacing two load-bearing models (backdrop dismissal, per-modal focus trapping) shared with `Dialog` and every `Popover` consumer. That is a re-implementation of exactly the machinery Base UI's `Menu` already ships, with disproportionate regression risk, as throwaway work ahead of the next major. Rejected. +Fixing this means changing `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`, and replacing two core models (backdrop dismissal and per-modal focus traps) that `Dialog` and every `Popover` also use. That rebuilds what Base UI's `Menu` already does, with high regression risk, and it would be thrown away in the next major. Rejected. -### Option B: Keep it as copy-paste docs composition (like the Menubar page) +### Option B: Leave it as copy-paste docs code -Rejected as the end state: unversioned and untested code with no theming contract cannot be the first-class answer to an 8-year-old feature request, and users have already asked for the packaged component (https://github.com/mui/material-ui/issues/48336). +Rejected as the end state. Unversioned, untested code with no theming contract is not an answer to an 8-year-old request, and people have already asked for the real component. -### Option C: Wait for the next major rewrite +### Option C: Wait for the next major -Rejected: demand has waited since 2018, and shipping a public unstable component now battle-tests the API so the next major's `Menu` lands already validated instead of freshly designed. +Rejected. The request has waited since 2018, and shipping now lets us validate the API before it becomes `Menu`. -### Option D: Successor component built on Base UI, shipped as public unstable (proposed) +### Option D: A successor built on Base UI, shipped as public unstable (proposed) -Base UI's `Menu` (`@base-ui/react`, stable 1.x since early 2026, maintained by the same organization) covers requirements 1-3 out of the box, verified against its source and test suites: safe-polygon hover intent on submenu triggers (`openOnHover` default `true`, `delay` 100ms, `safePolygon` close handler), RTL-aware submenu keyboard navigation (parametrized ltr/rtl open/close key tests), Escape closing the innermost submenu by default (`closeParentOnEsc`, default `false`), focus returned to the parent trigger item on close (asserted in tests), per-level typeahead, and collision avoidance defaulting to flip with automatic anchor tracking. Material UI's job reduces to styling, theming, and API surface -- detailed below. +Base UI's `Menu` (`@base-ui/react`, stable since early 2026, maintained by the same team) covers requirements 1-3 out of the box. Verified against its source and tests: hover intent on submenu triggers, RTL-aware submenu keys, Escape closing the innermost submenu, focus returning to the parent item, per-level typeahead, and collision handling that flips and tracks the anchor. Our job is styling, theming, and API surface. ## Proposed solution -Introduce a Base UI-based successor to `Menu`, positioned as "Menu v2" and following the Grid lifecycle precedent. A proof of concept validates feasibility: https://github.com/mui/material-ui/pull/48663 (live demo: https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/), and a companion playground exercises the open questions: https://github.com/mui/material-ui/pull/48823. +Build a Base UI-based successor to `Menu` and follow the Grid lifecycle. A proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) shows it works, and a companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) is where the open questions get tried out. ### Positioning and lifecycle (decided) -The new component is a successor, not an in-place reimplementation of the legacy internals and not a permanently parallel namespace: +The new component is a successor. It is not a rewrite of the current internals, and not a second namespace that stays forever. -| Phase | Component name | What happens | -| --------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | -| Now (v9 minors) | `Unstable_Menu2` | Public incubation -- a real release, not docs-only. Theme keys and classes are `MuiMenu2*` (see the naming note below). | -| Later in v9 | `Menu2` | Stabilized under the interim name; users adopt. Legacy `Menu` untouched; theme keys unchanged. | -| Next major | `Menu` | `Menu2` promoted to the canonical name. | -| Next major | `MenuLegacy` | Old `Menu` renamed, deprecated; codemod provided. | +| Phase | Component name | What happens | +| --------------- | ---------------- | ------------------------------------------------------------------------------ | +| Now (v9 minors) | `Unstable_Menu2` | Public incubation, a real release. Theme keys and classes are `MuiMenu2*`. | +| Later in v9 | `Menu2` | Stable under the interim name. Today's `Menu` untouched, theme keys unchanged. | +| Next major | `Menu` | `Menu2` becomes the canonical name. | +| Next major | `MenuLegacy` | Today's `Menu` renamed and deprecated, with a codemod. | -Precedent: Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, with the old component renamed `GridLegacy`, see https://github.com/mui/material-ui/pull/45363). The rename at each step is a breaking change for early adopters, but it is codemoddable and Material UI has accepted this trade before. The `2`-suffixed interim name is what makes a stable pre-major phase possible at all: an unsuffixed name would collide with the still-shipping legacy `Menu` until the major. +This follows Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, old one renamed `GridLegacy`, [#45363](https://github.com/mui/material-ui/pull/45363)). Each rename breaks early adopters, but it is codemoddable and we have accepted that trade before. The `2` suffix is what makes a stable phase before the major possible: an unsuffixed name would collide with the `Menu` we still ship. -Naming note: only directories, subpaths, and export names carry the `Unstable_` prefix. Internal identifiers use clean `Menu2*` names and theme keys/classes use `MuiMenu2*` -- both enforced by the repo's naming-convention and name-matches-component lint rules, and both matching the Grid2 precedent (`Unstable_Grid2` used `MuiGrid2` keys). A side benefit: theme keys and classes written against the unstable component survive the `Unstable_Menu2` -> `Menu2` stabilization unchanged; only the final `Menu2` -> `Menu` promotion renames them (codemod). +Only directories, subpaths, and exports carry the `Unstable_` prefix. Internal names are `Menu2*` and theme keys are `MuiMenu2*`, which our lint rules require and which matches Grid2. This also means theme keys survive the `Unstable_Menu2` -> `Menu2` step unchanged; only the final promotion to `Menu` renames them. -Import ergonomics follow the existing convention -- flat-named components, one component per subpath, no Base UI-style short aliases (`Root`/`Item`/`Trigger`): +Imports follow our usual convention: flat names, one component per subpath, no short aliases like `Root` or `Item`. ```jsx import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; ``` -Since the subpaths use default exports, adopters can locally drop the `Unstable_` prefix, as above -- the JSX then already reads like the future stable API. Root barrel exports are added at graduation (the experiment is deliberately subpath-only). +Because the subpaths use default exports, adopters can drop the `Unstable_` prefix locally, so their JSX already reads like the future API. Barrel exports come at graduation. -Graduation is gated by a fixed checklist, not judgment calls: the legacy `describeConformance` suite passing minus a documented skip list, theme-registration parity, the pinned `data-*` conformance boundary (see dependency riders below), and design/API sign-off. +Graduation is gated by a checklist, not a judgment call: the conformance suite passing minus documented skips, theme registration parity, the pinned `data-*` boundary, and design sign-off. -### Standards for Base UI-backed components (Menu is the pilot) +### Rules for Base UI-backed components (Menu is the first) -Meta-principle: a Base UI-backed component must be indistinguishable from a normal Material UI component in every cross-cutting concern. Decided: +The rule: a Base UI-backed component should be indistinguishable from any other Material UI component. Only the behavior underneath is new. -- Customization contract: `slots`/`slotProps` is the user-facing mechanism, as in every existing component. Base UI's `render` prop is used internally by Material UI to inject its styled elements (for example the trigger rendering a Material `Button`), and remains a last-resort escape hatch -- not the documented contract. -- Slot plumbing: reuse the `@mui/utils` slot utilities (`useSlotProps`/`mergeSlotProps`/`appendOwnerState`/`resolveComponentProps`); extend them in place if genuine Base UI `render`-bridging gaps appear. No bespoke per-component bridge layers. Migrated in the experiment: the Material UI surfaces (paper, list) build their props with `useSlotProps`, and the bespoke slot-props resolver was replaced by `resolveComponentProps` across every part. Three Base UI-specific helpers remain, and they are the concrete candidates for graduating into the shared utilities: (a) host-slot prop omission -- when a user swaps a Base UI part for a host element, the part's own props (`anchor`, `side`, `keepMounted`, ...) must not reach the DOM, and `appendOwnerState` only handles `ownerState`; (b) state-callback className bridging -- Base UI accepts `className={(state) => string}`, which has to compose with Material UI utility classes; (c) `nativeButton` inference from the resolved root slot. -- Styling reuse with the legacy component: share the actual styled element by passing it through Base UI's `render` internally (the model used by the Menubar docs components), falling back to a shared style function where element injection is impractical. The experiment already implements the fallback: classic `Menu`/`MenuItem` and the new parts consume the same extracted style modules, so there is a single source of visual truth. The remaining work is upgrading to element-level sharing where it fits; free-floating copies are not acceptable. Caution learned from two shipped regressions: extracted classic styles can embed DOM-context assumptions that silently change meaning under the Base UI structure -- a `maxHeight: calc(100% - 96px)` whose `100%` meant the viewport inside the classic full-screen Modal but resolved against the content-sized popup, and an `[item] + divider` adjacency margin that broke when Base UI mounted inline focus-guard nodes next to an open submenu trigger. Shared style functions need a per-consumer audit for positional values and structural selectors, and parts should own their own spacing instead of relying on sibling combinators. -- Material presentational props are preserved (`dense`, `disableGutters`, `divider`, `selected`): Material UI owns presentation, Base UI owns behavior. The line is styling-vs-functionality, not old-vs-new; individual long-tail props can still be dropped case-by-case. -- Dependency shape: `@base-ui/react` becomes a direct dependency of `@mui/material` (caret range), like `@popperjs/core` -- an implementation detail users never install or import directly. Two riders: (a) Base UI version bumps are deliberate, reviewed events, never auto-merged; (b) conformance tests pin the `data-*` attribute surface we consume, so an upstream rename or removal fails CI instead of silently regressing styles. -- Docs and API tooling: existing infrastructure is a fixed constraint; the component conforms to it. If a component cannot be documented without tooling changes, that is a signal about the component. -- Theme registration: standard `defaultProps`/`styleOverrides`/`variants` registration per part, under standard keys (`MuiMenu2*`, see the naming note above) -- no special rule for Base UI parts. +Decided: -Proposed, awaiting team reaction: +- **Customization:** `slots`/`slotProps`, same as every other component. We use Base UI's `render` prop internally to inject our styled elements; it is not the documented contract. +- **Slot plumbing:** reuse the `@mui/utils` helpers (`useSlotProps`, `mergeSlotProps`, `appendOwnerState`, `resolveComponentProps`) instead of writing our own. Done in the experiment. Three Base UI-specific helpers are left, and they should move into the shared utilities: hiding a Base UI part's own props when a slot is swapped for a plain element, bridging Base UI's `className={(state) => string}` callbacks to our utility classes, and inferring `nativeButton` from the root slot. +- **Style reuse:** share the styled element itself through `render` where possible, and fall back to a shared style function. Today classic `Menu`/`MenuItem` and the new parts read the same style modules, so there is one source of truth. Two regressions taught us to audit shared styles per consumer: a `maxHeight: calc(100% - 96px)` that meant "the viewport" inside the old Modal but not inside the new popup, and an `[item] + divider` margin that broke when Base UI added focus-guard elements next to an open submenu trigger. Parts should own their spacing rather than depend on sibling selectors. +- **Presentational props stay:** `dense`, `disableGutters`, `divider`, `selected`. We own presentation, Base UI owns behavior. The line is styling versus behavior, not old versus new, so individual rarely-used props can still go case by case. +- **Dependency:** `@base-ui/react` becomes a direct dependency of `@mui/material`, like `@popperjs/core`. Users never install or import it. Two conditions: version bumps are reviewed, never auto-merged; and conformance tests pin the `data-*` attributes we rely on, so an upstream rename fails CI instead of quietly breaking styles. +- **Docs tooling:** the component adapts to our tooling, not the other way around. +- **Theme registration:** normal `defaultProps`/`styleOverrides`/`variants` per part, under `MuiMenu2*` keys. -- Styling state source of truth (hybrid): `Mui-*` classes + `ownerState` remain the public contract that `styleOverrides`/`variants`/`sx` are written against; internal styles may read Base UI `data-*` attributes for positional or transient state (precedent: Tooltip's `[data-popper-placement]` selectors). Rule of thumb: state users theme or that appears in the documented API gets a class; purely positional/transient internal state stays `data-*`-only. The experiment already exercises the internal side of this rule: the popup surface consumes the positioner-provided `--available-height` variable for its collision-aware max-height. -- Prop surface typing: `extends` the Base UI prop types with `Omit` for curated or renamed props -- inheritance by default (no drift as Base UI evolves), curation as an explicit, documented list. Consequence: callback signatures follow Base UI by default, for example `onOpenChange(open, eventDetails)` rather than the legacy event-first `onClose(event, reason)`. Validated in the experiment (renderless roots, flattened popup): the pattern type-checks, the spec's negative assertions hold, and the flattened container inherits its hoisted positioner/portal surface via `Pick`. One confirmed limitation: the proptypes generator does not expand members declared in `node_modules`, so runtime PropTypes on inherited props degrade to the locally declared ones (types still carry the full contract; `remove-proptypes` strips them in production anyway). Teaching the generator to expand external heritage is a shared-infra follow-up; until then the trade is inherited types + reduced dev-mode runtime validation. -- Testing: reuse the existing harnesses on two fronts -- `describeConformance` for the Material UI contract (ref, className, `sx`, theme `styleOverrides`), and the legacy Menu behavior suite (keyboard navigation, open/close, focus) rerun against the successor. Every skip is annotated with why it is incompatible under the Base UI model. Parity is proven by the same tests passing, not by new bespoke tests. Rolled out in the experiment: all 14 rendering parts now run `describeConformance` (the two renderless roots have no DOM surface), which let the bespoke suite drop its hand-written theming, classes, slots and `component`-prop coverage. Two adaptations are needed and both are candidates for the shared harness: portalled roots have no parent whose `firstChild` is the part root (a `getRootElement` option would fix it), and suites whose part only mounts with real layout -- the nested submenu popup -- run in the browser project only. +Still up for discussion: -### API shape (pending a dedicated design phase) +- **Where styling state lives:** `Mui-*` classes and `ownerState` stay the public contract for `styleOverrides`, `variants`, and `sx`. Internally we can read Base UI `data-*` attributes for positional state, like Tooltip already does with `[data-popper-placement]`. Rule of thumb: anything users theme gets a class; internal positional state stays a data attribute. +- **Prop types:** extend Base UI's types and `Omit` what we hide or rename, so we inherit new props automatically. This means callbacks keep Base UI signatures, for example `onOpenChange(open, eventDetails)` instead of `onClose(event, reason)`. It works in the experiment, with one limitation: our proptypes generator cannot read types from `node_modules`, so runtime PropTypes only cover locally declared props. Types are unaffected, and PropTypes are stripped in production. Teaching the generator to follow external types is a separate infra task. +- **Testing:** reuse `describeConformance` for the Material UI contract, and rerun the existing Menu behavior tests against the successor, annotating every skip. Parity should be shown by those suites passing, not by new tests written for the successor. All 14 rendering parts now run conformance, which let us delete the hand-written theming and slots tests. Two adaptations may belong in the shared harness: portalled roots need a way to point the harness at the real root element, and the nested submenu popup only mounts with real layout, so its suite runs in the browser project only. -The agreed rules for the shape, replacing a global flat-vs-compound choice with a per-part split: +### API shape (needs a design phase) -- Keep the public API as close to the legacy `Menu` as the substrate allows; users should not need to know Base UI is involved. -- Structural/plumbing parts (Portal, Positioner, Popup, Paper, List) are bundled into a flat container component and exposed via `slots`/`slotProps` -- they exist for wiring, not day-to-day composition. -- Customization-heavy parts stay standalone components (`MenuItem`-like parts, submenu triggers, checkbox/radio items) -- they need per-instance children and props and cannot be buried in a container. +Agreed rules, instead of one global flat-vs-compound choice: -Hard precondition before the design is finalized: a behavior benchmark diffing the Material UI Menu against the Base UI Menu from the user's perspective. This has now run -- it is executable, lives next to the component as `Menu2Benchmark.test.tsx`, and every row below is an assertion in real Chromium rather than a reading of the source. The benchmark gates the design -- how close to drop-in the successor API can be -- not the positioning, which is decided above. +- Stay as close to today's `Menu` as the foundation allows. +- Wiring parts (Portal, Positioner, Popup, Paper, List) collapse into one flat container, configured through `slots`/`slotProps`. +- Parts users customize per instance stay separate components: items, submenu triggers, checkbox and radio items. + +Before finalizing the shape we needed to know how far the behavior differs. That benchmark now exists as a test next to the component (`Menu2Benchmark.test.tsx`), so every row below is measured in a real browser, not read off the source. #### Benchmark results -| Dimension | Classic `Menu` | Successor | Verdict | -| ----------------------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------- | -| Opening from the trigger | no trigger part; the consumer wires `onClick` by hand | `Trigger` opens on click and on ArrowDown | successor adds behavior | -| Initial focus, opened by keyboard | n/a (no trigger part) | first item highlighted | matches the menu button pattern | -| Initial focus, opened by pointer | selected item highlighted, or the first item when none is selected | nothing highlighted; focus rests on the popup | **divergence** | -| Disabled items during keyboard navigation | skipped entirely | focusable, per the WAI-ARIA menu pattern | **divergence** | -| Escape | closes, focus returns to the trigger | same | parity | -| Tab while open | closes (`onClose` reason `tabKeyDown`), and `preventDefault` sends focus back to the trigger | closes, and focus advances to the next element in the tab sequence | parity on closing; differs on where focus lands | -| Body scrolling while open | locked | locked | parity | -| Backdrop element | rendered | none; dismissal uses an outside-press listener | **divergence** | -| Sibling content while open | marked `aria-hidden` | left in the accessibility tree | **divergence** | -| Default placement | flush under the trigger, left aligned (`anchorOrigin` bottom/left) | flush under the trigger, left aligned (`side="bottom" align="start"`) | parity | +| Dimension | Classic `Menu` | Successor | Verdict | +| -------------------------- | -------------------------------------------------- | ------------------------------------------- | --------------------------- | +| Opening from the trigger | no trigger part; you wire `onClick` yourself | `Trigger` opens on click and ArrowDown | successor adds behavior | +| Initial focus, keyboard | n/a (no trigger part) | first item highlighted | matches the menu pattern | +| Initial focus, pointer | selected item, or first item when none is selected | nothing highlighted, focus on the popup | **difference** | +| Disabled items, keyboard | skipped | focusable, per the WAI-ARIA menu pattern | **difference** | +| Escape | closes, focus returns to the trigger | same | same | +| Tab while open | closes, and focus goes back to the trigger | closes, and focus moves to the next element | same close, different focus | +| Body scrolling while open | locked | locked | same | +| Backdrop element | rendered | opt-in (see open question 4) | **difference** | +| Sibling content while open | `aria-hidden` | left in the accessibility tree | **difference** | +| Default placement | under the trigger, left aligned | under the trigger, left aligned | same | + +What this means: the successor is closer to a drop-in than expected. Placement, scroll locking, Escape, and Tab-closes-the-menu already match. Keyboard opening cannot be compared directly because the classic Menu has no trigger part, but both end up highlighting an item, so they agree in practice. Five differences remain: -Reading: the successor is closer to drop-in than expected. Placement, scroll locking, Escape handling and Tab-closes-the-menu are already identical. Keyboard opening cannot be compared side by side -- the classic Menu has no trigger part, so the benchmark records it as not applicable -- but classic highlights an item however it is opened, and the successor highlights the first item, so the two converge there. Five differences remain, and they split cleanly: +- **Keep, they are accessibility fixes.** Disabled items stay focusable and sibling content stays in the accessibility tree. Both follow the WAI-ARIA menu pattern; the classic behavior is the odd one out. The backdrop belongs here too: dismissal no longer needs one, and it is available as a slot. +- **Decided: keep Base UI's behavior** for initial focus when opening with the pointer. Nothing is highlighted, so Enter cannot trigger an item the user never picked, which is how native desktop menus work. The menu pattern only prescribes focus for keyboard opening, where both already agree, so this was ours to choose. Matching the classic behavior would mean focusing an item ourselves after opening, since Base UI has no `initialFocus` prop on Menu, which fights the library and brings back the accidental-activation risk. This is a documented change for people migrating. +- **Document it.** Where focus goes after Tab. Classic sends it back to the trigger; the successor lets it move on, which is what pressing Tab asks for. -- **Accessibility-driven, ratify them.** Disabled items stay focusable and sibling content stays in the accessibility tree; both follow the WAI-ARIA menu pattern, and the classic behavior is the outlier. The missing backdrop belongs here too -- dismissal now uses an outside-press listener, and a backdrop can be reintroduced as a slot if a design calls for it. -- **Decided: keep the Base UI behavior.** Initial focus on pointer-opened menus stays as it is -- nothing highlighted, focus resting on the popup -- so Enter cannot activate an item the user never chose, matching native desktop menus. The menu button pattern only prescribes focus for _keyboard_ opening, where the two already agree, so this was ours to choose. Aligning to classic was the alternative and was rejected: Base UI exposes no `initialFocus` prop, so it would mean focusing an item ourselves after open -- fighting the substrate and reinstating the accidental-activation risk. This is a documented behavior change for migrating users, not a bug. -- **Cosmetic, document it.** Where focus lands after Tab. Classic swallows the Tab and returns focus to the trigger; the successor lets it through. The successor's behavior is what a user pressing Tab is asking for, so this is a documentation note rather than a fix. +`variant="selectedMenu"` is dropped, and this one is a lost feature rather than a changed behavior. The prop opens the menu with the current value highlighted. In the code it decides which item is focused when the menu opens, and hides the focus ring for that first moment. Base UI cannot do either: `Menu.Root` has no initial-highlight prop and `Menu.Popup` has no `initialFocus`. Radio items do not fill the gap either. A `RadioGroup` with its second item checked still opens with the first item highlighted, which the benchmark asserts. -`variant="selectedMenu"` is dropped, and unlike the other differences this one is a capability loss rather than a behavior change. What the prop buys is opening with the current value highlighted; measured against the code, it decides which item becomes the roving-tabindex active item (the selected one, falling back to the first) and suppresses the focus-visible ring for one initial handoff. Neither is reachable through Base UI: `Menu.Root` exposes no initial-highlight prop and `Menu.Popup` no `initialFocus`, and the accessible expression of "current value" does not fill the gap either -- a `RadioGroup` whose second item is checked still opens with the _first_ item highlighted (asserted in the benchmark). It also accounts for the selected-versus-first part of the initial-focus difference, but not the rest: classic auto-focuses an item on pointer open under either variant, so dropping `selectedMenu` narrows the divergence decided above without closing it. +Dropping it also narrows the pointer-focus difference above without closing it: the classic Menu highlights an item on pointer open under either variant. -The omission is deliberate upstream, not a gap waiting to be filled: `initialFocus` exists on Base UI's Combobox, Dialog, Drawer and Popover popups but not on Menu, and a maintainer states the reason directly in https://github.com/mui/base-ui/issues/2143 -- "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items". In the same thread a user reports the pointer-versus-keyboard focus difference we decided to keep, and the guidance is to reach for a different pattern: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." There is no open request to change this, so an upstream feature request is not the migration path -- the pattern boundary is. +This is deliberate upstream, not an oversight. `initialFocus` exists on Base UI's Combobox, Dialog, Drawer, and Popover, but not Menu, and a maintainer explains why in [base-ui#2143](https://github.com/mui/base-ui/issues/2143): "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items." The same thread recommends a different component for this case: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." No open request exists to change it, so the answer is the pattern boundary, not an upstream feature request. -This has a scope consequence the rest of this RFC does not cover. Material UI's `Select` renders a classic `` without passing `variant`, so it inherits `selectedMenu` and depends on the selected option being focused on open (it also overrides the list `role` to `listbox`). The successor cannot serve that, and by the reasoning above it should not: a `Select` built on Base UI's `Select` is the counterpart, and it needs its own design work. Promoting the successor to `Menu` in the next major therefore does not automatically migrate `Select` -- either `Select` moves to Base UI `Select` in the same major, or the classic `Menu`/`MenuList` internals stay alive to back it. +That has a consequence this RFC does not otherwise cover. Material UI's `Select` renders a classic `` without passing `variant`, so it relies on the selected option being focused on open, and it overrides the list role to `listbox`. The successor cannot serve that. So promoting the successor to `Menu` does not automatically migrate `Select`: either `Select` moves onto Base UI's `Select` in the same major, or the classic `Menu`/`MenuList` internals stay to back it. -Consequence for the API shape: the interaction model is close enough that a flat container really can carry the classic surface, so the migration story is "same component, a handful of documented behavior changes" rather than a rewrite. With pointer-open focus settled, none of the remaining differences change the API -- they are defaults to ratify and notes to document. +For the API shape this means a flat container really can carry today's surface, and the migration story is "same component, a few documented behavior changes" rather than a rewrite. -Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): +A sketch, not a proposal. Container boundaries and the submenu shape are exactly what the design phase decides; the experiment's fully compound API is the other end of the range. ```jsx ``` -Behavior notes worth stating explicitly regardless of final shape: - -- Submenus open on hover by default in Base UI (`openOnHover` default `true`, `delay` 100ms, safe-polygon close). This is new behavior the classic Menu never had; it matches native OS menus, and it is configurable. -- Escape closes the innermost submenu and returns focus to its trigger item (APG behavior); closing the whole tree is opt-in. -- Opening by pointer highlights no item; focus rests on the popup until the user navigates. Opening from the keyboard highlights the first item, as the menu button pattern prescribes. The classic Menu highlights an item in both cases, so this is a deliberate, documented change (decided). -- While a submenu is open, inline focus-guard nodes sit next to its trigger inside the parent popup; see open question 7 for the styling-contract implications. -- The menu surface constrains itself to `min(calc(100vh - 96px), var(--available-height))` and scrolls internally -- the classic viewport-only clamp replaced by a collision-aware one. +Behavior worth stating whatever shape we pick: -### Compatibility posture +- Submenus open on hover by default (100ms delay, hover intent on close). New compared to the classic Menu, matches native menus, and is configurable. +- Escape closes the innermost submenu and returns focus to its trigger. Closing the whole tree is opt-in. +- Opening with the pointer highlights nothing; opening with the keyboard highlights the first item. +- While a submenu is open, Base UI puts focus-guard elements next to its trigger. See open question 7. +- The menu surface caps its height against the available space and scrolls internally, instead of the old viewport-only clamp. -Continuity where it matters, honesty where it does not: +### Compatibility -- Kept as-is: item-level presentational props (`dense`, `disableGutters`, `divider`, `selected` -- visual-only, as today), `disabled`, visual design, theming entry points, `keepMounted`, portal `container`. -- Changed deliberately (Base UI model replaces the old one): initial focus (pointer-opened menus highlight nothing, keyboard-opened menus highlight the first item), open/close control (`open`/`defaultOpen` + `onOpenChange(open, eventDetails)` instead of controlled-only `open` + `onClose(event, reason)`), positioning (`anchor`/`side`/`align`/offsets instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS `data-starting-style`/`data-ending-style` + `onOpenChangeComplete` instead of `TransitionComponent`/`Grow`). -- Dropped intentionally: - - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that degrade accessibility; `modal` and `finalFocus` cover the legitimate cases. - - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: listbox-style selection behavior on `role="menu"`; initial focus is handled internally (keyboard opening follows the menu button pattern, pointer opening highlights nothing -- see the benchmark reading). - - `anchorOrigin`/`transformOrigin`/`anchorReference`/`anchorPosition`, `PopoverClasses`, `transitionDuration`, `slots.transition`, `action.updatePosition`: superseded by Base UI's Floating-UI-based positioning with automatic anchor tracking and collision handling. +- **Unchanged:** item props (`dense`, `disableGutters`, `divider`, `selected`, `disabled`), visuals, theming, `keepMounted`, `container`. +- **Changed on purpose:** initial focus (above), open/close control (`open`/`defaultOpen` + `onOpenChange` instead of controlled-only `open` + `onClose`), positioning (`anchor`/`side`/`align` instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS instead of `TransitionComponent`). +- **Dropped:** + - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that hurt accessibility. `modal` and `finalFocus` cover the real cases. + - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: selection state does not belong on menu items, and initial focus is handled internally. + - `anchorOrigin`, `transformOrigin`, `anchorReference`, `anchorPosition`, `PopoverClasses`, `transitionDuration`, `slots.transition`, `action.updatePosition`: replaced by the new positioning. - `disablePortal`: Base UI popups are always portalled. -A full old-to-new prop mapping is in the collapsible appendix at the end. +The full prop mapping is in the appendix. -### New capabilities (vs classic Menu) +### New capabilities -- Submenus with correct keyboard, hover-intent, and ARIA behavior. -- Checkbox and radio items (`role="menuitemcheckbox"` / `role="menuitemradio"` with `aria-checked` and indicators). -- Groups with automatically associated labels (`role="group"` + `aria-labelledby`). -- Trigger wiring for `aria-haspopup`/`aria-expanded`/`aria-controls` out of the box. -- Typeahead with per-item `label` override. -- Hover-open with configurable delays; reason-rich, cancelable `onOpenChange`. +- Submenus, with correct keyboard, hover, and ARIA behavior. +- Checkbox and radio items (`menuitemcheckbox` / `menuitemradio` with `aria-checked` and indicators). +- Groups with labels wired up via `aria-labelledby`. +- A trigger that sets `aria-haspopup`, `aria-expanded`, and `aria-controls` for you. +- Typeahead, with a per-item `label` override. +- Hover-open with delays, and a cancelable `onOpenChange` that says why the menu is closing. -### Reference implementation and known deltas +### Where the experiment stands -The PoC (https://github.com/mui/material-ui/pull/48663) proves feasibility end-to-end: submenus, checkbox/radio items, groups, pixel parity with classic `MenuItem`, full theme registration, and a comprehensive test suite -- at +77 B gzip on the `@mui/material` barrel (Base UI code is only paid when importing the component). The companion experiment (https://github.com/mui/material-ui/pull/48823) iterates on it toward the standards above. +The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) covers submenus, checkbox and radio items, groups, matching visuals, theme registration, and tests, at +77 B gzip on the `@mui/material` barrel. The companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) moves it toward the rules above. -Resolved in the experiment branch: +Done: -- `MenuPreview` naming -> `Unstable_Menu2` lifecycle naming, per-part subpaths, Base UI-style short aliases dropped. -- Docs tooling special-casing -> removed; the experiment is exercised via a non-public playground page instead of generated API docs. -- Style sharing: classic and successor consume the same extracted style modules (single source of visual truth). -- Composed list primitives keep working inside items: `ListItemText inset` aligns with the icon column as it does in the classic menu (verified in the experiment). Note `inset` is a `ListItemText`/`ListSubheader` prop, not a menu item prop, so nothing had to be implemented. -- Prop surfaces on the renderless roots and the flattened popup inherit Base UI types via `Omit`/`Pick` (the item parts already followed the pattern); the roots gain `actionsRef` and future Base UI props for free (hover-open with delays lives on the trigger parts, already exposed). -- Top-level `elevation` convenience prop exists on the popup (default 8, forwards to the Paper slot). +- Renamed to the `Unstable_Menu2` lifecycle naming, one component per subpath. +- Docs tooling special-casing removed. +- Classic and successor share the same style modules. +- Composed list primitives still work inside items (`ListItemText inset` lines up with the icon column). `inset` is a `ListItemText` prop, not a menu item prop, so nothing needed implementing. +- Prop types inherit from Base UI, so the roots get `actionsRef` and future props for free. +- Top-level `elevation` on the popup, default 8. +- Default open/close animation, and an opt-in backdrop slot. -Remaining: +Left: -- Fully compound API -> per-part flat/standalone split per the design phase. -- Style sharing is at the shared-style-function level -> upgrade to sharing the styled element itself via internal `render` where practical. -- Slot plumbing is migrated to `@mui/utils`; what remains is the Base UI-specific residue listed in the standards section above, which should graduate into the shared utilities rather than stay per-component. -- The legacy Menu behavior suite has not been rerun against the successor yet; it is written against the `anchorEl` API, so it needs the flat container from the design phase before it can run. -- No default open/close animation and no ripple -> open questions below. +- The compound API needs the flat/standalone split from the design phase. +- Style sharing is at the style-function level; sharing the styled element itself is better where it fits. +- The Base UI-specific slot helpers should move into `@mui/utils`. +- The existing Menu behavior tests cannot run against the successor yet, because they are written against `anchorEl` and need the flat container first. ### Open questions -1. Ripple. Items render as Base UI divs, so there is no `TouchRipple` by default. Measured: consumers can get ripple today by swapping the item root slot -- `slots={{ root: ButtonBase }}` renders a real `MuiTouchRipple` and produces a ripple on press -- but they lose the Material UI item styling with it, because that CSS lives on the default styled root. Wrapping a `ButtonBase` _inside_ an item is not an option: it puts a focusable element inside a `role="menuitem"`, which auto-highlights the item on open (https://github.com/mui/base-ui/issues/2622) and is the pattern Base UI maintainers explicitly rule out. So the question is whether to ship ripple by default (make the default item root a styled `ButtonBase`, restoring `disableRipple` and friends), or leave it to the root slot and document the recipe. Design-identity call, no accessibility stake. -2. Default open/close animation. **Decided: ship one.** The popup surface carries a CSS transition matching the classic `Grow` -- same scale ramp (`scale(0.75, 0.5625)`), the theme's entering/leaving durations, and the transform running at two thirds of the opacity duration -- so migrating apps do not silently lose the animation the classic Menu had. It opts out under `prefers-reduced-motion` and is overridable through `slotProps.popup`, `styleOverrides` or the theme. It has to live on the popup element: Base UI waits for animations on the popup itself before unmounting (`useAnimationsFinished` calls `element.getAnimations()` with no `subtree`), so a transition on a descendant would animate in and then be clipped on exit. Two consequences: geometry read immediately after opening is mid-animation, and Base UI suppresses the transition for the frame in which it applies the starting style -- both mean measurements have to wait for it to settle. -3. Convenience `elevation` prop on the container. **Decided: keep it.** The popup takes a top-level `elevation` (default 8, matching classic) and forwards it to the Paper slot, so the common case does not have to go through `slotProps.paper`. -4. Backdrop. **Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` exist on the popup, mirroring the classic Menu's backdrop slot, with a default element that is transparent and click-through -- the classic backdrop is invisible too, and dismissal stays with Base UI's outside-press listener rather than the backdrop. Dimming is `slotProps={{ backdrop: {{ sx: {{ bgcolor: ... }} }} }}`. One deliberate difference from classic: it renders only when the consumer opts in through those props, because rendering it unconditionally would hand _non-modal_ menus a full-screen layer they never had, and modal menus already receive Base UI's own inert backdrop. -5. Imperative actions. **Decided: inherit Base UI's `actionsRef` as-is.** It arrives through the prop-typing standard (`close()`, `unmount()`), and we neither rename it to something Material UI-flavoured nor hand-roll a parallel `action` ref like the classic Menu had. Renaming would create permanent wrapper drift for no gain, and a hand-rolled ref would duplicate machinery Base UI already owns. The classic `action` ref's other job, `updatePosition()`, has no counterpart because the positioner tracks the anchor automatically. -6. Context menu (right-click / cursor positioning): does this need its own component? Classic `anchorPosition` use cases work today through a virtual-element `anchor` recipe, but the recipe carries a focus-restore quirk that bit us in the experiment: a detached menu has no trigger to return focus to, so on close Base UI falls back to an internal previously-focused-element record, which can be a stale trigger from an unrelated menu elsewhere on the page. The recipe has to pass `finalFocus` (the invoked surface, per the APG context-menu pattern) to behave correctly, and nothing in the API makes that obvious -- it looks fine until a second menu exists on the page. A component wrapping Base UI's dedicated `ContextMenu` would own that internally, since its trigger is the right-click surface itself. So: ship the recipe with a documented caveat, or absorb the quirk into a component? -7. Sibling-structure styling contract around submenu triggers. **Decided: do both.** While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them -- so consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens. The experiment shipped exactly this bug, and the built-in parts now avoid it by owning their spacing. We document the constraint as part of the styling contract (the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must target them), and separately raise upstream whether guard placement could avoid interleaving trigger siblings, since that would benefit every Base UI consumer. -8. Bundle-size governance. Base UI adds real weight per component family; do we add a size-snapshot gate per Base UI-backed component? -9. Residual accessibility obligation. Base UI owns the interaction a11y; what does Material UI still verify on top (an axe pass in conformance, screen-reader smoke tests, contrast of the styled surfaces)? -10. Behavior defaults divergence. Two are now **decided**: initial focus on pointer-opened menus keeps the Base UI behavior (see the benchmark reading), and submenus keep opening on hover by default (`openOnHover` on the submenu trigger, 100ms delay, safe-polygon close) -- behavior the classic Menu never had, matching native desktop menus, and configurable per trigger. Correction to an earlier draft of this question: there is no open question about modality. Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as parity. What the successor adds is the ability to opt out with `modal={false}`, a new capability rather than a changed default. -11. SSR, `'use client'` boundaries, and ref typing are expected to follow the existing patterns with no divergence -- to confirm during implementation, not expected to be contentious. +1. **Ripple.** Items are plain elements, so there is no ripple by default. Users can get one today by swapping the item root: `slots={{ root: ButtonBase }}` produces a real ripple, but it loses our item styling, because that CSS lives on the default root. Putting a `ButtonBase` inside an item is not an option: it places a focusable element inside a `menuitem`, which highlights the item on open ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)) and is the pattern Base UI rules out. So: ship ripple by default (make the item root a styled `ButtonBase`, bringing back `disableRipple`), or leave it to the root slot and document the recipe? Design call, no accessibility stake. +2. **Default animation. Decided: ship one.** The popup has a CSS transition matching the classic `Grow`, using the same scale and the theme's durations, so migrating apps do not lose their animation. It turns off under `prefers-reduced-motion` and can be overridden through `slotProps.popup`, `styleOverrides`, or the theme. It has to live on the popup element, because Base UI waits for animations on that element before unmounting; a transition on a child would be cut off when closing. One practical effect: anything measuring the menu right after it opens now reads it mid-animation, so tests have to wait for the transition to finish. +3. **`elevation` prop. Decided: keep it.** The popup takes `elevation` (default 8) and passes it to the Paper slot, so the common case does not need `slotProps.paper`. +4. **Backdrop. Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` mirror the classic Menu, with a default element that is transparent and click-through, like the classic invisible backdrop. Dismissal stays with Base UI's outside-press handling. Dimming is `slotProps={{ backdrop: { sx: { bgcolor: ... } } }}`. One difference from classic: it only renders when you opt in, because always rendering it would give non-modal menus a full-screen layer they never had, and modal menus already get Base UI's own inert backdrop. +5. **Imperative actions. Decided: use Base UI's `actionsRef` as-is.** It arrives with the inherited types and gives `close()` and `unmount()`. We do not rename it or build our own `action` ref: renaming means our API drifts from Base UI's for no gain, and rebuilding duplicates what Base UI already does. The classic `action.updatePosition()` has no equivalent because the position updates automatically. +6. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor, but the recipe has a focus bug we hit ourselves: a menu with no trigger has nothing to return focus to, so Base UI falls back to the last element it remembers, which can be a trigger from an unrelated menu on the page. The recipe has to pass `finalFocus` pointing at the element that was right-clicked, and nothing in the API tells you that. It looks fine until a second menu exists. A component wrapping Base UI's `ContextMenu` would handle it internally, since its trigger is the right-click surface. So: document the recipe, or ship the component? +7. **Styling around submenu triggers. Decided: do both.** While a submenu is open, Base UI keeps focus-guard elements next to the trigger, because tab order depends on them. Any CSS using sibling selectors (`+`, `~`, `:last-child`) around a trigger breaks the moment a submenu opens. We hit this bug ourselves, and our parts now own their spacing instead. We will document the rule (the guards are identifiable via `data-base-ui-focus-guard`) and separately ask upstream whether the guards could sit outside the item list, which would help every Base UI user. +8. **Bundle size.** Base UI adds real weight per component. Do we want a size check per Base UI-backed component? +9. **Accessibility ownership.** Base UI owns the interaction behavior. What do we still verify: an axe pass in conformance, screen reader smoke tests, contrast of our surfaces? +10. **Other defaults.** Two are decided: pointer-opened menus highlight nothing, and submenus open on hover. There is no question about modality: Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as the same. The successor only adds the option to turn it off. +11. **SSR, `'use client'`, ref typing.** Expected to follow existing patterns; to confirm during implementation. ### Rollout plan -1. Behavior benchmark (hard precondition): **done** -- the classic Menu and the successor are diffed by an executable benchmark (`Menu2Benchmark.test.tsx`), and the results are in the API shape section above. Structure-sensitive cases found along the way are pinned by their own regression tests: parent-menu layout stability while a submenu is open, and the popup surface using a collision-aware max-height instead of the classic viewport-only clamp. -2. Design phase for the API shape under the rules above (container boundaries, submenu shape), validated in the companion experiment -- each open question resolved against a deploy preview rather than in the abstract. -3. Land `Unstable_Menu2` in a v9 minor: conformance + legacy behavior suites with annotated skips, API reference docs, and a docs section on the Menu page (submenu, checkbox/radio, context-menu demos). -4. Iterate on feedback; stabilize as `Menu2` once the graduation checklist passes (conformance minus documented skips, theme-registration parity, pinned `data-*` boundary, design sign-off). -5. Next major: promote `Menu2` to `Menu`, rename legacy to `MenuLegacy` (deprecated), ship the migration guide and codemods for the renames and the mechanical parts of the mapping below (Grid precedent). +1. Behavior benchmark: **done**, results above. +2. Design phase for the API shape, tried out in the companion experiment so each question is answered against a real preview. +3. Ship `Unstable_Menu2` in a v9 minor, with conformance tests, API docs, and demos on the Menu page. +4. Iterate on feedback, then stabilize as `Menu2` once the graduation checklist passes. +5. Next major: promote `Menu2` to `Menu`, rename the old one to `MenuLegacy`, and ship the migration guide and codemods. -### Appendix: full prop mapping (classic Menu -> successor) +### Appendix: full prop mapping
-1. Open / close and control +1. Open and close -| Classic Menu | New equivalent | Notes | -| ---------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `open` (required, controlled-only) | `open` + `defaultOpen` | uncontrolled becomes possible | -| `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | signature inherits Base UI types per the typing standard; reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, `item-press`; supports `cancel()` and exposes the native event | -| n/a | `onOpenChangeComplete(open)` | replaces `onTransitionExited`-style hooks | +| Classic Menu | New equivalent | Notes | +| ---------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `open` (required, controlled-only) | `open` + `defaultOpen` | uncontrolled is now possible | +| `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, `item-press`; can be canceled, and exposes the native event | +| n/a | `onOpenChangeComplete(open)` | replaces `onTransitionExited` |
@@ -272,27 +274,27 @@ Remaining: | `anchorEl` | `anchor` | also accepts refs and virtual elements | | `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | finer control | | `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | see open question 6 | -| `marginThreshold` (default 16) | `collisionPadding` (default 5) | equivalent concept | -| `anchorReference="none"` | omit `anchor`, position via CSS | equivalent | -| `action.updatePosition()` | automatic anchor tracking | `disableAnchorTracking` to opt out | -| -- | `collisionBoundary`, `sticky`, `collisionAvoidance`, `positionMethod`, `arrowPadding` | new capabilities | +| `marginThreshold` (default 16) | `collisionPadding` (default 5) | same idea | +| `anchorReference="none"` | omit `anchor`, position via CSS | same | +| `action.updatePosition()` | automatic | `disableAnchorTracking` to opt out | +| -- | `collisionBoundary`, `sticky`, `collisionAvoidance`, `positionMethod`, `arrowPadding` | new |
3. Focus and modality -| Classic Menu | New equivalent | Notes | -| ----------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `autoFocus`, `disableAutoFocusItem` | internal | keyboard opening highlights the first item (menu button pattern); pointer opening highlights nothing (decided) | -| `variant` (`menu`/`selectedMenu`) | dropped | selection state is invalid on menu items; opening with the current value highlighted is not reproducible (see the benchmark reading). Use checkbox/radio items so the current value is at least indicated | -| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` prop covers modality | -| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | -| `disableEscapeKeyDown` | dropped | contradicts the menu pattern; use `onKeyDown` if truly needed | -| `disableScrollLock` | `modal` | non-modal menus do not lock scroll | -| `hideBackdrop` | partially via `modal` | see open question 4 | -| `disablePortal` | dropped | always portalled | -| `keepMounted`, `container` | same | identical semantics | +| Classic Menu | New equivalent | Notes | +| ----------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- | +| `autoFocus`, `disableAutoFocusItem` | internal | keyboard opening highlights the first item; pointer opening highlights nothing | +| `variant` (`menu`/`selectedMenu`) | dropped | not reproducible on Base UI (see above). Use checkbox or radio items so the current value is at least indicated | +| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` covers this | +| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | +| `disableEscapeKeyDown` | dropped | goes against the menu pattern; use `onKeyDown` if you must | +| `disableScrollLock` | `modal` | non-modal menus do not lock scroll | +| `hideBackdrop` | backdrop slot | the backdrop is opt-in (open question 4) | +| `disablePortal` | dropped | always portalled | +| `keepMounted`, `container` | same | same behavior |
@@ -304,39 +306,39 @@ Remaining: | `TransitionComponent` / `slots.transition` (default `Grow`) | CSS via `data-starting-style` / `data-ending-style` | | `transitionDuration` | CSS `transition-duration` on the popup | | `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | -| default `Grow` animation | open question 2 (proposal: default CSS transition) | +| default `Grow` animation | shipped as a CSS default (open question 2) |
-5. Styling / slots +5. Styling and slots -| Classic Menu | New equivalent | Notes | -| ------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------- | -| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list` | no transition slot (CSS-based) | -| `elevation` (default 8) | `elevation` (default 8, forwards to the Paper slot) | implemented; see open question 3 | -| paper `maxHeight: calc(100% - 96px)` (viewport clamp via the Modal) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | collision-aware | -| `slots.backdrop` + `BackdropProps` | not surfaced | see open question 4 | -| `PopoverClasses` | n/a | no Popover underneath | +| Classic Menu | New equivalent | Notes | +| ------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------ | +| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list`, `backdrop` | no transition slot (CSS-based) | +| `elevation` (default 8) | `elevation` (default 8, forwarded to the Paper slot) | kept | +| paper `maxHeight: calc(100% - 96px)` (viewport clamp via the Modal) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | collision-aware | +| `slots.backdrop` + `BackdropProps` | `slots.backdrop` + `slotProps.backdrop` | opt-in (open question 4) | +| `PopoverClasses` | n/a | no Popover underneath |
-6. Item-level props - -| Classic MenuItem / MenuList | New equivalent | Notes | -| ------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dense`, `disableGutters`, `divider` | same | preserved (decided): Material UI owns presentation | -| `` between items | `Separator` part | owns its 8px margins (stable while submenus are open); classic spacing relied on item-adjacency selectors | -| `selected` | same (visual-only, as today) | preserved (decided); checkbox/radio items cover real selection semantics. Note: classic `MenuItem` now derives `aria-checked` from `selected` for `menuitemcheckbox`/`menuitemradio` roles (https://github.com/mui/material-ui/pull/48651) -- the successor's dedicated checkbox/radio items own this instead | -| `disabled` | same | `aria-disabled`, item stays focusable | -| `href` / `LinkComponent` | link item variant | real `` | -| `autoFocus` (item) | dropped | initial focus is internal | -| ripple props | none currently | see open question 1 | -| `focusVisibleClassName`, `onFocusVisible`, `action.focusVisible()` | `highlighted` state class / data attributes | style via CSS | -| `MenuList.disableListWrap` | `loopFocus` (default true) | inverse | -| `MenuList.autoFocus`/`autoFocusItem`/`variant` | dropped | internal / legacy | -| `MenuList.disablePadding`, `subheader` | `slotProps.list`, group + group label parts | groups get proper ARIA association | +6. Item props + +| Classic MenuItem / MenuList | New equivalent | Notes | +| ------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dense`, `disableGutters`, `divider` | same | kept: we own presentation | +| `` between items | `Separator` part | owns its margins, so spacing stays put while a submenu is open | +| `selected` | same (visual only) | kept. Checkbox and radio items cover real selection. Classic `MenuItem` now derives `aria-checked` from `selected` for checkbox and radio roles ([#48651](https://github.com/mui/material-ui/pull/48651)); our dedicated items own that instead | +| `disabled` | same | `aria-disabled`, item stays focusable | +| `href` / `LinkComponent` | link item | real `` | +| `autoFocus` (item) | dropped | initial focus is internal | +| ripple props | none yet | see open question 1 | +| `focusVisibleClassName`, `onFocusVisible`, `action.focusVisible()` | `highlighted` class and data attributes | style via CSS | +| `MenuList.disableListWrap` | `loopFocus` (default true) | inverse | +| `MenuList.autoFocus`/`autoFocusItem`/`variant` | dropped | internal or legacy | +| `MenuList.disablePadding`, `subheader` | `slotProps.list`, group parts | groups get proper ARIA |
@@ -344,34 +346,32 @@ Remaining: Proof of concept and experiment: -- PoC PR: https://github.com/mui/material-ui/pull/48663 (live demo: https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/) -- RFC companion playground (use case demos + knobs for the open questions): https://github.com/mui/material-ui/pull/48823 -- Measured bundle impact of the PoC on `@mui/material`: +160 B parsed / +77 B gzip (~0.03%) -- the style-extraction refactor only; Base UI code is paid only when importing the new component. +- PoC: [#48663](https://github.com/mui/material-ui/pull/48663) ([demo](https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/)) +- Companion playground: [#48823](https://github.com/mui/material-ui/pull/48823) +- Bundle impact on `@mui/material`: +160 B parsed, +77 B gzip (~0.03%), from the style extraction only. Base UI code is only paid when you import the component. Demand: -- https://github.com/mui/material-ui/issues/11723 (canonical request, open since 2018, 120+ reactions) -- https://github.com/mui/material-ui/issues/8152 (closed as duplicate) -- https://github.com/mui/material-ui/issues/48336 (packaged Menubar/submenu component request) -- https://github.com/mui/material-ui/issues/45790 (nested menu docs demo request) +- [#11723](https://github.com/mui/material-ui/issues/11723) (main request, open since 2018, 120+ reactions) +- [#8152](https://github.com/mui/material-ui/issues/8152) (closed as duplicate) +- [#48336](https://github.com/mui/material-ui/issues/48336) (packaged Menubar/submenu component) +- [#45790](https://github.com/mui/material-ui/issues/45790) (nested menu docs demo) -Prior attempts on the legacy stack: +Earlier attempts: -- https://github.com/mui/material-ui/pull/14700 (2019, closed) -- https://github.com/mui/material-ui/pull/20591 (2020-2022, closed) -- https://github.com/mui/material-ui/pull/37570 (2023-2024, closed) -- v0.x nested menu support: https://github.com/mui/material-ui/pull/2148, https://github.com/mui/material-ui/pull/3265 +- [#14700](https://github.com/mui/material-ui/pull/14700) (2019, closed), [#20591](https://github.com/mui/material-ui/pull/20591) (2020-2022, closed), [#37570](https://github.com/mui/material-ui/pull/37570) (2023-2024, closed) +- v0.x nested menus: [#2148](https://github.com/mui/material-ui/pull/2148), [#3265](https://github.com/mui/material-ui/pull/3265) Direction and precedent: -- Maintainer statement (Dec 2024): https://github.com/mui/material-ui/issues/11723#issuecomment-2556390056 -- "Material UI will adopt (this new) Base UI component in its next major release." -- Grid lifecycle precedent (legacy rename): https://github.com/mui/material-ui/pull/45363 -- Menubar docs page composed from Base UI with submenus: https://mui.com/material-ui/react-menubar/ (added in https://github.com/mui/material-ui/pull/47616) -- Base UI Menu (submenu anatomy, keyboard model): https://base-ui.com/react/components/menu -- Base UI releases (1.x stable): https://base-ui.com/react/overview/releases +- Maintainer statement (Dec 2024): [#11723 comment](https://github.com/mui/material-ui/issues/11723#issuecomment-2556390056) -- "Material UI will adopt (this new) Base UI component in its next major release." +- Grid lifecycle: [#45363](https://github.com/mui/material-ui/pull/45363) +- Menubar docs page built on Base UI: [react-menubar](https://mui.com/material-ui/react-menubar/) (from [#47616](https://github.com/mui/material-ui/pull/47616)) +- [Base UI Menu](https://base-ui.com/react/components/menu) and [releases](https://base-ui.com/react/overview/releases) +- Why Base UI's Menu has no `initialFocus`: [base-ui#2143](https://github.com/mui/base-ui/issues/2143) -Community workarounds discussed in the threads: +Community workarounds: -- https://github.com/azmenak/material-ui-nested-menu-item (and its successor `mui-nested-menu`) -- https://jcoreio.github.io/material-ui-popup-state/ -- https://www.npmjs.com/package/better-mui-menu +- [material-ui-nested-menu-item](https://github.com/azmenak/material-ui-nested-menu-item) and `mui-nested-menu` +- [material-ui-popup-state](https://jcoreio.github.io/material-ui-popup-state/) +- [better-mui-menu](https://www.npmjs.com/package/better-mui-menu) From e14f5626ac68b1c7f1f9f6d05dcd2de892475b0c Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 29 Jul 2026 18:24:42 +0300 Subject: [PATCH 34/62] Drop the Select dependency and ground two open questions The Select coupling is out of scope: the next major may rewrite it anyway, so the RFC should not carry that decision. Open questions 9 and 11 said "what do we still verify" and "to confirm during implementation" without saying what. Both are now specific: - Accessibility: enhanceHighContrast matches classic items through menuItemClasses, so menu2ItemClasses items (state class `highlighted`, no `focusVisible`) match nothing today, while the trigger is covered as a styled Button. The highlight is a background tint with the native outline removed, and axe only runs in the non-interactive visual regression suite, which skips the menus page. - SSR: the directives are present and placed like the classic Menu, but nothing exercises an RSC boundary, since the docs site is Pages Router with output: 'export'. Only the trigger renders on the server, and defaultOpen/keepMounted do not change that. The open part is ref typing, which is inherited wide from Base UI. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index caaab97e277ec4..e8f4ef0337c09c 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -157,8 +157,6 @@ Dropping it also narrows the pointer-focus difference above without closing it: This is deliberate upstream, not an oversight. `initialFocus` exists on Base UI's Combobox, Dialog, Drawer, and Popover, but not Menu, and a maintainer explains why in [base-ui#2143](https://github.com/mui/base-ui/issues/2143): "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items." The same thread recommends a different component for this case: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." No open request exists to change it, so the answer is the pattern boundary, not an upstream feature request. -That has a consequence this RFC does not otherwise cover. Material UI's `Select` renders a classic `` without passing `variant`, so it relies on the selected option being focused on open, and it overrides the list role to `listbox`. The successor cannot serve that. So promoting the successor to `Menu` does not automatically migrate `Select`: either `Select` moves onto Base UI's `Select` in the same major, or the classic `Menu`/`MenuList` internals stay to back it. - For the API shape this means a flat container really can carry today's surface, and the migration story is "same component, a few documented behavior changes" rather than a rewrite. A sketch, not a proposal. Container boundaries and the submenu shape are exactly what the design phase decides; the experiment's fully compound API is the other end of the range. @@ -241,9 +239,9 @@ Left: 6. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor, but the recipe has a focus bug we hit ourselves: a menu with no trigger has nothing to return focus to, so Base UI falls back to the last element it remembers, which can be a trigger from an unrelated menu on the page. The recipe has to pass `finalFocus` pointing at the element that was right-clicked, and nothing in the API tells you that. It looks fine until a second menu exists. A component wrapping Base UI's `ContextMenu` would handle it internally, since its trigger is the right-click surface. So: document the recipe, or ship the component? 7. **Styling around submenu triggers. Decided: do both.** While a submenu is open, Base UI keeps focus-guard elements next to the trigger, because tab order depends on them. Any CSS using sibling selectors (`+`, `~`, `:last-child`) around a trigger breaks the moment a submenu opens. We hit this bug ourselves, and our parts now own their spacing instead. We will document the rule (the guards are identifiable via `data-base-ui-focus-guard`) and separately ask upstream whether the guards could sit outside the item list, which would help every Base UI user. 8. **Bundle size.** Base UI adds real weight per component. Do we want a size check per Base UI-backed component? -9. **Accessibility ownership.** Base UI owns the interaction behavior. What do we still verify: an axe pass in conformance, screen reader smoke tests, contrast of our surfaces? +9. **Accessibility: what is still ours.** Base UI owns the roles, keyboard behavior, focus, and dismissal. Everything visual is ours, and that is where the remaining risk sits. Three concrete gaps. Our forced-colors enhancer matches classic items through `menuItemClasses`, but successor items use `menu2ItemClasses`, whose state class is `highlighted` and which has no `focusVisible`, so nothing matches them today; the trigger is covered, because it is a styled `Button`. The highlight itself is a background tint (`action.focus`, roughly 1.3:1) with the native outline removed, which is parity with the classic item but worth choosing rather than inheriting. And nothing checks an open menu automatically: axe runs only in the visual regression suite, which never interacts with the page and skips the menus page for exactly that reason, and `describeConformance` has no accessibility assertions. 10. **Other defaults.** Two are decided: pointer-opened menus highlight nothing, and submenus open on hover. There is no question about modality: Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as the same. The successor only adds the option to turn it off. -11. **SSR, `'use client'`, ref typing.** Expected to follow existing patterns; to confirm during implementation. +11. **SSR, `'use client'`, ref typing.** All 18 modules carry `'use client'`, placed the way the classic Menu places it, and Base UI ships the directive on its own menu modules. Nothing verifies that it works: the docs site is Pages Router with `output: 'export'`, so no server component boundary is ever evaluated, and an App Router fixture is the only thing that would confirm it. Server rendering is measured rather than open: only the trigger renders on the server, and neither `defaultOpen` nor `keepMounted` changes that, because Base UI creates the portal node in a layout effect. The real question is ref typing. Refs come from Base UI and are wide (`HTMLElement`, `Element`), where the classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. Conformance pins the runtime element; the type stays loose. Narrow per part, or keep parity with Base UI's signatures? ### Rollout plan From 4f021a781a22eaa68ad85c1fa2c9edcfa0508209 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 31 Jul 2026 11:05:06 +0300 Subject: [PATCH 35/62] Cover the Menu2 items in the high contrast enhancer enhanceHighContrast matched menu items through menuItemClasses, so the successor's items matched nothing: they are MuiMenu2Item and friends, and their state class is `highlighted` (Base UI's data-highlighted covers keyboard and pointer alike) where the classic item has `focusVisible`. The trigger was already covered as a styled Button, so forced colors worked on the way in and stopped at the popup. Adds the five item-like parts and both indicators. `forced-color-adjust` inherits, so the item rules also stop the browser forcing colors on the indicator; the indicators now inherit the item's color the way ListItemIcon does, and the checkbox's knocked-out checkmark is repainted in the item's background, which it otherwise merges into. The playground gets an `enhanceHighContrast theme` toggle, since experiment pages do not go through DemoInstanceThemeProvider the way docs demos do. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-playground.tsx | 28 +++- .../src/Unstable_Menu2/Menu2.test.tsx | 54 ++++++- .../src/styles/enhanceHighContrast.test.ts | 146 +++++++++++++++++ .../src/styles/enhanceHighContrast.ts | 149 ++++++++++++++++++ 4 files changed, 374 insertions(+), 3 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index 281574421f2198..aa63340effdd34 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -11,7 +11,13 @@ import Typography from '@mui/material/Typography'; import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; import KeyboardArrowRightRoundedIcon from '@mui/icons-material/KeyboardArrowRightRounded'; import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'; -import { ThemeProvider, createTheme, type SxProps, type Theme } from '@mui/material/styles'; +import { + ThemeProvider, + createTheme, + enhanceHighContrast, + type SxProps, + type Theme, +} from '@mui/material/styles'; import { DirectionProvider } from '@base-ui/react/direction-provider'; // The Unstable_ subpaths use default exports, so the local bindings drop the // prefix and the JSX mirrors the future stable names. @@ -62,6 +68,7 @@ interface PlaygroundSettings { dense: boolean; dividers: boolean; rtl: boolean; + highContrast: boolean; } const defaultSettings: PlaygroundSettings = { @@ -84,6 +91,7 @@ const defaultSettings: PlaygroundSettings = { dense: false, dividers: false, rtl: false, + highContrast: true, }; const SIDES: PopupSide[] = ['bottom', 'top', 'left', 'right', 'inline-start', 'inline-end']; @@ -92,6 +100,11 @@ const ELEVATIONS = [0, 1, 4, 8, 16, 24]; const theme = createTheme({}); const rtlTheme = createTheme({ direction: 'rtl' }); +// Docs demos always run through the enhancer (see `DemoInstanceThemeProvider`); +// the toggle is here so the forced-colors rules can be compared against their +// absence while emulating high contrast in the browser. +const highContrastTheme = enhanceHighContrast(createTheme({})); +const rtlHighContrastTheme = enhanceHighContrast(createTheme({ direction: 'rtl' })); // The successor animates by default (a CSS match for the classic Grow); this // demonstrates overriding that default away through the popup slot. @@ -523,6 +536,7 @@ function SettingsPanel({ {renderCheckbox('dense', 'dense items')} {renderCheckbox('dividers', 'item dividers')} {renderCheckbox('rtl', 'RTL direction')} + {renderCheckbox('highContrast', 'enhanceHighContrast theme')}
); @@ -536,7 +550,12 @@ export default function MenuRfcExperiment() { setLog((currentLog) => [...currentLog.slice(-11), entry]); }, []); - const playgroundTheme = settings.rtl ? rtlTheme : theme; + const playgroundTheme = (() => { + if (settings.rtl) { + return settings.highContrast ? rtlHighContrastTheme : rtlTheme; + } + return settings.highContrast ? highContrastTheme : theme; + })(); return ( @@ -566,6 +585,11 @@ export default function MenuRfcExperiment() { Nested submenus (three levels), groups with labels, checkbox and radio items, a disabled item, a visual-only selected item, and a link item. All knobs apply.

+

+ To check the forced-colors rules, emulate high contrast in the browser (in Chrome + DevTools: Rendering > Emulate CSS media feature forced-colors) and toggle the + enhanceHighContrast theme knob. +

diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index 693c829d08bb77..2acfe2af218c5e 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -23,8 +23,11 @@ import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndica import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2SubmenuTrigger, { + menu2SubmenuTriggerClasses, +} from '@mui/material/Unstable_Menu2SubmenuTrigger'; import Menu2Trigger, { menu2TriggerClasses } from '@mui/material/Unstable_Menu2Trigger'; +import { createTheme, enhanceHighContrast, ThemeProvider } from '@mui/material/styles'; describe('', () => { const { render } = createRenderer(); @@ -343,6 +346,55 @@ describe('', () => { } }); + it.skipIf(isJsdom())( + 'emits forced-colors rules for items under the contrast enhancer', + async () => { + const { user } = render( + + + Options + + Profile + + {/* The indicator only mounts while checked. */} + + Bookmarks + + + View + + Zoom in + + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + await screen.findByRole('menu'); + + // The rules only apply in forced colors, which the runner cannot emulate; + // assert that the enhancer's overrides reach the stylesheet at all. + const emitted = Array.from(document.styleSheets) + .flatMap((sheet) => { + try { + return Array.from(sheet.cssRules); + } catch { + return []; + } + }) + .map((rule) => rule.cssText) + .join('\n'); + + expect(emitted).to.contain('forced-colors'); + expect(emitted).to.contain(menu2ItemClasses.highlighted); + expect(emitted).to.contain(menu2CheckboxItemClasses.highlighted); + expect(emitted).to.contain(menu2SubmenuTriggerClasses.open); + expect(emitted).to.contain('data-mui-menu2-checkbox-checkmark'); + }, + ); + it.skipIf(isJsdom())('lets the default animation be overridden', async () => { const { user } = render( diff --git a/packages/mui-material/src/styles/enhanceHighContrast.test.ts b/packages/mui-material/src/styles/enhanceHighContrast.test.ts index a88bf0938b35e4..849e189b831068 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.test.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.test.ts @@ -9,6 +9,14 @@ import formLabelClasses from '../FormLabel/formLabelClasses'; import inputClasses from '../Input/inputClasses'; import listItemButtonClasses from '../ListItemButton/listItemButtonClasses'; import menuItemClasses from '../MenuItem/menuItemClasses'; +import { + menu2CheckboxItemClasses, + menu2CheckboxItemIndicatorClasses, + menu2ItemClasses, + menu2LinkItemClasses, + menu2RadioItemClasses, + menu2SubmenuTriggerClasses, +} from '../Unstable_Menu2/menu2Classes'; import nativeSelectClasses from '../NativeSelect/nativeSelectClasses'; import outlinedInputClasses from '../OutlinedInput/outlinedInputClasses'; import radioClasses from '../Radio/radioClasses'; @@ -587,6 +595,137 @@ describe('enhanceHighContrast', () => { }); }); + describe('Menu2 item overrides', () => { + const itemCases: Array< + [component: string, classes: { disabled: string; highlighted: string; selected: string }] + > = [ + ['MuiMenu2Item', menu2ItemClasses], + ['MuiMenu2LinkItem', menu2LinkItemClasses], + ['MuiMenu2CheckboxItem', menu2CheckboxItemClasses], + ['MuiMenu2RadioItem', menu2RadioItemClasses], + ['MuiMenu2SubmenuTrigger', menu2SubmenuTriggerClasses], + ]; + + test.each(itemCases)('%s keys the active state off `highlighted`', (component, classes) => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = (theme.components as any)[component].styleOverrides + .root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${classes.highlighted}, &:hover`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'HighlightText', + backgroundColor: 'Highlight', + outline: 'none', + }, + }); + }); + + test.each(itemCases)('%s covers disabled and selected', (component, classes) => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = (theme.components as any)[component].styleOverrides + .root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${classes.disabled}`]).to.deep.equal({ + [HCM]: { color: 'GrayText', opacity: 1 }, + }); + expect(hcmOverride[`&.${classes.selected}`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'SelectedItemText', + backgroundColor: 'SelectedItem', + }, + }); + const selectedActiveKey = `&.${classes.selected}.${classes.highlighted}, &.${classes.selected}:hover`; + expect(hcmOverride[selectedActiveKey]).to.deep.equal({ + [HCM]: { color: 'HighlightText', backgroundColor: 'Highlight' }, + }); + }); + + test.each(itemCases)('%s uses custom tokens', (component, classes) => { + const theme = enhanceHighContrast(createTheme(), { + disabled: 'ButtonText', + activeText: 'Canvas', + activeBackground: 'ButtonBorder', + }); + const rootOverrides = (theme.components as any)[component].styleOverrides + .root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${classes.disabled}`]).to.deep.equal({ + [HCM]: { color: 'ButtonText', opacity: 1 }, + }); + expect(hcmOverride[`&.${classes.highlighted}, &:hover`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'Canvas', + backgroundColor: 'ButtonBorder', + outline: 'none', + }, + }); + }); + + test('MuiMenu2SubmenuTrigger also marks the open state as active', () => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = theme.components?.MuiMenu2SubmenuTrigger?.styleOverrides + ?.root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${menu2SubmenuTriggerClasses.open}`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'HighlightText', + backgroundColor: 'Highlight', + }, + }); + }); + }); + + describe('Menu2 indicator overrides', () => { + test('MuiMenu2CheckboxItemIndicator inherits the item color and repaints the checkmark', () => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = theme.components?.MuiMenu2CheckboxItemIndicator?.styleOverrides + ?.root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[HCM]).to.deep.equal({ + color: 'inherit', + '& [data-mui-menu2-checkbox-checkmark]': { + forcedColorAdjust: 'none', + fill: 'Canvas', + }, + }); + expect(hcmOverride[`&.${menu2CheckboxItemIndicatorClasses.highlighted}`]).to.deep.equal({ + [HCM]: { + '& [data-mui-menu2-checkbox-checkmark]': { fill: 'Highlight' }, + }, + }); + }); + + test('MuiMenu2RadioItemIndicator inherits the item color', () => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = theme.components?.MuiMenu2RadioItemIndicator?.styleOverrides + ?.root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[HCM]).to.deep.equal({ color: 'inherit' }); + }); + + test('the checkmark follows the canvas token', () => { + const theme = enhanceHighContrast(createTheme(), { canvas: 'ButtonFace' }); + const rootOverrides = theme.components?.MuiMenu2CheckboxItemIndicator?.styleOverrides + ?.root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1] as Record; + + expect(hcmOverride[HCM]['& [data-mui-menu2-checkbox-checkmark]']).to.deep.equal({ + forcedColorAdjust: 'none', + fill: 'ButtonFace', + }); + }); + }); + describe('MuiNativeSelect overrides', () => { test('should apply disabled color to disabled icon', () => { const theme = enhanceHighContrast(createTheme()); @@ -880,6 +1019,13 @@ describe('enhanceHighContrast', () => { ['MuiLinearProgress', 'bar2'], ['MuiListItemButton', 'root'], ['MuiMenuItem', 'root'], + ['MuiMenu2Item', 'root'], + ['MuiMenu2LinkItem', 'root'], + ['MuiMenu2CheckboxItem', 'root'], + ['MuiMenu2RadioItem', 'root'], + ['MuiMenu2SubmenuTrigger', 'root'], + ['MuiMenu2CheckboxItemIndicator', 'root'], + ['MuiMenu2RadioItemIndicator', 'root'], ['MuiNativeSelect', 'icon'], ['MuiOutlinedInput', 'root'], ['MuiRadio', 'root'], diff --git a/packages/mui-material/src/styles/enhanceHighContrast.ts b/packages/mui-material/src/styles/enhanceHighContrast.ts index 4e8bd43ec44fcb..fb1e023d2e1b37 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.ts @@ -8,6 +8,14 @@ import formLabelClasses from '../FormLabel/formLabelClasses'; import inputClasses from '../Input/inputClasses'; import listItemButtonClasses from '../ListItemButton/listItemButtonClasses'; import menuItemClasses from '../MenuItem/menuItemClasses'; +import { + menu2CheckboxItemClasses, + menu2CheckboxItemIndicatorClasses, + menu2ItemClasses, + menu2LinkItemClasses, + menu2RadioItemClasses, + menu2SubmenuTriggerClasses, +} from '../Unstable_Menu2/menu2Classes'; import nativeSelectClasses from '../NativeSelect/nativeSelectClasses'; import outlinedInputClasses from '../OutlinedInput/outlinedInputClasses'; import radioClasses from '../Radio/radioClasses'; @@ -78,6 +86,44 @@ const defaultHcTokens: Required = { const HCM = '@media (forced-colors: active)'; +// The Menu2 parts reuse the classic item styles, but Base UI marks the active +// item with `data-highlighted` for keyboard and pointer alike, so the state +// class is `highlighted` where the classic item has `focusVisible`. +function menu2ItemOverrides( + classes: { disabled: string; highlighted: string; selected: string }, + hcTokens: Required, +) { + return { + [`&.${classes.disabled}`]: { + [HCM]: { + color: hcTokens.disabled, + opacity: 1, + }, + }, + [`&.${classes.highlighted}, &:hover`]: { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.activeText, + backgroundColor: hcTokens.activeBackground, + outline: 'none', + }, + }, + [`&.${classes.selected}`]: { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.selectedText, + backgroundColor: hcTokens.selectedBackground, + }, + }, + [`&.${classes.selected}.${classes.highlighted}, &.${classes.selected}:hover`]: { + [HCM]: { + color: hcTokens.activeText, + backgroundColor: hcTokens.activeBackground, + }, + }, + }; +} + /** * Enhances a theme with styles for Windows High Contrast Mode (forced-colors). * @@ -396,6 +442,109 @@ export default function enhanceHighContrast< ], }, }, + MuiMenu2Item: { + ...c?.MuiMenu2Item, + styleOverrides: { + ...c?.MuiMenu2Item?.styleOverrides, + root: [ + c?.MuiMenu2Item?.styleOverrides?.root, + menu2ItemOverrides(menu2ItemClasses, hcTokens), + ], + }, + }, + MuiMenu2LinkItem: { + ...c?.MuiMenu2LinkItem, + styleOverrides: { + ...c?.MuiMenu2LinkItem?.styleOverrides, + root: [ + c?.MuiMenu2LinkItem?.styleOverrides?.root, + menu2ItemOverrides(menu2LinkItemClasses, hcTokens), + ], + }, + }, + MuiMenu2CheckboxItem: { + ...c?.MuiMenu2CheckboxItem, + styleOverrides: { + ...c?.MuiMenu2CheckboxItem?.styleOverrides, + root: [ + c?.MuiMenu2CheckboxItem?.styleOverrides?.root, + menu2ItemOverrides(menu2CheckboxItemClasses, hcTokens), + ], + }, + }, + MuiMenu2RadioItem: { + ...c?.MuiMenu2RadioItem, + styleOverrides: { + ...c?.MuiMenu2RadioItem?.styleOverrides, + root: [ + c?.MuiMenu2RadioItem?.styleOverrides?.root, + menu2ItemOverrides(menu2RadioItemClasses, hcTokens), + ], + }, + }, + MuiMenu2SubmenuTrigger: { + ...c?.MuiMenu2SubmenuTrigger, + styleOverrides: { + ...c?.MuiMenu2SubmenuTrigger?.styleOverrides, + root: [ + c?.MuiMenu2SubmenuTrigger?.styleOverrides?.root, + { + ...menu2ItemOverrides(menu2SubmenuTriggerClasses, hcTokens), + // A trigger whose submenu is open carries the focus background. + [`&.${menu2SubmenuTriggerClasses.open}`]: { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.activeText, + backgroundColor: hcTokens.activeBackground, + }, + }, + }, + ], + }, + }, + // `forced-color-adjust` inherits, so the item rules above stop the browser + // from forcing colors on the indicator too. Inherit the item's color the way + // ListItemIcon does, and repaint the knocked-out checkmark in whatever the + // item's background now is, otherwise it merges into the box. + MuiMenu2CheckboxItemIndicator: { + ...c?.MuiMenu2CheckboxItemIndicator, + styleOverrides: { + ...c?.MuiMenu2CheckboxItemIndicator?.styleOverrides, + root: [ + c?.MuiMenu2CheckboxItemIndicator?.styleOverrides?.root, + { + [HCM]: { + color: 'inherit', + '& [data-mui-menu2-checkbox-checkmark]': { + forcedColorAdjust: 'none', + fill: hcTokens.canvas, + }, + }, + [`&.${menu2CheckboxItemIndicatorClasses.highlighted}`]: { + [HCM]: { + '& [data-mui-menu2-checkbox-checkmark]': { + fill: hcTokens.activeBackground, + }, + }, + }, + }, + ], + }, + }, + MuiMenu2RadioItemIndicator: { + ...c?.MuiMenu2RadioItemIndicator, + styleOverrides: { + ...c?.MuiMenu2RadioItemIndicator?.styleOverrides, + root: [ + c?.MuiMenu2RadioItemIndicator?.styleOverrides?.root, + { + [HCM]: { + color: 'inherit', + }, + }, + ], + }, + }, MuiListItemIcon: { ...c?.MuiListItemIcon, styleOverrides: { From 4b5545478b064979db935bb71c42962a53d899f3 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 31 Jul 2026 11:31:02 +0300 Subject: [PATCH 36/62] Fix four defects in the Menu2 forced-colors coverage An adversarial review pass found these; all four are confirmed against the source rather than theoretical. The integration test was a false positive. It grepped the whole stylesheet for `forced-colors` and for the Menu2 class names, all of which are emitted without the enhancer, so it passed with the entire Menu2 block deleted. It now looks inside the forced-colors media rules for ones that name a Menu2 class, and deleting the block fails it. A highlighted disabled item lost its disabled cue. `&.disabled` came first and the highlight rule matched at equal specificity, so a disabled item rendered as HighlightText on Highlight at full opacity, which is worse than no enhancer at all. This is unreachable on the classic item, because MenuList skips disabled items, but Base UI keeps them focusable by design, so one ArrowDown reaches it. The disabled rules now come last and the combination gets Canvas plus a border, so the cue survives and focus stays visible. The checked mark vanished on a selected checkbox item. The knocked-out checkmark was only repainted for the highlighted background, so on a selected item it stayed Canvas while the box painted SelectedItemText, and those are the same colour in both stock Windows palettes. The item now drives the checkmark for its selected state too. A selected submenu trigger kept its translucent background while open, because the base style pairs `selected` with `open` and outranked the lone `open` override. Co-Authored-By: Claude Opus 5 --- .../src/Unstable_Menu2/Menu2.test.tsx | 24 +++++--- .../src/styles/enhanceHighContrast.test.ts | 51 +++++++++++++++- .../src/styles/enhanceHighContrast.ts | 58 ++++++++++++++----- 3 files changed, 111 insertions(+), 22 deletions(-) diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index 2acfe2af218c5e..6fe59125a3bdbb 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -376,7 +376,10 @@ describe('', () => { // The rules only apply in forced colors, which the runner cannot emulate; // assert that the enhancer's overrides reach the stylesheet at all. - const emitted = Array.from(document.styleSheets) + // Look inside the forced-colors media rules specifically: the class names + // and the media query itself are both emitted without the enhancer, so + // matching on the whole stylesheet would pass with the overrides removed. + const menu2ForcedColorsRules = Array.from(document.styleSheets) .flatMap((sheet) => { try { return Array.from(sheet.cssRules); @@ -384,14 +387,19 @@ describe('', () => { return []; } }) + .filter((rule) => (rule as CSSMediaRule).conditionText?.includes('forced-colors')) + .flatMap((rule) => Array.from((rule as CSSMediaRule).cssRules ?? [])) .map((rule) => rule.cssText) - .join('\n'); - - expect(emitted).to.contain('forced-colors'); - expect(emitted).to.contain(menu2ItemClasses.highlighted); - expect(emitted).to.contain(menu2CheckboxItemClasses.highlighted); - expect(emitted).to.contain(menu2SubmenuTriggerClasses.open); - expect(emitted).to.contain('data-mui-menu2-checkbox-checkmark'); + .filter((text) => text.includes('MuiMenu2')); + + // The CSSOM lowercases system colour keywords. + expect(menu2ForcedColorsRules.join('\n').toLowerCase()).to.contain('highlighttext'); + const matches = (needle: string) => + menu2ForcedColorsRules.some((text) => text.includes(needle)); + expect(matches(menu2ItemClasses.highlighted)).to.equal(true); + expect(matches(menu2CheckboxItemClasses.highlighted)).to.equal(true); + expect(matches(menu2SubmenuTriggerClasses.open)).to.equal(true); + expect(matches('data-mui-menu2-checkbox-checkmark')).to.equal(true); }, ); diff --git a/packages/mui-material/src/styles/enhanceHighContrast.test.ts b/packages/mui-material/src/styles/enhanceHighContrast.test.ts index 849e189b831068..6f2b15cbf3a173 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.test.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.test.ts @@ -667,13 +667,62 @@ describe('enhanceHighContrast', () => { }); }); + test.each(itemCases)('%s keeps the disabled cue when highlighted', (component, classes) => { + // Base UI keeps disabled items focusable, so this combination is + // reachable here even though it is not on the classic item. + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = (theme.components as any)[component].styleOverrides + .root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${classes.disabled}.${classes.highlighted}`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'GrayText', + backgroundColor: 'Canvas', + outline: '1px solid ButtonBorder', + }, + }); + }); + + test.each(itemCases)( + '%s orders the disabled rules after the highlight', + (component, classes) => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = (theme.components as any)[component].styleOverrides + .root as Array; + const keys = Object.keys(rootOverrides[rootOverrides.length - 1]); + + expect(keys.indexOf(`&.${classes.disabled}`)).to.be.greaterThan( + keys.indexOf(`&.${classes.highlighted}, &:hover`), + ); + }, + ); + + test('MuiMenu2CheckboxItem repaints the checkmark for the selected background', () => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = theme.components?.MuiMenu2CheckboxItem?.styleOverrides + ?.root as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + const selectedKey = `&.${menu2CheckboxItemClasses.selected} [data-mui-menu2-checkbox-checkmark]`; + const selectedActiveKey = `&.${menu2CheckboxItemClasses.selected}.${menu2CheckboxItemClasses.highlighted} [data-mui-menu2-checkbox-checkmark]`; + + expect(hcmOverride[selectedKey]).to.deep.equal({ + [HCM]: { forcedColorAdjust: 'none', fill: 'SelectedItem' }, + }); + expect(hcmOverride[selectedActiveKey]).to.deep.equal({ + [HCM]: { fill: 'Highlight' }, + }); + }); + test('MuiMenu2SubmenuTrigger also marks the open state as active', () => { const theme = enhanceHighContrast(createTheme()); const rootOverrides = theme.components?.MuiMenu2SubmenuTrigger?.styleOverrides ?.root as Array; const hcmOverride = rootOverrides[rootOverrides.length - 1]; - expect(hcmOverride[`&.${menu2SubmenuTriggerClasses.open}`]).to.deep.equal({ + const openKey = `&.${menu2SubmenuTriggerClasses.open}, &.${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`; + expect(hcmOverride[openKey]).to.deep.equal({ [HCM]: { forcedColorAdjust: 'none', color: 'HighlightText', diff --git a/packages/mui-material/src/styles/enhanceHighContrast.ts b/packages/mui-material/src/styles/enhanceHighContrast.ts index fb1e023d2e1b37..44efcd1a3a5d6a 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.ts @@ -94,12 +94,6 @@ function menu2ItemOverrides( hcTokens: Required, ) { return { - [`&.${classes.disabled}`]: { - [HCM]: { - color: hcTokens.disabled, - opacity: 1, - }, - }, [`&.${classes.highlighted}, &:hover`]: { [HCM]: { forcedColorAdjust: 'none', @@ -121,6 +115,24 @@ function menu2ItemOverrides( backgroundColor: hcTokens.activeBackground, }, }, + // Base UI keeps disabled items focusable, so unlike the classic item a + // disabled one can be highlighted. The disabled cue has to outrank the + // highlight, so it comes last, and the combination gets its own rule to + // keep the cue off the highlight background while still showing focus. + [`&.${classes.disabled}`]: { + [HCM]: { + color: hcTokens.disabled, + opacity: 1, + }, + }, + [`&.${classes.disabled}.${classes.highlighted}`]: { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.disabled, + backgroundColor: hcTokens.canvas, + outline: `1px solid ${hcTokens.buttonBorder}`, + }, + }, }; } @@ -468,7 +480,24 @@ export default function enhanceHighContrast< ...c?.MuiMenu2CheckboxItem?.styleOverrides, root: [ c?.MuiMenu2CheckboxItem?.styleOverrides?.root, - menu2ItemOverrides(menu2CheckboxItemClasses, hcTokens), + { + ...menu2ItemOverrides(menu2CheckboxItemClasses, hcTokens), + // The indicator has no `selected` class of its own, so the + // knocked-out checkmark has to follow the item background from + // here; left alone it stays Canvas and merges into the box. + [`&.${menu2CheckboxItemClasses.selected} [data-mui-menu2-checkbox-checkmark]`]: { + [HCM]: { + forcedColorAdjust: 'none', + fill: hcTokens.selectedBackground, + }, + }, + [`&.${menu2CheckboxItemClasses.selected}.${menu2CheckboxItemClasses.highlighted} [data-mui-menu2-checkbox-checkmark]`]: + { + [HCM]: { + fill: hcTokens.activeBackground, + }, + }, + }, ], }, }, @@ -491,13 +520,16 @@ export default function enhanceHighContrast< { ...menu2ItemOverrides(menu2SubmenuTriggerClasses, hcTokens), // A trigger whose submenu is open carries the focus background. - [`&.${menu2SubmenuTriggerClasses.open}`]: { - [HCM]: { - forcedColorAdjust: 'none', - color: hcTokens.activeText, - backgroundColor: hcTokens.activeBackground, + // The selected variant needs spelling out: the base style pairs + // `selected` with `open`, which outranks a lone `open` selector. + [`&.${menu2SubmenuTriggerClasses.open}, &.${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`]: + { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.activeText, + backgroundColor: hcTokens.activeBackground, + }, }, - }, }, ], }, From 26352a66f3336976da79932b494918939619fa4e Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 31 Jul 2026 17:52:46 +0300 Subject: [PATCH 37/62] Build the indicator into the checkbox and radio items Per review: the two-part shape had two easy mistakes. Omit the indicator and the checked state has no visual while the item still works; omit `keepMounted` and the label shifts when unchecked. Both are real -- every one of our own 14 call sites passed `keepMounted`, and a test written without it silently rendered no indicator at all, because Base UI defaults it to false. Items now render their own indicator with the space reserved. Usage drops to: PDF document Customization goes through `slots.indicator` / `slotProps.indicator`, and `slots={{ indicator: null }}` renders none, which is what the indicator's own conformance suite uses to mount one directly. Not done: reusing Radio/Checkbox internally. Checkbox renders SwitchBase, a ButtonBase wrapping a real input, which would put a focusable control inside a menuitemcheckbox -- the pattern open question 1 rules out, and axe's nested-interactive. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-playground.tsx | 11 +--- docs/pages/experiments/menu2-recipes.tsx | 54 ++++--------------- .../src/Unstable_Menu2/Menu2.spec.tsx | 2 - .../src/Unstable_Menu2/Menu2.test.tsx | 43 +++++---------- .../Menu2CheckboxItem.tsx | 28 +++++++++- .../Menu2CheckboxItemIndicator.test.tsx | 4 +- .../Menu2RadioItem.tsx | 27 +++++++++- .../Menu2RadioItemIndicator.test.tsx | 4 +- 8 files changed, 82 insertions(+), 91 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index aa63340effdd34..f14aa99e961f5f 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -23,7 +23,6 @@ import { DirectionProvider } from '@base-ui/react/direction-provider'; // prefix and the JSX mirrors the future stable names. import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; -import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; import Menu2Group from '@mui/material/Unstable_Menu2Group'; import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; @@ -31,7 +30,6 @@ import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; @@ -210,15 +208,12 @@ function PlaygroundDemo({ - PDF document - EPUB publication - Markdown @@ -234,13 +229,9 @@ function PlaygroundDemo({ - Show ruler - - - Show outline - + Show outline diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index 5db3480fbf4bb0..9f4f91340c8f45 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -13,7 +13,6 @@ import { ThemeProvider, createTheme, useTheme } from '@mui/material/styles'; // prefix and the JSX mirrors the future stable names. import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; -import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; import Menu2Group from '@mui/material/Unstable_Menu2Group'; import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; @@ -21,7 +20,6 @@ import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; @@ -351,20 +349,10 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { Document display - - - 100% - - - - Fit - - - - Page width - + 100% + Fit + Page width - Custom zoom unavailable @@ -374,22 +362,10 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { Show - - - Ruler - - - - Document outline - - - - Line numbers - - - - Page breaks unavailable - + Ruler + Document outline + Line numbers + Page breaks unavailable @@ -491,16 +467,10 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b Show - - - Comments - + Comments - - - Page breaks - + Page breaks @@ -510,14 +480,10 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b Zoom - - - Fit - + Fit - Custom diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx index 012e77f1fd923b..667f050208a096 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -69,7 +69,6 @@ function Menu2Composition() { eventDetails.cancel(); }} > - Checkbox - One diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index 6fe59125a3bdbb..6db7b7af6d9381 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -11,7 +11,6 @@ import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2CheckboxItem, { menu2CheckboxItemClasses, } from '@mui/material/Unstable_Menu2CheckboxItem'; -import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; import Menu2Group from '@mui/material/Unstable_Menu2Group'; import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; @@ -19,7 +18,6 @@ import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; import Menu2Popup, { menu2PopupClasses } from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; @@ -357,7 +355,6 @@ describe('', () => { Profile {/* The indicator only mounts while checked. */} - Bookmarks @@ -718,19 +715,10 @@ describe('', () => { Options - - - Show hidden files - + Show hidden files - - - Small - - - - Large - + Small + Large , @@ -770,17 +758,20 @@ describe('', () => { Options - - + Show hidden files - - + Small - - + Large @@ -991,17 +982,11 @@ describe('', () => { New document - - - Comments - + Comments - - - Fit - + Fit diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx index d37c964704f978..59ad4b0ff1fe06 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -9,10 +9,14 @@ import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import Menu2CheckboxItemIndicator, { + Menu2CheckboxItemIndicatorProps, +} from '../Unstable_Menu2CheckboxItemIndicator'; import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps, + SlotProps, } from '../Unstable_Menu2/menu2Utils'; import { getMenu2ItemClassName, @@ -35,9 +39,19 @@ export interface Menu2CheckboxItemSlots { * @default 'div' */ root?: React.ElementType | undefined; + /** + * The component that renders the check indicator. + * Pass `null` to render no indicator. + * @default Menu2CheckboxItemIndicator + */ + indicator?: React.ElementType | null | undefined; } -export interface Menu2CheckboxItemSlotProps extends Menu2RootSlotProps {} +export interface Menu2CheckboxItemSlotProps extends Menu2RootSlotProps { + indicator?: + | SlotProps & Record, Menu2ItemOwnerState> + | undefined; +} export interface Menu2CheckboxItemProps extends @@ -126,6 +140,7 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( }); const { + children, checked, className, classes: classesProp, @@ -173,6 +188,9 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( [onChange], ); const RootSlot = slots?.root ?? Menu2CheckboxItemRoot; + const IndicatorSlot = + slots?.indicator === undefined ? Menu2CheckboxItemIndicator : slots.indicator; + const resolvedIndicatorProps = resolveComponentProps(slotProps?.indicator, ownerState); return ( @@ -197,7 +215,11 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( onCheckedChange={handleCheckedChange} style={style} {...other} - /> + > + {/* Reserved by default: an unmounted indicator would shift the label. */} + {IndicatorSlot ? : null} + {children} + ); }); @@ -284,12 +306,14 @@ Menu2CheckboxItem.propTypes /* remove-proptypes */ = { * The props used for each slot inside. */ slotProps: PropTypes.shape({ + indicator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }), /** * The components used for each slot inside. */ slots: PropTypes.shape({ + indicator: PropTypes.elementType, root: PropTypes.elementType, }), /** diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx index a939573f6ebc6f..94c3c596b001d8 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx @@ -19,7 +19,9 @@ describe('', () => { render( - {node}Ruler + + {node}Ruler + , ), diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx index 1e2a4711f10042..94b832b42516d8 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx @@ -9,10 +9,14 @@ import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import Menu2RadioItemIndicator, { + Menu2RadioItemIndicatorProps, +} from '../Unstable_Menu2RadioItemIndicator'; import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps, + SlotProps, } from '../Unstable_Menu2/menu2Utils'; import { getMenu2ItemClassName, @@ -35,9 +39,19 @@ export interface Menu2RadioItemSlots { * @default 'div' */ root?: React.ElementType | undefined; + /** + * The component that renders the check indicator. + * Pass `null` to render no indicator. + * @default Menu2RadioItemIndicator + */ + indicator?: React.ElementType | null | undefined; } -export interface Menu2RadioItemSlotProps extends Menu2RootSlotProps {} +export interface Menu2RadioItemSlotProps extends Menu2RootSlotProps { + indicator?: + | SlotProps & Record, Menu2ItemOwnerState> + | undefined; +} export interface Menu2RadioItemProps extends @@ -100,6 +114,7 @@ const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( }); const { + children, className, classes: classesProp, component, @@ -132,6 +147,8 @@ const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( [dense, disableGutters], ); const RootSlot = slots?.root ?? Menu2RadioItemRoot; + const IndicatorSlot = slots?.indicator === undefined ? Menu2RadioItemIndicator : slots.indicator; + const resolvedIndicatorProps = resolveComponentProps(slotProps?.indicator, ownerState); return ( @@ -154,7 +171,11 @@ const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} style={style} {...other} - /> + > + {/* Reserved by default: an unmounted indicator would shift the label. */} + {IndicatorSlot ? : null} + {children} + ); }); @@ -224,12 +245,14 @@ Menu2RadioItem.propTypes /* remove-proptypes */ = { * The props used for each slot inside. */ slotProps: PropTypes.shape({ + indicator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }), /** * The components used for each slot inside. */ slots: PropTypes.shape({ + indicator: PropTypes.elementType, root: PropTypes.elementType, }), /** diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx index 1f5e96a0662de6..022815bd72f70c 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx @@ -21,7 +21,9 @@ describe('', () => { - {node}One + + {node}One + , From c9ae437f7d008d7a97052b2b17eb25862b8d29d5 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 31 Jul 2026 18:53:11 +0300 Subject: [PATCH 38/62] Collapse the menu containers, at both levels Applies the review proposal, extended so the two levels match. `Menu2` now carries the trigger and the popup surface, and `Menu2Submenu` is the same shape one level down, so a menu tree uses one component per menu instead of a root plus a trigger plus a popup: Options}> Cut Zoom in The root `trigger` takes an element and Base UI's `render` merges the behavior into it, so the caller keeps whatever component they chose; anything else falls back to the default trigger. Omit it and drive the menu with `open` and `anchor`, which keeps the classic controlled shape. The submenu `trigger` takes content rather than an element, which is the one place the two levels cannot match: a submenu trigger is already a menu item, so passing `` nests an item inside an item and the submenu never opens. The prototype is what surfaced that; the review reply I drafted had it wrong. `slots.trigger` swaps the component. The 12 conformance suites move to the collapsed form and stay green. Co-Authored-By: Claude Opus 5 --- .../mui-material/src/Unstable_Menu2/Menu2.tsx | 154 +++++++++++++++- .../Menu2CheckboxItem.test.tsx | 5 +- .../Menu2CheckboxItemIndicator.test.tsx | 11 +- .../Unstable_Menu2Group/Menu2Group.test.tsx | 5 +- .../Menu2GroupLabel.test.tsx | 7 +- .../src/Unstable_Menu2Item/Menu2Item.test.tsx | 5 +- .../Menu2LinkItem.test.tsx | 5 +- .../Menu2RadioGroup.test.tsx | 5 +- .../Menu2RadioItem.test.tsx | 7 +- .../Menu2RadioItemIndicator.test.tsx | 15 +- .../Menu2Separator.test.tsx | 5 +- .../Menu2Collapsed.test.tsx | 128 ++++++++++++++ .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 164 ++++++++++++++++++ .../src/Unstable_Menu2Submenu/index.d.ts | 2 + .../src/Unstable_Menu2Submenu/index.js | 2 + .../Menu2SubmenuPopup.test.tsx | 13 +- .../Menu2SubmenuTrigger.test.tsx | 7 +- .../mui-material/src/styles/components.ts | 5 + packages/mui-material/src/styles/props.ts | 2 + 19 files changed, 482 insertions(+), 65 deletions(-) create mode 100644 packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx create mode 100644 packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts create mode 100644 packages/mui-material/src/Unstable_Menu2Submenu/index.js diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 40501b5bfeedba..662067bb1b9c43 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -1,20 +1,57 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import resolveComponentProps from '@mui/utils/resolveComponentProps'; import { Menu as BaseMenu } from '@base-ui/react/menu'; +import Menu2Popup, { Menu2PopupProps } from '../Unstable_Menu2Popup'; +import Menu2Trigger from '../Unstable_Menu2Trigger'; import { useDefaultProps } from '../DefaultPropsProvider'; +import { menu2TriggerClasses } from './menu2Classes'; +import { SlotProps } from './menu2Utils'; + +export interface Menu2Slots extends NonNullable { + /** + * The component that renders the trigger, when `trigger` is not an element. + * @default Menu2Trigger + */ + trigger?: React.ElementType | undefined; +} + +export interface Menu2SlotProps extends NonNullable { + trigger?: SlotProps, Menu2Props> | undefined; +} /** - * Inherits the full Base UI `Menu.Root` prop surface (open/close control, - * modality, `actionsRef`, keyboard behavior); hover-open props live on the - * trigger parts. `Omit` (a mapped type) is used instead of bare `extends` so - * the proptypes generator resolves the inherited members. + * Inherits the Base UI `Menu.Root` prop surface (open/close control, modality, + * `actionsRef`, keyboard behavior) plus the popup's positioning and appearance + * props, so one menu is one component. `Omit` (a mapped type) is used instead + * of bare `extends` so the proptypes generator resolves the inherited members. */ -export interface Menu2Props extends Omit { +export interface Menu2Props + extends Omit, + Omit { /** - * The content of the menu. + * The menu items. */ children?: React.ReactNode; + /** + * The element that opens the menu. + * + * An element is rendered as-is with the trigger behavior merged into it, so + * it keeps whatever component you passed. Anything else renders inside the + * default trigger. Omit it and drive the menu with `open` and `anchor` + * instead, which is the classic controlled pattern. + */ + trigger?: React.ReactNode; + /** + * The components used for each slot inside. + */ + slots?: Menu2Slots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2SlotProps | undefined; } /** @@ -29,7 +66,96 @@ function Menu2(props: Menu2Props): React.JSX.Element { name: 'MuiMenu2', }); - return ; + const { + children, + trigger, + slots, + slotProps, + // The popup surface, hoisted onto the root. + align, + alignOffset, + anchor, + arrowPadding, + classes, + className, + collisionAvoidance, + collisionBoundary, + collisionPadding, + container, + disableAnchorTracking, + elevation, + finalFocus, + keepMounted, + positionMethod, + side, + sideOffset, + sticky, + style, + sx, + ...rootProps + } = themedProps; + + const { trigger: triggerSlot, ...popupSlots } = slots ?? {}; + const { trigger: triggerSlotProps, ...popupSlotProps } = slotProps ?? {}; + const resolvedTriggerProps = resolveComponentProps(triggerSlotProps, themedProps); + + let triggerNode: React.ReactNode = null; + if (trigger != null) { + triggerNode = React.isValidElement(trigger) ? ( + // Base UI's `render` merges the trigger behavior into the element, so the + // caller keeps whatever component they passed. + + clsx( + menu2TriggerClasses.root, + state.open && menu2TriggerClasses.open, + resolvedTriggerProps?.className, + ) + } + /> + ) : ( + + {trigger} + + ); + } + + return ( + + {triggerNode} + + {children} + + + ); } Menu2.propTypes /* remove-proptypes */ = { @@ -38,9 +164,21 @@ Menu2.propTypes /* remove-proptypes */ = { // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ // └─────────────────────────────────────────────────────────────────────┘ /** - * The content of the menu. + * The menu items. */ children: PropTypes.node, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.object, + /** + * The components used for each slot inside. + */ + slots: PropTypes.object, + /** + * The element that opens the menu. + */ + trigger: PropTypes.node, } as any; export default Menu2; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx index e8fe9bfac71aae..9e73846a0fc6cc 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2CheckboxItem, { menu2CheckboxItemClasses as classes, } from '@mui/material/Unstable_Menu2CheckboxItem'; @@ -16,8 +15,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - {node} + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx index 94c3c596b001d8..64290417f7545b 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; import Unstable_Menu2CheckboxItemIndicator, { menu2CheckboxItemIndicatorClasses as classes, @@ -17,12 +16,10 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - - - {node}Ruler - - + + + {node}Ruler + , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx index cd8fbb4f2128b0..e446bbb3c9c360 100644 --- a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2Group, { menu2GroupClasses as classes, } from '@mui/material/Unstable_Menu2Group'; @@ -16,8 +15,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - {node} + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx index 80b4f4efdc7e9c..73476244dc698e 100644 --- a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; import Unstable_Menu2GroupLabel, { menu2GroupLabelClasses as classes, @@ -17,10 +16,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - - {node} - + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx index 54c0596540014e..68e0c2fa9fe713 100644 --- a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; import Unstable_Menu2Item, { menu2ItemClasses as classes } from '@mui/material/Unstable_Menu2Item'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import describeConformance from '../../test/describeConformance'; describe('', () => { @@ -12,8 +11,8 @@ describe('', () => { classes, render: (node) => { const { container, ...other } = render( - - {node} + + {node} , ); // The popup renders in a portal; hand the harness a container whose diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx index 058eb5efcf9909..ecd4d51e98e52b 100644 --- a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2LinkItem, { menu2LinkItemClasses as classes, } from '@mui/material/Unstable_Menu2LinkItem'; @@ -18,8 +17,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - {node} + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx index 3252ab0a589be0..6fe8dabf0dc6e6 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2RadioGroup, { menu2RadioGroupClasses as classes, } from '@mui/material/Unstable_Menu2RadioGroup'; @@ -16,8 +15,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - {node} + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx index 986eca3c3a61f5..37b36fb32abf8a 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Unstable_Menu2RadioItem, { menu2RadioItemClasses as classes, @@ -17,10 +16,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - - {node} - + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx index 022815bd72f70c..f3df74814eda8e 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; import Unstable_Menu2RadioItemIndicator, { @@ -18,14 +17,12 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - - - - {node}One - - - + + + + {node}One + + , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx index 6427e977d16c16..cdc628d47d09f0 100644 --- a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2Separator, { menu2SeparatorClasses as classes, } from '@mui/material/Unstable_Menu2Separator'; @@ -16,8 +15,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - {node} + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx new file mode 100644 index 00000000000000..6eb6f731dfadf8 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; +import Button from '@mui/material/Button'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; +import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; +import { menu2PopupClasses } from '@mui/material/Unstable_Menu2Popup'; +import { menu2TriggerClasses } from '@mui/material/Unstable_Menu2Trigger'; + +// The collapsed shape: one component per menu at both levels, trigger as a +// prop, children as the popup. +describe(' collapsed API', () => { + const { render } = createRenderer(); + + it('renders the trigger element as-is and opens the menu', async () => { + const { user } = render( + Options}> + Profile + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + // The caller's component survives; only the trigger behavior is merged in. + expect(trigger).to.have.class('MuiButton-root'); + expect(trigger).to.have.class(menu2TriggerClasses.root); + expect(trigger).to.have.attribute('aria-haspopup', 'menu'); + + await user.click(trigger); + + const menu = await screen.findByRole('menu'); + expect(menu).to.have.class(menu2PopupClasses.root); + expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class(menu2ItemClasses.root); + }); + + it('falls back to the default trigger for a non-element', async () => { + const { user } = render( + + Profile + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger).to.have.class(menu2TriggerClasses.root); + + await user.click(trigger); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it('marks the trigger open while the menu is open', async () => { + const { user } = render( + Options}> + Profile + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger).not.to.have.class(menu2TriggerClasses.open); + + await user.click(trigger); + await screen.findByRole('menu'); + + expect(trigger).to.have.class(menu2TriggerClasses.open); + }); + + it('accepts the hoisted popup props', async () => { + const { user } = render( + Options} + side="top" + elevation={16} + slotProps={{ paper: { 'data-testid': 'paper' } }} + > + Profile + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByTestId('paper')).to.have.class('MuiPaper-elevation16'); + }); + + it('works without a trigger, driven by open and anchor', async () => { + function ControlledMenu() { + const [anchorEl, setAnchorEl] = React.useState(null); + return ( + + + setAnchorEl(null)}> + Profile + + + ); + } + + const { user } = render(); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + // The nested popup needs real layout to mount. + it.skipIf(isJsdom())('uses the same shape for submenus', async () => { + const { user } = render( + Options}> + Cut + + Zoom in + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const submenuTrigger = await screen.findByRole('menuitem', { name: 'View' }); + expect(submenuTrigger).to.have.attribute('aria-haspopup', 'menu'); + + await user.click(submenuTrigger); + + await waitFor(() => { + expect(screen.getByRole('menuitem', { name: 'Zoom in' })).not.to.equal(null); + }); + }); +}); diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx new file mode 100644 index 00000000000000..4b92f8126bd11d --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -0,0 +1,164 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import resolveComponentProps from '@mui/utils/resolveComponentProps'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import Menu2SubmenuPopup, { Menu2SubmenuPopupProps } from '../Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuTrigger from '../Unstable_Menu2SubmenuTrigger'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { SlotProps } from '../Unstable_Menu2/menu2Utils'; + +export interface Menu2SubmenuSlots extends NonNullable { + /** + * The component that renders the trigger, when `trigger` is not an element. + * @default Menu2SubmenuTrigger + */ + trigger?: React.ElementType | undefined; +} + +export interface Menu2SubmenuSlotProps extends NonNullable { + trigger?: SlotProps, Menu2SubmenuProps> | undefined; +} + +/** + * The submenu counterpart of `Menu2`, with the same shape: a prop-only root, + * the trigger passed as a prop, and the children forming the popup. + */ +export interface Menu2SubmenuProps + extends + Omit, + Omit { + /** + * The submenu items. + */ + children?: React.ReactNode; + /** + * The content of the item that opens the submenu. + * + * Unlike the root menu, this is the label rather than the element: a submenu + * trigger is always a menu item, so passing one would nest an item inside an + * item. Swap the component through `slots.trigger` instead. + */ + trigger?: React.ReactNode; + /** + * The components used for each slot inside. + */ + slots?: Menu2SubmenuSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2SubmenuSlotProps | undefined; +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +function Menu2Submenu(props: Menu2SubmenuProps): React.JSX.Element { + const themedProps = useDefaultProps({ + props, + name: 'MuiMenu2Submenu', + }); + + const { + children, + trigger, + slots, + slotProps, + // The popup surface, hoisted onto the root. + align, + alignOffset, + anchor, + arrowPadding, + classes, + className, + collisionAvoidance, + collisionBoundary, + collisionPadding, + container, + disableAnchorTracking, + elevation, + finalFocus, + keepMounted, + positionMethod, + side, + sideOffset, + sticky, + style, + sx, + ...rootProps + } = themedProps; + + const { trigger: triggerSlot, ...popupSlots } = slots ?? {}; + const { trigger: triggerSlotProps, ...popupSlotProps } = slotProps ?? {}; + const resolvedTriggerProps = resolveComponentProps(triggerSlotProps, themedProps); + + const triggerNode = + trigger == null ? null : ( + + {trigger} + + ); + + return ( + + {triggerNode} + + {children} + + + ); +} + +Menu2Submenu.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The submenu items. + */ + children: PropTypes.node, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.object, + /** + * The components used for each slot inside. + */ + slots: PropTypes.object, + /** + * The item that opens the submenu. + */ + trigger: PropTypes.node, +} as any; + +export default Menu2Submenu; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts b/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts new file mode 100644 index 00000000000000..c37bb3b5049376 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts @@ -0,0 +1,2 @@ +export { default } from './Menu2Submenu'; +export * from './Menu2Submenu'; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.js b/packages/mui-material/src/Unstable_Menu2Submenu/index.js new file mode 100644 index 00000000000000..c37bb3b5049376 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/index.js @@ -0,0 +1,2 @@ +export { default } from './Menu2Submenu'; +export * from './Menu2Submenu'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx index 2d55d188c1238e..1e6c986d8d30f2 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer, isJsdom } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; @@ -25,13 +24,11 @@ describe.skipIf(isJsdom())('', () => { render: (node) => withPortalledRoot( render( - - - - More - {node} - - + + + More + {node} + , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx index dae4c4f03915a5..8631903acc6fd6 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; import Unstable_Menu2SubmenuTrigger, { menu2SubmenuTriggerClasses as classes, @@ -17,10 +16,8 @@ describe('', () => { render: (node) => withPortalledRoot( render( - - - {node} - + + {node} , ), `.${classes.root}`, diff --git a/packages/mui-material/src/styles/components.ts b/packages/mui-material/src/styles/components.ts index ca0f1ac67610e3..a068a06f37e1a5 100644 --- a/packages/mui-material/src/styles/components.ts +++ b/packages/mui-material/src/styles/components.ts @@ -566,6 +566,11 @@ export interface Components { variants?: ComponentsVariants['MuiMenu2SubmenuPopup'] | undefined; } | undefined; + MuiMenu2Submenu?: + | { + defaultProps?: ComponentsProps['MuiMenu2Submenu'] | undefined; + } + | undefined; MuiMenu2SubmenuRoot?: | { defaultProps?: ComponentsProps['MuiMenu2SubmenuRoot'] | undefined; diff --git a/packages/mui-material/src/styles/props.ts b/packages/mui-material/src/styles/props.ts index 88858b6a69df9b..ae71534d6e6601 100644 --- a/packages/mui-material/src/styles/props.ts +++ b/packages/mui-material/src/styles/props.ts @@ -77,6 +77,7 @@ import { Menu2RadioItemProps } from '../Unstable_Menu2RadioItem'; import { Menu2RadioItemIndicatorProps } from '../Unstable_Menu2RadioItemIndicator'; import { Menu2SeparatorProps } from '../Unstable_Menu2Separator'; import { Menu2SubmenuPopupProps } from '../Unstable_Menu2SubmenuPopup'; +import { Menu2SubmenuProps } from '../Unstable_Menu2Submenu'; import { Menu2SubmenuRootProps } from '../Unstable_Menu2SubmenuRoot'; import { Menu2SubmenuTriggerProps } from '../Unstable_Menu2SubmenuTrigger'; import { Menu2TriggerProps } from '../Unstable_Menu2Trigger'; @@ -218,6 +219,7 @@ export interface ComponentsPropsList { MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorProps; MuiMenu2Separator: Menu2SeparatorProps; MuiMenu2SubmenuPopup: Menu2SubmenuPopupProps; + MuiMenu2Submenu: Menu2SubmenuProps; MuiMenu2SubmenuRoot: Menu2SubmenuRootProps; MuiMenu2SubmenuTrigger: Menu2SubmenuTriggerProps; MuiMenu2Trigger: Menu2TriggerProps; From 379eed9f4bd53bade0afbe5d92b2296e3a5a9811 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 3 Aug 2026 11:11:39 +0300 Subject: [PATCH 39/62] Make the composed parts internal and migrate the rest The trigger and popup parts are rendered by Menu2 and Menu2Submenu now, so they stop being public deep imports. They move into the Unstable_Menu2 folder next to the other internal modules; only their class hooks stay exported, from Unstable_Menu2 and Unstable_Menu2Submenu, so styleOverrides and sx keep working. Their four conformance suites go with them: what they covered is reached through the collapsed components, which have their own suites. Migrates everything still on the compound shape: Menu2.test.tsx (35 roots, 6 submenus), the benchmark, the type spec, the playground, and the recipes. The benchmark passing unchanged is the useful signal here, since it measures behavior against the classic Menu rather than structure. One regression worth naming: a tooltip could wrap the trigger element directly in the compound shape. With the trigger as a prop the wrapper has to move into the trigger's root slot, which costs a forwardRef component. Both the test suite and the recipes page hit it, so it is not a one-off. Recorded in both places. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-playground.tsx | 260 +++++---- docs/pages/experiments/menu2-recipes.tsx | 409 +++++++------ .../src/Unstable_Menu2/Menu2.spec.tsx | 158 +++-- .../src/Unstable_Menu2/Menu2.test.tsx | 548 +++++++----------- .../mui-material/src/Unstable_Menu2/Menu2.tsx | 4 +- .../Unstable_Menu2/Menu2Benchmark.test.tsx | 26 +- .../Menu2Popup.tsx | 6 +- .../Menu2SubmenuPopup.tsx | 6 +- .../Menu2SubmenuRoot.tsx | 0 .../Menu2SubmenuTrigger.tsx | 8 +- .../Menu2Trigger.tsx | 4 +- .../src/Unstable_Menu2/index.d.ts | 14 + .../mui-material/src/Unstable_Menu2/index.js | 8 + .../Unstable_Menu2Popup/Menu2Popup.test.tsx | 47 -- .../src/Unstable_Menu2Popup/index.d.ts | 4 - .../src/Unstable_Menu2Popup/index.js | 3 - .../Menu2Collapsed.test.tsx | 4 +- .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 4 +- .../src/Unstable_Menu2Submenu/index.d.ts | 13 + .../src/Unstable_Menu2Submenu/index.js | 7 + .../Menu2SubmenuPopup.test.tsx | 52 -- .../src/Unstable_Menu2SubmenuPopup/index.d.ts | 10 - .../src/Unstable_Menu2SubmenuPopup/index.js | 6 - .../src/Unstable_Menu2SubmenuRoot/index.d.ts | 2 - .../src/Unstable_Menu2SubmenuRoot/index.js | 2 - .../Menu2SubmenuTrigger.test.tsx | 30 - .../Unstable_Menu2SubmenuTrigger/index.d.ts | 10 - .../src/Unstable_Menu2SubmenuTrigger/index.js | 6 - .../Menu2Trigger.test.tsx | 20 - .../src/Unstable_Menu2Trigger/index.d.ts | 4 - .../src/Unstable_Menu2Trigger/index.js | 3 - packages/mui-material/src/styles/overrides.ts | 5 +- packages/mui-material/src/styles/props.ts | 10 +- 33 files changed, 735 insertions(+), 958 deletions(-) rename packages/mui-material/src/{Unstable_Menu2Popup => Unstable_Menu2}/Menu2Popup.tsx (98%) rename packages/mui-material/src/{Unstable_Menu2SubmenuPopup => Unstable_Menu2}/Menu2SubmenuPopup.tsx (98%) rename packages/mui-material/src/{Unstable_Menu2SubmenuRoot => Unstable_Menu2}/Menu2SubmenuRoot.tsx (100%) rename packages/mui-material/src/{Unstable_Menu2SubmenuTrigger => Unstable_Menu2}/Menu2SubmenuTrigger.tsx (97%) rename packages/mui-material/src/{Unstable_Menu2Trigger => Unstable_Menu2}/Menu2Trigger.tsx (99%) delete mode 100644 packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx delete mode 100644 packages/mui-material/src/Unstable_Menu2Popup/index.d.ts delete mode 100644 packages/mui-material/src/Unstable_Menu2Popup/index.js delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts delete mode 100644 packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js delete mode 100644 packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx delete mode 100644 packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts delete mode 100644 packages/mui-material/src/Unstable_Menu2Trigger/index.js diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index f14aa99e961f5f..e3e761ed2278a6 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -27,18 +27,14 @@ import Menu2Group from '@mui/material/Unstable_Menu2Group'; import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; -import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; -import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; -import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; type MenuProps = React.ComponentProps; -type PopupProps = React.ComponentProps; +type PopupProps = MenuProps; type PopupSide = NonNullable; type PopupAlign = NonNullable; @@ -158,91 +154,101 @@ function PlaygroundDemo({ highlightItemOnHover={settings.highlightItemOnHover} onOpenChange={handleOpenChange} onOpenChangeComplete={handleOpenChangeComplete} + trigger="Project" + slotProps={{ + trigger: { + variant: 'contained', + openOnHover: settings.triggerOpenOnHover, + endIcon: , + }, + }} + side={settings.side} + align={settings.align} + sideOffset={settings.sideOffset} + alignOffset={settings.alignOffset} + keepMounted={settings.keepMounted} + {...popupKnobProps} > - } - > - Project - - - - Actions - - New file - - - Duplicate - - - Archive (disabled) - - - + + Actions + + New file + + + Duplicate + + + Archive (disabled) + + + - - + Share - - - - Email - - - Copy link - - - - Export as - - - - - - PDF document - - - EPUB publication - - - Markdown - - - - - - - - - + + } + slotProps={{ trigger: submenuTriggerProps }} + {...submenuPopupProps} + > + + Email + + + Copy link + + + Export as + + + } + slotProps={{ trigger: submenuTriggerProps }} + {...submenuPopupProps} + > + + + PDF document + + + EPUB publication + + + Markdown + + + + + + View - - - - Show ruler - - Show outline - - - - - - Selected item (visual-only) - - - Menu documentation - - + + } + slotProps={{ trigger: submenuTriggerProps }} + {...submenuPopupProps} + > + + Show ruler + + Show outline + + + + + Selected item (visual-only) + + + Menu documentation + ); } @@ -289,25 +295,26 @@ function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings ))}
- - } - > - Successor - - - {parityItems.map((item) => ( - - {item.label} - - ))} - + , + }, + }} + {...popupKnobProps} + > + {parityItems.map((item) => ( + + {item.label} + + ))} ); @@ -332,12 +339,15 @@ function ControlledAnchorDemo() { > Open (controlled) - - - setAnchorEl(null)}>Profile - setAnchorEl(null)}>My account - setAnchorEl(null)}>Logout - + + setAnchorEl(null)}>Profile + setAnchorEl(null)}>My account + setAnchorEl(null)}>Logout
); @@ -380,20 +390,22 @@ const typeaheadEntries = [ function TypeaheadScrollDemo() { return ( - - }> - Country - - - {typeaheadEntries.map((entry) => ( - - {entry} - - ))} - + , + }, + paper: { sx: { maxHeight: 320, overflow: 'auto' } }, + }} + sideOffset={4} + > + {typeaheadEntries.map((entry) => ( + + {entry} + + ))} ); } diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index 9f4f91340c8f45..786dbed2c17b96 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -17,14 +17,10 @@ import Menu2Group from '@mui/material/Unstable_Menu2Group'; import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; -import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; -import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; -import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; interface MenuSettings { @@ -273,41 +269,49 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove setAnchorEl(null); } }} + trigger="Help cards" + slotProps={{ + trigger: { + variant: 'contained', + endIcon: , + }, + }} + sideOffset={8} > - }> - Help cards - - - - {rootPreviewCardItems[0].label} - - - + + {rootPreviewCardItems[0].label} + + Version history - - - {versionHistoryPreviewCardItems.map((item) => ( - - {item.label} - - ))} - - - - {rootPreviewCardItems[1].label} - - + + } + slotProps={{ + trigger: { + openOnHover: submenusOpenOnHover, + onFocus: clearActiveItem, + onMouseEnter: clearActiveItem, + }, + }} + sideOffset={8} + > + {versionHistoryPreviewCardItems.map((item) => ( + + {item.label} + + ))} + + + {rootPreviewCardItems[1].label} + ); @@ -320,92 +324,111 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { }, []); return ( - - }> - File - - - New document - Open… - Template gallery - Recent documents - Docs help center - Make a copy + , + }, + }} + sideOffset={8} + > + New document + Open… + Template gallery + Recent documents + Docs help center + Make a copy + + + Rename document + + + Offline editing unavailable + + + + + View options + + + } + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} + sideOffset={8} + > + + Document display + + 100% + Fit + Page width + + Custom zoom unavailable + + + + - - Rename document - - - Offline editing unavailable - + + + Show + Ruler + Document outline + Line numbers + Page breaks unavailable + + - - - View options - - - - - Document display - - 100% - Fit - Page width - - Custom zoom unavailable - - - - - - - - Show - Ruler - Document outline - Line numbers - Page breaks unavailable - - - - - - - More tools - - - - Word count - Dictionary - Accessibility settings - - - - - - - + + More tools + + + } + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} + sideOffset={8} + > + Word count + Dictionary + Accessibility settings + + + + Download - - - Microsoft Word (.docx) - PDF document (.pdf) - Plain text (.txt) - - - - - + + } + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} + sideOffset={8} + > + Microsoft Word (.docx) + PDF document (.pdf) + Plain text (.txt) + + + Add-ons unavailable - - - Marketplace - - - + + } + slotProps={{ trigger: { disabled: true, openOnHover: settings.submenusOpenOnHover } }} + sideOffset={8} + > + Marketplace + ); } @@ -433,65 +456,89 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b [direction], ); + // The trigger is a prop now, so the tooltip around it has to move into the + // trigger's root slot. This is the one composition the collapse made harder. + const TooltipSubmenuTriggerRoot = React.useMemo( + () => + React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { ownerState?: unknown } + >(function TooltipSubmenuTriggerRoot({ ownerState: _ownerState, ...rootProps }, ref) { + return ( + +
+ + ); + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + return ( - - }> - Tools - - - - New document - - - Open recent - - - Make a copy - - - Import from Drive - - - Share with people - + , + }, + }} + sideOffset={8} + > + + New document + + + Open recent + + + Make a copy + + + Import from Drive + + + Share with people + + + + + View options + + + } + slots={{ trigger: TooltipSubmenuTriggerRoot }} + slotProps={{ trigger: { openOnHover: submenusOpenOnHover } }} + sideOffset={8} + > + + Show + + Comments + + + Page breaks + + + - - - - View options - - - - - - Show - - Comments - - - Page breaks - - - - - - - Zoom - - - Fit - - - - Custom - - - - - - - + + Zoom + + + Fit + + + + Custom + + + + + ); } @@ -567,13 +614,17 @@ function Menu2ContextMenuRecipe() { facilisis neque enim sed neque. Quisque accumsan metus vel maximus consequat. Suspendisse lacinia tellus a libero volutpat maximus. - - - Copy - Print - Highlight - Email - + + Copy + Print + Highlight + Email
); diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx index 667f050208a096..fb871ea42c80dd 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -7,15 +7,11 @@ import Menu2Group from '@mui/material/Unstable_Menu2Group'; import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; -import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; -import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; -import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; import { createTheme } from '@mui/material/styles'; // @ts-expect-error Menu2 is intentionally not exported from the root barrel for this POC. import { Menu2 as RootBarrelMenu2 } from '@mui/material'; @@ -30,75 +26,70 @@ function Menu2Composition() { eventDetails.cancel(); eventDetails.preventUnmountOnClose(); }} + trigger={} + anchor={null} + side="bottom" + align="start" + sideOffset={4} + collisionPadding={8} + keepMounted + finalFocus + slots={{ + trigger: 'button', + portal: 'div', + positioner: 'div', + popup: 'div', + paper: 'div', + list: 'div', + }} + slotProps={{ + trigger: { openOnHover: true, delay: 100 }, + paper: { elevation: 4 }, + list: { 'data-testid': 'list' }, + }} > - - Options - - - - Menu2Group - - Menu2Item - - Profile - { - expectType(event); - expectType(checked); - eventDetails.cancel(); - }} - > - Checkbox - - { - expectType(event); - expectType(value); - eventDetails.cancel(); - }} - > - - One - - - - { - expectType(open); - eventDetails.cancel(); - }} - > - - More - - - Nested - - - - + + Menu2Group + + Menu2Item + + Profile + { + expectType(event); + expectType(checked); + eventDetails.cancel(); + }} + > + Checkbox + + { + expectType(event); + expectType(value); + eventDetails.cancel(); + }} + > + + One + + + + { + expectType(open); + eventDetails.cancel(); + }} + trigger="More" + sideOffset={2} + slotProps={{ trigger: { openOnHover: true, nativeButton: false } }} + > + Nested + + ); } @@ -110,7 +101,7 @@ createTheme({ modal: false, }, }, - MuiMenu2SubmenuRoot: { + MuiMenu2Submenu: { defaultProps: { defaultOpen: false, }, @@ -165,33 +156,24 @@ createTheme({ }, }); -; -; -} -> - Options -; - - - Options -; +/>; -', () => { @@ -45,11 +39,8 @@ describe('', () => { it('opens from the trigger and keeps Menu.Popup as the semantic menu root', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -72,11 +63,8 @@ describe('', () => { it('does not render the trigger as a link when href is passed by a JS caller', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -95,11 +83,12 @@ describe('', () => { it('does not pass ownerState to host popup slots', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -113,29 +102,21 @@ describe('', () => { try { const { user } = render( - - Options - - - Native item - - - Native checkbox - - - - Native radio - - - - - Native submenu trigger - - - Nested - - - + + + Native item + + + Native checkbox + + + + Native radio + + + + Nested + , ); @@ -186,23 +167,21 @@ describe('', () => { try { const { user } = render( - - - Options - - - - Custom native item - - - - Custom native submenu trigger - - - Nested - - - + + + Custom native item + + + Nested + , ); @@ -229,29 +208,27 @@ describe('', () => { it('does not pass internal props to host paper and list slots', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -273,11 +250,8 @@ describe('', () => { it('defaults the popup surface elevation to 8', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -288,11 +262,8 @@ describe('', () => { it('forwards a custom elevation to the popup surface', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -303,11 +274,8 @@ describe('', () => { it.skipIf(isJsdom())('animates the popup surface by default', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -349,21 +317,15 @@ describe('', () => { async () => { const { user } = render( - - Options - - Profile - - {/* The indicator only mounts while checked. */} - Bookmarks - - - View - - Zoom in - - - + + Profile + + {/* The indicator only mounts while checked. */} + Bookmarks + + + Zoom in + , ); @@ -402,11 +364,8 @@ describe('', () => { it.skipIf(isJsdom())('lets the default animation be overridden', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -418,11 +377,8 @@ describe('', () => { it('renders an invisible backdrop that does not swallow clicks', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -439,15 +395,13 @@ describe('', () => { it('supports dimming through the backdrop slot', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -459,11 +413,8 @@ describe('', () => { it.skipIf(isJsdom())('constrains the popup surface to the collision-aware height', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -488,11 +439,8 @@ describe('', () => { }); const { user } = render( - - Options - - Profile - + + Profile , ); @@ -506,11 +454,8 @@ describe('', () => { it('does not open when the root is disabled', async () => { render( - - Options - - Profile - + + Profile , ); @@ -520,11 +465,8 @@ describe('', () => { it('supports defaultOpen', () => { render( - - Options - - Profile - + + Profile , ); @@ -533,11 +475,8 @@ describe('', () => { it('supports keepMounted', () => { render( - - Options - - Profile - + + Profile , ); @@ -555,11 +494,8 @@ describe('', () => { - - Options - - Profile - + + Profile , ); @@ -576,11 +512,8 @@ describe('', () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -606,11 +539,8 @@ describe('', () => { it('supports touch trigger interactions', async () => { const { user } = render( - - Options - - Profile - + + Profile , ); @@ -625,17 +555,19 @@ describe('', () => { it('supports modal backdrop behavior', async () => { const { user } = render( - - Modal menu - - Profile - + + Profile - - Non-modal menu - - Settings - + + Settings , ); @@ -660,11 +592,8 @@ describe('', () => { it('opens in an RTL tree', async () => { const { user } = render(
- - Options - - Profile - + + Profile
, ); @@ -677,16 +606,14 @@ describe('', () => { it.skipIf(isJsdom())('applies Base UI positioning attributes in the browser', async () => { const { user } = render(
- - Options - - Profile - + + Profile
, ); @@ -712,15 +639,12 @@ describe('', () => { }); const { user } = render( - - Options - - Show hidden files - - Small - Large - - + + Show hidden files + + Small + Large + , ); @@ -755,27 +679,24 @@ describe('', () => { it('keeps mounted unchecked indicator marks hidden', () => { render( - - Options - - - Show hidden files - - - - Small - - - Large - - - + + + Show hidden files + + + + Small + + + Large + + , ); @@ -815,21 +736,15 @@ describe('', () => { it('supports groups, labels, separators, link items, and submenus', async () => { const { user } = render( - - Options - - - Account - Profile - - - - More - - Archive - - - + + + Account + Profile + + + + Archive + , ); @@ -873,10 +788,11 @@ describe('', () => { setAnchor(null); } }} + anchor={anchor ?? undefined} + positionMethod="fixed" + finalFocus={areaRef} > - - Copy - + Copy ); @@ -884,11 +800,8 @@ describe('', () => { const { user } = render( - - Other menu - - Other item - + + Other item , @@ -920,19 +833,16 @@ describe('', () => { it.skipIf(isJsdom())('supports inset list text composed inside items', async () => { const { user } = render( - - Options - - - i - Cut - - - - Paste - - - + + + i + Cut + + + + Paste + + , ); @@ -947,18 +857,12 @@ describe('', () => { it.skipIf(isJsdom())('keeps separator spacing stable while a submenu is open', async () => { const { user } = render( - - Options - - - View - - Zoom - - - - After - + + + Zoom + + + After , ); @@ -975,21 +879,18 @@ describe('', () => { it('supports Material UI Tooltip on enabled item flavors', async () => { const { user } = render( - - Options - - - New document - - - Comments + + + New document + + + Comments + + + + Fit - - - Fit - - - + , ); @@ -1044,19 +945,29 @@ describe('', () => { ); } + // The compound shape wrapped the trigger element directly. With the trigger + // as a prop the wrapper has to move into the trigger's root slot, which is + // the one composition the collapse made harder. + const TooltipSubmenuTriggerRoot = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { ownerState?: unknown } + >(function TooltipSubmenuTriggerRoot({ ownerState: _ownerState, ...rootProps }, ref) { + return ( + +
+ + ); + }); + const { user } = render( - - Options - - - - View options - - - Comments - - - + + + Comments + , ); @@ -1075,15 +986,12 @@ describe('', () => { it('supports Material UI Tooltip on disabled items through a non-disabled wrapper', async () => { const { user } = render( - - Options - - - - Import from Drive - - - + + + + Import from Drive + + , ); @@ -1103,16 +1011,10 @@ describe('', () => { try { const { user } = render( - - Options - - - Add-ons unavailable - - Marketplace - - - + + + Marketplace + , ); diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 662067bb1b9c43..b80cfff0b29529 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -4,8 +4,8 @@ import PropTypes from 'prop-types'; import clsx from 'clsx'; import resolveComponentProps from '@mui/utils/resolveComponentProps'; import { Menu as BaseMenu } from '@base-ui/react/menu'; -import Menu2Popup, { Menu2PopupProps } from '../Unstable_Menu2Popup'; -import Menu2Trigger from '../Unstable_Menu2Trigger'; +import Menu2Popup, { Menu2PopupProps } from './Menu2Popup'; +import Menu2Trigger from './Menu2Trigger'; import { useDefaultProps } from '../DefaultPropsProvider'; import { menu2TriggerClasses } from './menu2Classes'; import { SlotProps } from './menu2Utils'; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx index af3d3c7031ddd5..0148fbca9276ba 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -5,10 +5,8 @@ import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; -import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; /** * Behavior benchmark: the classic `Menu` against the Base UI-backed successor, @@ -50,13 +48,10 @@ function Menu2Harness(props: { withSelected?: boolean }) { return (
- - Options - - Alpha - Beta - Gamma - + + Alpha + Beta + Gamma

sibling content

@@ -140,14 +135,11 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { it('the successor highlights the first item, not the checked one', async () => { const { user } = render( - - Options - - - 100% - 200% - - + + + 100% + 200% + , ); openTrigger().focus(); diff --git a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx similarity index 98% rename from packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx index d224e4418f38ed..1210b6552c1b86 100644 --- a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx @@ -15,13 +15,13 @@ import { Menu2PopupPublicProps, Menu2PopupSharedProps, Menu2PopupSharedSlotProps, -} from '../Unstable_Menu2/menu2PopupShared'; +} from './menu2PopupShared'; import { menu2PopupListStyles, menu2PopupPaperStyles, menu2PopupTransitionStyles, -} from '../Unstable_Menu2/menu2SharedStyles'; -import { getMenu2PopupUtilityClass, Menu2PopupClasses } from '../Unstable_Menu2/menu2Classes'; +} from './menu2SharedStyles'; +import { getMenu2PopupUtilityClass, Menu2PopupClasses } from './menu2Classes'; export interface Menu2PopupProps extends Omit< Menu2PopupSharedProps, diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx similarity index 98% rename from packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx index 540b1268037129..9d943b0f73a3b2 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx @@ -14,16 +14,16 @@ import { Menu2PopupPublicProps, Menu2PopupSharedProps, Menu2PopupSharedSlotProps, -} from '../Unstable_Menu2/menu2PopupShared'; +} from './menu2PopupShared'; import { menu2PopupListStyles, menu2PopupPaperStyles, menu2PopupTransitionStyles, -} from '../Unstable_Menu2/menu2SharedStyles'; +} from './menu2SharedStyles'; import { getMenu2SubmenuPopupUtilityClass, Menu2SubmenuPopupClasses, -} from '../Unstable_Menu2/menu2Classes'; +} from './menu2Classes'; export interface Menu2SubmenuPopupProps extends Omit< Menu2PopupSharedProps, diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx similarity index 100% rename from packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx similarity index 97% rename from packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx index 148b0fbdc73272..b77f0b580bacff 100644 --- a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx @@ -8,12 +8,12 @@ import ListContext from '../List/ListContext'; import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; -import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { getMenu2ItemStyles } from './menu2SharedStyles'; import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps, -} from '../Unstable_Menu2/menu2Utils'; +} from './menu2Utils'; import { getMenu2ItemClassName, getMenu2ItemOwnerState, @@ -22,12 +22,12 @@ import { Menu2SubmenuTriggerBaseProps, menu2ItemOverridesResolver, useMenu2ItemUtilityClasses, -} from '../Unstable_Menu2/menu2ItemShared'; +} from './menu2ItemShared'; import { getMenu2SubmenuTriggerUtilityClass, menu2SubmenuTriggerClasses, Menu2SubmenuTriggerClasses, -} from '../Unstable_Menu2/menu2Classes'; +} from './menu2Classes'; export interface Menu2SubmenuTriggerSlots { /** diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx similarity index 99% rename from packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.tsx rename to packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx index d7da32c886cc34..1d8541ebf5da42 100644 --- a/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx @@ -14,8 +14,8 @@ import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps, -} from '../Unstable_Menu2/menu2Utils'; -import { getMenu2TriggerUtilityClass, Menu2TriggerClasses } from '../Unstable_Menu2/menu2Classes'; +} from './menu2Utils'; +import { getMenu2TriggerUtilityClass, Menu2TriggerClasses } from './menu2Classes'; export interface Menu2TriggerSlots { /** diff --git a/packages/mui-material/src/Unstable_Menu2/index.d.ts b/packages/mui-material/src/Unstable_Menu2/index.d.ts index 23dd0c131828d4..c79b08f2d3f201 100644 --- a/packages/mui-material/src/Unstable_Menu2/index.d.ts +++ b/packages/mui-material/src/Unstable_Menu2/index.d.ts @@ -1,3 +1,17 @@ export { default } from './Menu2'; export { default as Unstable_Menu2 } from './Menu2'; export * from './Menu2'; +// The trigger and popup are rendered by Menu2 itself; only their style hooks +// are public, for `styleOverrides` and `sx`. +export { + menu2TriggerClasses, + getMenu2TriggerUtilityClass, + menu2PopupClasses, + getMenu2PopupUtilityClass, +} from './menu2Classes'; +export type { + Menu2TriggerClasses, + Menu2TriggerClassKey, + Menu2PopupClasses, + Menu2PopupClassKey, +} from './menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2/index.js b/packages/mui-material/src/Unstable_Menu2/index.js index 23dd0c131828d4..81445884860d75 100644 --- a/packages/mui-material/src/Unstable_Menu2/index.js +++ b/packages/mui-material/src/Unstable_Menu2/index.js @@ -1,3 +1,11 @@ export { default } from './Menu2'; export { default as Unstable_Menu2 } from './Menu2'; export * from './Menu2'; +// The trigger and popup are rendered by Menu2 itself; only their style hooks +// are public, for `styleOverrides` and `sx`. +export { + menu2TriggerClasses, + getMenu2TriggerUtilityClass, + menu2PopupClasses, + getMenu2PopupUtilityClass, +} from './menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx deleted file mode 100644 index 121098a0961898..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react'; -import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; -import Unstable_Menu2Popup, { - menu2PopupClasses as classes, -} from '@mui/material/Unstable_Menu2Popup'; -import describeConformance from '../../test/describeConformance'; - -describe('', () => { - const { render } = createRenderer(); - - describeConformance( - document.body}> - Item - , - () => ({ - classes, - render: (node) => { - const { container, ...other } = render( - - {node} - , - ); - // The popup renders in a portal surrounded by Base UI focus-guard - // spans, so no real parent has it as firstChild; hand the harness a - // container satisfying its firstChild contract. - const popup = document.querySelector('[role="menu"]')!; - return { ...other, container: { firstChild: popup } as unknown as HTMLElement }; - }, - // The popup root is the Base UI Popup element; swapping the host goes - // through slots.popup rather than the component prop. - skip: ['componentProp'], - refInstanceof: window.HTMLDivElement, - muiName: 'MuiMenu2Popup', - testVariantProps: { align: 'center' }, - slots: { - paper: { - expectedClassName: classes.paper, - }, - list: { - expectedClassName: classes.list, - }, - }, - }), - ); -}); diff --git a/packages/mui-material/src/Unstable_Menu2Popup/index.d.ts b/packages/mui-material/src/Unstable_Menu2Popup/index.d.ts deleted file mode 100644 index fd40324bf82c76..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Popup/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default } from './Menu2Popup'; -export * from './Menu2Popup'; -export { menu2PopupClasses, getMenu2PopupUtilityClass } from '../Unstable_Menu2/menu2Classes'; -export type { Menu2PopupClasses, Menu2PopupClassKey } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Popup/index.js b/packages/mui-material/src/Unstable_Menu2Popup/index.js deleted file mode 100644 index f3e4e588fc00a7..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Popup/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Menu2Popup'; -export * from './Menu2Popup'; -export { menu2PopupClasses, getMenu2PopupUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index 6eb6f731dfadf8..bd8d364ed08078 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -2,11 +2,9 @@ import * as React from 'react'; import { expect } from 'chai'; import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; import Button from '@mui/material/Button'; -import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2, { menu2PopupClasses, menu2TriggerClasses } from '@mui/material/Unstable_Menu2'; import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; -import { menu2PopupClasses } from '@mui/material/Unstable_Menu2Popup'; -import { menu2TriggerClasses } from '@mui/material/Unstable_Menu2Trigger'; // The collapsed shape: one component per menu at both levels, trigger as a // prop, children as the popup. diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index 4b92f8126bd11d..a082e691859e70 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -3,8 +3,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import resolveComponentProps from '@mui/utils/resolveComponentProps'; import { Menu as BaseMenu } from '@base-ui/react/menu'; -import Menu2SubmenuPopup, { Menu2SubmenuPopupProps } from '../Unstable_Menu2SubmenuPopup'; -import Menu2SubmenuTrigger from '../Unstable_Menu2SubmenuTrigger'; +import Menu2SubmenuPopup, { Menu2SubmenuPopupProps } from '../Unstable_Menu2/Menu2SubmenuPopup'; +import Menu2SubmenuTrigger from '../Unstable_Menu2/Menu2SubmenuTrigger'; import { useDefaultProps } from '../DefaultPropsProvider'; import { SlotProps } from '../Unstable_Menu2/menu2Utils'; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts b/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts index c37bb3b5049376..ec8e07d87122f0 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts +++ b/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts @@ -1,2 +1,15 @@ export { default } from './Menu2Submenu'; export * from './Menu2Submenu'; +// Rendered by Menu2Submenu itself; only the style hooks are public. +export { + menu2SubmenuTriggerClasses, + getMenu2SubmenuTriggerUtilityClass, + menu2SubmenuPopupClasses, + getMenu2SubmenuPopupUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2SubmenuTriggerClasses, + Menu2SubmenuTriggerClassKey, + Menu2SubmenuPopupClasses, + Menu2SubmenuPopupClassKey, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.js b/packages/mui-material/src/Unstable_Menu2Submenu/index.js index c37bb3b5049376..97baaa48d9deca 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/index.js +++ b/packages/mui-material/src/Unstable_Menu2Submenu/index.js @@ -1,2 +1,9 @@ export { default } from './Menu2Submenu'; export * from './Menu2Submenu'; +// Rendered by Menu2Submenu itself; only the style hooks are public. +export { + menu2SubmenuTriggerClasses, + getMenu2SubmenuTriggerUtilityClass, + menu2SubmenuPopupClasses, + getMenu2SubmenuPopupUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx deleted file mode 100644 index 1e6c986d8d30f2..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react'; -import { createRenderer, isJsdom } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; -import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Unstable_Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import Unstable_Menu2SubmenuPopup, { - menu2SubmenuPopupClasses as classes, -} from '@mui/material/Unstable_Menu2SubmenuPopup'; -import describeConformance from '../../test/describeConformance'; -import withPortalledRoot from '../../test/menu2Conformance'; - -// Base UI submenus need layout to open, which jsdom does not provide, so the -// nested popup never mounts there; run this suite in the browser project. -describe.skipIf(isJsdom())('', () => { - const { render } = createRenderer(); - - describeConformance( - - Nested - , - () => ({ - classes, - render: (node) => - withPortalledRoot( - render( - - - More - {node} - - , - ), - `.${classes.root}`, - ), - // The popup root is the Base UI Popup element; swapping the host goes - // through slots.popup rather than the component prop. - skip: ['componentProp'], - refInstanceof: window.HTMLDivElement, - muiName: 'MuiMenu2SubmenuPopup', - testVariantProps: { align: 'center' }, - slots: { - paper: { - expectedClassName: classes.paper, - }, - list: { - expectedClassName: classes.list, - }, - }, - }), - ); -}); diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts deleted file mode 100644 index 9a231c449df2f2..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './Menu2SubmenuPopup'; -export * from './Menu2SubmenuPopup'; -export { - menu2SubmenuPopupClasses, - getMenu2SubmenuPopupUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; -export type { - Menu2SubmenuPopupClasses, - Menu2SubmenuPopupClassKey, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js deleted file mode 100644 index 3469ac08b3ed0f..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2SubmenuPopup'; -export * from './Menu2SubmenuPopup'; -export { - menu2SubmenuPopupClasses, - getMenu2SubmenuPopupUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts deleted file mode 100644 index 5821f3d3212344..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './Menu2SubmenuRoot'; -export * from './Menu2SubmenuRoot'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js deleted file mode 100644 index 5821f3d3212344..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './Menu2SubmenuRoot'; -export * from './Menu2SubmenuRoot'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx deleted file mode 100644 index 8631903acc6fd6..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; -import Unstable_Menu2SubmenuTrigger, { - menu2SubmenuTriggerClasses as classes, -} from '@mui/material/Unstable_Menu2SubmenuTrigger'; -import describeConformance from '../../test/describeConformance'; -import withPortalledRoot from '../../test/menu2Conformance'; - -describe('', () => { - const { render } = createRenderer(); - - describeConformance(More, () => ({ - classes, - render: (node) => - withPortalledRoot( - render( - - {node} - , - ), - `.${classes.root}`, - ), - refInstanceof: window.HTMLDivElement, - testComponentPropWith: 'span', - muiName: 'MuiMenu2SubmenuTrigger', - testVariantProps: { dense: true }, - })); -}); diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts deleted file mode 100644 index f80db63dd82338..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { default } from './Menu2SubmenuTrigger'; -export * from './Menu2SubmenuTrigger'; -export { - menu2SubmenuTriggerClasses, - getMenu2SubmenuTriggerUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; -export type { - Menu2SubmenuTriggerClasses, - Menu2SubmenuTriggerClassKey, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js deleted file mode 100644 index e624188f7f5cc2..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2SubmenuTrigger'; -export * from './Menu2SubmenuTrigger'; -export { - menu2SubmenuTriggerClasses, - getMenu2SubmenuTriggerUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx b/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx deleted file mode 100644 index f3ce7116a3a501..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.test.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Trigger, { - menu2TriggerClasses as classes, -} from '@mui/material/Unstable_Menu2Trigger'; -import describeConformance from '../../test/describeConformance'; - -describe('', () => { - const { render } = createRenderer(); - - describeConformance(Options, () => ({ - classes, - render: (node) => render({node}), - refInstanceof: window.HTMLButtonElement, - testComponentPropWith: 'a', - muiName: 'MuiMenu2Trigger', - testVariantProps: { disabled: true }, - })); -}); diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts b/packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts deleted file mode 100644 index da738f98abc084..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default } from './Menu2Trigger'; -export * from './Menu2Trigger'; -export { menu2TriggerClasses, getMenu2TriggerUtilityClass } from '../Unstable_Menu2/menu2Classes'; -export type { Menu2TriggerClasses, Menu2TriggerClassKey } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/index.js b/packages/mui-material/src/Unstable_Menu2Trigger/index.js deleted file mode 100644 index f7afa354ca2cb7..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Trigger/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Menu2Trigger'; -export * from './Menu2Trigger'; -export { menu2TriggerClasses, getMenu2TriggerUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/styles/overrides.ts b/packages/mui-material/src/styles/overrides.ts index e72ab84d71adc8..7f29c08f433cad 100644 --- a/packages/mui-material/src/styles/overrides.ts +++ b/packages/mui-material/src/styles/overrides.ts @@ -73,14 +73,11 @@ import { Menu2GroupClassKey } from '../Unstable_Menu2Group'; import { Menu2GroupLabelClassKey } from '../Unstable_Menu2GroupLabel'; import { Menu2ItemClassKey } from '../Unstable_Menu2Item'; import { Menu2LinkItemClassKey } from '../Unstable_Menu2LinkItem'; -import { Menu2PopupClassKey } from '../Unstable_Menu2Popup'; +import { Menu2PopupClassKey , Menu2SubmenuPopupClassKey , Menu2SubmenuTriggerClassKey , Menu2TriggerClassKey } from '../Unstable_Menu2/menu2Classes'; import { Menu2RadioGroupClassKey } from '../Unstable_Menu2RadioGroup'; import { Menu2RadioItemClassKey } from '../Unstable_Menu2RadioItem'; import { Menu2RadioItemIndicatorClassKey } from '../Unstable_Menu2RadioItemIndicator'; import { Menu2SeparatorClassKey } from '../Unstable_Menu2Separator'; -import { Menu2SubmenuPopupClassKey } from '../Unstable_Menu2SubmenuPopup'; -import { Menu2SubmenuTriggerClassKey } from '../Unstable_Menu2SubmenuTrigger'; -import { Menu2TriggerClassKey } from '../Unstable_Menu2Trigger'; import { MobileStepperClassKey } from '../MobileStepper'; import { ModalClassKey } from '../Modal'; import { NativeSelectClassKey } from '../NativeSelect'; diff --git a/packages/mui-material/src/styles/props.ts b/packages/mui-material/src/styles/props.ts index ae71534d6e6601..bc2b2112db4021 100644 --- a/packages/mui-material/src/styles/props.ts +++ b/packages/mui-material/src/styles/props.ts @@ -71,16 +71,16 @@ import { Menu2GroupProps } from '../Unstable_Menu2Group'; import { Menu2GroupLabelProps } from '../Unstable_Menu2GroupLabel'; import { Menu2ItemProps } from '../Unstable_Menu2Item'; import { Menu2LinkItemProps } from '../Unstable_Menu2LinkItem'; -import { Menu2PopupProps } from '../Unstable_Menu2Popup'; +import { Menu2PopupProps } from '../Unstable_Menu2/Menu2Popup'; import { Menu2RadioGroupProps } from '../Unstable_Menu2RadioGroup'; import { Menu2RadioItemProps } from '../Unstable_Menu2RadioItem'; import { Menu2RadioItemIndicatorProps } from '../Unstable_Menu2RadioItemIndicator'; import { Menu2SeparatorProps } from '../Unstable_Menu2Separator'; -import { Menu2SubmenuPopupProps } from '../Unstable_Menu2SubmenuPopup'; +import { Menu2SubmenuPopupProps } from '../Unstable_Menu2/Menu2SubmenuPopup'; import { Menu2SubmenuProps } from '../Unstable_Menu2Submenu'; -import { Menu2SubmenuRootProps } from '../Unstable_Menu2SubmenuRoot'; -import { Menu2SubmenuTriggerProps } from '../Unstable_Menu2SubmenuTrigger'; -import { Menu2TriggerProps } from '../Unstable_Menu2Trigger'; +import { Menu2SubmenuRootProps } from '../Unstable_Menu2/Menu2SubmenuRoot'; +import { Menu2SubmenuTriggerProps } from '../Unstable_Menu2/Menu2SubmenuTrigger'; +import { Menu2TriggerProps } from '../Unstable_Menu2/Menu2Trigger'; import { MenuProps } from '../Menu'; import { MobileStepperProps } from '../MobileStepper'; import { ModalProps } from '../Modal'; From 64f8d61ccd95b9832411ffbacb640ad4ec043997 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 3 Aug 2026 11:12:33 +0300 Subject: [PATCH 40/62] Record the settled API shape in the RFC The API section still showed a compound strawman and called the shape undecided. It now shows what was built, and names the three things that only surfaced by building it: the root trigger takes an element while the submenu trigger takes content, and wrapping a trigger in a Tooltip now costs a forwardRef component. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index e8f4ef0337c09c..c7f636ff9c3afb 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -120,7 +120,7 @@ Still up for discussion: - **Prop types:** extend Base UI's types and `Omit` what we hide or rename, so we inherit new props automatically. This means callbacks keep Base UI signatures, for example `onOpenChange(open, eventDetails)` instead of `onClose(event, reason)`. It works in the experiment, with one limitation: our proptypes generator cannot read types from `node_modules`, so runtime PropTypes only cover locally declared props. Types are unaffected, and PropTypes are stripped in production. Teaching the generator to follow external types is a separate infra task. - **Testing:** reuse `describeConformance` for the Material UI contract, and rerun the existing Menu behavior tests against the successor, annotating every skip. Parity should be shown by those suites passing, not by new tests written for the successor. All 14 rendering parts now run conformance, which let us delete the hand-written theming and slots tests. Two adaptations may belong in the shared harness: portalled roots need a way to point the harness at the real root element, and the nested submenu popup only mounts with real layout, so its suite runs in the browser project only. -### API shape (needs a design phase) +### API shape (settled by review) Agreed rules, instead of one global flat-vs-compound choice: @@ -159,26 +159,31 @@ This is deliberate upstream, not an oversight. `initialFocus` exists on Base UI For the API shape this means a flat container really can carry today's surface, and the migration story is "same component, a few documented behavior changes" rather than a rewrite. -A sketch, not a proposal. Container boundaries and the submenu shape are exactly what the design phase decides; the experiment's fully compound API is the other end of the range. +Settled by review. The experiment started fully compound, one component per Base UI part. It is now one component per menu, at both levels: the root carries the trigger and the popup surface, and a submenu is the same shape one level down. ```jsx Options} slotProps={{ paper: { elevation: 4 } }} > Cut - {/* strawman: submenu as a nested container owned by a standalone trigger item */} - - - Email - Copy link - - + + Email + Copy link + ``` +The trigger and popup parts still exist, but they are internal; only their class hooks are exported, so `styleOverrides` and `sx` are unaffected. + +Three things fell out of building it: + +- The root `trigger` takes an element and Base UI's `render` merges the behavior into it, so the caller keeps their own component. Anything else renders inside the default trigger. +- The submenu `trigger` takes content, not an element. This is the one place the levels cannot match: a submenu trigger is already a menu item, so passing `` nests an item inside an item and the submenu never opens. +- Wrapping a trigger in a `Tooltip` got harder. The compound shape wrapped the element directly; now the wrapper has to move into the trigger's root slot, which costs a `forwardRef` component. The test suite and the recipes page both hit it. + +Omit `trigger` and drive the menu with `open` and `anchor` for the classic controlled pattern, which is what the context-menu recipe uses. + Behavior worth stating whatever shape we pick: - Submenus open on hover by default (100ms delay, hover intent on close). New compared to the classic Menu, matches native menus, and is configurable. From 70dda7f1c1a6dfc3e8d1860ba22b4edde69156e2 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 3 Aug 2026 12:12:16 +0300 Subject: [PATCH 41/62] Use index.ts and drop the Unstable_ prefix from identifiers Two cleanups from review. The index files were index.js plus index.d.ts, which is the pattern for the JS-authored components. Every .tsx-authored one here (ClickAwayListener, Grid, PigmentGrid, DefaultPropsProvider) uses a single index.ts, and Menu2 is all .tsx. The split had already caused the drift it invites: the type exports lived only in the .d.ts half. Test identifiers carried the Unstable_ prefix that belongs on the import path, not the local binding: `import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'`. Only paths, subpaths and exports carry it, per the naming rule in the RFC. The unused `Unstable_Menu2` named re-export goes too, since the default export covers it. Also fixes 8 pre-existing markdown table-alignment lint errors in the RFC. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 21 +++++------ .../mui-material/src/Unstable_Menu2/index.js | 11 ------ .../Unstable_Menu2/{index.d.ts => index.ts} | 1 - .../Menu2CheckboxItem.test.tsx | 10 +++--- .../src/Unstable_Menu2CheckboxItem/index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../Menu2CheckboxItemIndicator.test.tsx | 16 ++++----- .../index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../Unstable_Menu2Group/Menu2Group.test.tsx | 12 +++---- .../src/Unstable_Menu2Group/index.js | 3 -- .../{index.d.ts => index.ts} | 0 .../Menu2GroupLabel.test.tsx | 14 ++++---- .../src/Unstable_Menu2GroupLabel/index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../src/Unstable_Menu2Item/Menu2Item.test.tsx | 10 +++--- .../src/Unstable_Menu2Item/index.js | 3 -- .../{index.d.ts => index.ts} | 0 .../Menu2LinkItem.test.tsx | 35 +++++++++---------- .../src/Unstable_Menu2LinkItem/index.js | 3 -- .../{index.d.ts => index.ts} | 0 .../Menu2RadioGroup.test.tsx | 10 +++--- .../src/Unstable_Menu2RadioGroup/index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../Menu2RadioItem.test.tsx | 14 ++++---- .../src/Unstable_Menu2RadioItem/index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../Menu2RadioItemIndicator.test.tsx | 22 ++++++------ .../Unstable_Menu2RadioItemIndicator/index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../Menu2Separator.test.tsx | 10 +++--- .../src/Unstable_Menu2Separator/index.js | 6 ---- .../{index.d.ts => index.ts} | 0 .../src/Unstable_Menu2Submenu/index.js | 9 ----- .../{index.d.ts => index.ts} | 0 35 files changed, 82 insertions(+), 164 deletions(-) delete mode 100644 packages/mui-material/src/Unstable_Menu2/index.js rename packages/mui-material/src/Unstable_Menu2/{index.d.ts => index.ts} (89%) delete mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js rename packages/mui-material/src/Unstable_Menu2CheckboxItem/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js rename packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2Group/index.js rename packages/mui-material/src/Unstable_Menu2Group/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2GroupLabel/index.js rename packages/mui-material/src/Unstable_Menu2GroupLabel/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2Item/index.js rename packages/mui-material/src/Unstable_Menu2Item/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2LinkItem/index.js rename packages/mui-material/src/Unstable_Menu2LinkItem/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2RadioGroup/index.js rename packages/mui-material/src/Unstable_Menu2RadioGroup/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2RadioItem/index.js rename packages/mui-material/src/Unstable_Menu2RadioItem/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js rename packages/mui-material/src/Unstable_Menu2RadioItemIndicator/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2Separator/index.js rename packages/mui-material/src/Unstable_Menu2Separator/{index.d.ts => index.ts} (100%) delete mode 100644 packages/mui-material/src/Unstable_Menu2Submenu/index.js rename packages/mui-material/src/Unstable_Menu2Submenu/{index.d.ts => index.ts} (100%) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index c7f636ff9c3afb..eac53996639ed6 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -79,7 +79,7 @@ Build a Base UI-based successor to `Menu` and follow the Grid lifecycle. A proo The new component is a successor. It is not a rewrite of the current internals, and not a second namespace that stays forever. | Phase | Component name | What happens | -| --------------- | ---------------- | ------------------------------------------------------------------------------ | +| :-------------- | :--------------- | :----------------------------------------------------------------------------- | | Now (v9 minors) | `Unstable_Menu2` | Public incubation, a real release. Theme keys and classes are `MuiMenu2*`. | | Later in v9 | `Menu2` | Stable under the interim name. Today's `Menu` untouched, theme keys unchanged. | | Next major | `Menu` | `Menu2` becomes the canonical name. | @@ -133,7 +133,7 @@ Before finalizing the shape we needed to know how far the behavior differs. That #### Benchmark results | Dimension | Classic `Menu` | Successor | Verdict | -| -------------------------- | -------------------------------------------------- | ------------------------------------------- | --------------------------- | +| :------------------------- | :------------------------------------------------- | :------------------------------------------ | :-------------------------- | | Opening from the trigger | no trigger part; you wire `onClick` yourself | `Trigger` opens on click and ArrowDown | successor adds behavior | | Initial focus, keyboard | n/a (no trigger part) | first item highlighted | matches the menu pattern | | Initial focus, pointer | selected item, or first item when none is selected | nothing highlighted, focus on the popup | **difference** | @@ -162,10 +162,7 @@ For the API shape this means a flat container really can carry today's surface, Settled by review. The experiment started fully compound, one component per Base UI part. It is now one component per menu, at both levels: the root carries the trigger and the popup surface, and a submenu is the same shape one level down. ```jsx -Options} - slotProps={{ paper: { elevation: 4 } }} -> +Options} slotProps={{ paper: { elevation: 4 } }}> Cut Email @@ -262,7 +259,7 @@ Left: 1. Open and close | Classic Menu | New equivalent | Notes | -| ---------------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| :--------------------------------- | :--------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | | `open` (required, controlled-only) | `open` + `defaultOpen` | uncontrolled is now possible | | `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, `item-press`; can be canceled, and exposes the native event | | n/a | `onOpenChangeComplete(open)` | replaces `onTransitionExited` | @@ -273,7 +270,7 @@ Left: 2. Positioning | Classic Menu / Popover | New equivalent | Notes | -| ----------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------- | +| :---------------------------------------------------- | :------------------------------------------------------------------------------------ | :------------------------------------- | | `anchorEl` | `anchor` | also accepts refs and virtual elements | | `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | finer control | | `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | see open question 6 | @@ -288,7 +285,7 @@ Left: 3. Focus and modality | Classic Menu | New equivalent | Notes | -| ----------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------- | +| :---------------------------------------- | :------------- | :-------------------------------------------------------------------------------------------------------------- | | `autoFocus`, `disableAutoFocusItem` | internal | keyboard opening highlights the first item; pointer opening highlights nothing | | `variant` (`menu`/`selectedMenu`) | dropped | not reproducible on Base UI (see above). Use checkbox or radio items so the current value is at least indicated | | `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` covers this | @@ -305,7 +302,7 @@ Left: 4. Transitions | Classic Menu | New equivalent | -| ------------------------------------------------------------------- | --------------------------------------------------- | +| :------------------------------------------------------------------ | :-------------------------------------------------- | | `TransitionComponent` / `slots.transition` (default `Grow`) | CSS via `data-starting-style` / `data-ending-style` | | `transitionDuration` | CSS `transition-duration` on the popup | | `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | @@ -317,7 +314,7 @@ Left: 5. Styling and slots | Classic Menu | New equivalent | Notes | -| ------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------ | +| :------------------------------------------------------------------ | :------------------------------------------------------------------- | :----------------------------- | | `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list`, `backdrop` | no transition slot (CSS-based) | | `elevation` (default 8) | `elevation` (default 8, forwarded to the Paper slot) | kept | | paper `maxHeight: calc(100% - 96px)` (viewport clamp via the Modal) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | collision-aware | @@ -330,7 +327,7 @@ Left: 6. Item props | Classic MenuItem / MenuList | New equivalent | Notes | -| ------------------------------------------------------------------ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| :----------------------------------------------------------------- | :-------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dense`, `disableGutters`, `divider` | same | kept: we own presentation | | `` between items | `Separator` part | owns its margins, so spacing stays put while a submenu is open | | `selected` | same (visual only) | kept. Checkbox and radio items cover real selection. Classic `MenuItem` now derives `aria-checked` from `selected` for checkbox and radio roles ([#48651](https://github.com/mui/material-ui/pull/48651)); our dedicated items own that instead | diff --git a/packages/mui-material/src/Unstable_Menu2/index.js b/packages/mui-material/src/Unstable_Menu2/index.js deleted file mode 100644 index 81445884860d75..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2/index.js +++ /dev/null @@ -1,11 +0,0 @@ -export { default } from './Menu2'; -export { default as Unstable_Menu2 } from './Menu2'; -export * from './Menu2'; -// The trigger and popup are rendered by Menu2 itself; only their style hooks -// are public, for `styleOverrides` and `sx`. -export { - menu2TriggerClasses, - getMenu2TriggerUtilityClass, - menu2PopupClasses, - getMenu2PopupUtilityClass, -} from './menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2/index.d.ts b/packages/mui-material/src/Unstable_Menu2/index.ts similarity index 89% rename from packages/mui-material/src/Unstable_Menu2/index.d.ts rename to packages/mui-material/src/Unstable_Menu2/index.ts index c79b08f2d3f201..fea5dc2864a122 100644 --- a/packages/mui-material/src/Unstable_Menu2/index.d.ts +++ b/packages/mui-material/src/Unstable_Menu2/index.ts @@ -1,5 +1,4 @@ export { default } from './Menu2'; -export { default as Unstable_Menu2 } from './Menu2'; export * from './Menu2'; // The trigger and popup are rendered by Menu2 itself; only their style hooks // are public, for `styleOverrides` and `sx`. diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx index 9e73846a0fc6cc..c4a1b0afbe3d90 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2CheckboxItem, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem, { menu2CheckboxItemClasses as classes, } from '@mui/material/Unstable_Menu2CheckboxItem'; import describeConformance from '../../test/describeConformance'; @@ -10,14 +10,14 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(Ruler, () => ({ + describeConformance(Ruler, () => ({ classes, render: (node) => withPortalledRoot( render( - + {node} - , + , ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js deleted file mode 100644 index fc33e94bd00adf..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2CheckboxItem'; -export * from './Menu2CheckboxItem'; -export { - menu2CheckboxItemClasses, - getMenu2CheckboxItemUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts rename to packages/mui-material/src/Unstable_Menu2CheckboxItem/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx index 64290417f7545b..ce70fb1b75cfe2 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; -import Unstable_Menu2CheckboxItemIndicator, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +import Menu2CheckboxItemIndicator, { menu2CheckboxItemIndicatorClasses as classes, } from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; import describeConformance from '../../test/describeConformance'; @@ -11,16 +11,14 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(, () => ({ + describeConformance(, () => ({ classes, render: (node) => withPortalledRoot( render( - - - {node}Ruler - - , + + {node}Ruler + , ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js deleted file mode 100644 index 5cdec46f207d32..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2CheckboxItemIndicator'; -export * from './Menu2CheckboxItemIndicator'; -export { - menu2CheckboxItemIndicatorClasses, - getMenu2CheckboxItemIndicatorUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.d.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.d.ts rename to packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx index e446bbb3c9c360..004661448f869e 100644 --- a/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx @@ -1,23 +1,21 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Group, { - menu2GroupClasses as classes, -} from '@mui/material/Unstable_Menu2Group'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Group, { menu2GroupClasses as classes } from '@mui/material/Unstable_Menu2Group'; import describeConformance from '../../test/describeConformance'; import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(Group, () => ({ + describeConformance(Group, () => ({ classes, render: (node) => withPortalledRoot( render( - + {node} - , + , ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2Group/index.js b/packages/mui-material/src/Unstable_Menu2Group/index.js deleted file mode 100644 index f9932fa19a4cbb..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Group/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Menu2Group'; -export * from './Menu2Group'; -export { menu2GroupClasses, getMenu2GroupUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Group/index.d.ts b/packages/mui-material/src/Unstable_Menu2Group/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2Group/index.d.ts rename to packages/mui-material/src/Unstable_Menu2Group/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx index 73476244dc698e..0062ad5128fda5 100644 --- a/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Group from '@mui/material/Unstable_Menu2Group'; -import Unstable_Menu2GroupLabel, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel, { menu2GroupLabelClasses as classes, } from '@mui/material/Unstable_Menu2GroupLabel'; import describeConformance from '../../test/describeConformance'; @@ -11,14 +11,14 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(Section, () => ({ + describeConformance(Section, () => ({ classes, render: (node) => withPortalledRoot( render( - - {node} - , + + {node} + , ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/index.js b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.js deleted file mode 100644 index d76f379d195950..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2GroupLabel/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2GroupLabel'; -export * from './Menu2GroupLabel'; -export { - menu2GroupLabelClasses, - getMenu2GroupLabelUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/index.d.ts b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2GroupLabel/index.d.ts rename to packages/mui-material/src/Unstable_Menu2GroupLabel/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx index 68e0c2fa9fe713..2a013c63ff6b80 100644 --- a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx @@ -1,19 +1,19 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Item, { menu2ItemClasses as classes } from '@mui/material/Unstable_Menu2Item'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Item, { menu2ItemClasses as classes } from '@mui/material/Unstable_Menu2Item'; import describeConformance from '../../test/describeConformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(Item, () => ({ + describeConformance(Item, () => ({ classes, render: (node) => { const { container, ...other } = render( - + {node} - , + , ); // The popup renders in a portal; hand the harness a container whose // firstChild is the item root (the conformance contract). diff --git a/packages/mui-material/src/Unstable_Menu2Item/index.js b/packages/mui-material/src/Unstable_Menu2Item/index.js deleted file mode 100644 index 8113d84f952368..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Item/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Menu2Item'; -export * from './Menu2Item'; -export { menu2ItemClasses, getMenu2ItemUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Item/index.d.ts b/packages/mui-material/src/Unstable_Menu2Item/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2Item/index.d.ts rename to packages/mui-material/src/Unstable_Menu2Item/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx index ecd4d51e98e52b..5f4d8e390ddfa8 100644 --- a/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2LinkItem, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2LinkItem, { menu2LinkItemClasses as classes, } from '@mui/material/Unstable_Menu2LinkItem'; import describeConformance from '../../test/describeConformance'; @@ -10,22 +10,19 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance( - Profile, - () => ({ - classes, - render: (node) => - withPortalledRoot( - render( - - {node} - , - ), - `.${classes.root}`, + describeConformance(Profile, () => ({ + classes, + render: (node) => + withPortalledRoot( + render( + + {node} + , ), - refInstanceof: window.HTMLAnchorElement, - muiName: 'MuiMenu2LinkItem', - testVariantProps: { 'data-variant': 'probe' }, - }), - ); + `.${classes.root}`, + ), + refInstanceof: window.HTMLAnchorElement, + muiName: 'MuiMenu2LinkItem', + testVariantProps: { 'data-variant': 'probe' }, + })); }); diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/index.js b/packages/mui-material/src/Unstable_Menu2LinkItem/index.js deleted file mode 100644 index 2b6de8f99c77b0..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2LinkItem/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { default } from './Menu2LinkItem'; -export * from './Menu2LinkItem'; -export { menu2LinkItemClasses, getMenu2LinkItemUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/index.d.ts b/packages/mui-material/src/Unstable_Menu2LinkItem/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2LinkItem/index.d.ts rename to packages/mui-material/src/Unstable_Menu2LinkItem/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx index 6fe8dabf0dc6e6..2d00a7ce30b5a4 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2RadioGroup, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2RadioGroup, { menu2RadioGroupClasses as classes, } from '@mui/material/Unstable_Menu2RadioGroup'; import describeConformance from '../../test/describeConformance'; @@ -10,14 +10,14 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(Group, () => ({ + describeConformance(Group, () => ({ classes, render: (node) => withPortalledRoot( render( - + {node} - , +
, ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/index.js b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.js deleted file mode 100644 index e340c0c98a0045..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2RadioGroup/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2RadioGroup'; -export * from './Menu2RadioGroup'; -export { - menu2RadioGroupClasses, - getMenu2RadioGroupUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/index.d.ts b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2RadioGroup/index.d.ts rename to packages/mui-material/src/Unstable_Menu2RadioGroup/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx index 37b36fb32abf8a..a34286e05397d8 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; -import Unstable_Menu2RadioItem, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem, { menu2RadioItemClasses as classes, } from '@mui/material/Unstable_Menu2RadioItem'; import describeConformance from '../../test/describeConformance'; @@ -11,14 +11,14 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(One, () => ({ + describeConformance(One, () => ({ classes, render: (node) => withPortalledRoot( render( - - {node} - , + + {node} + , ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/index.js b/packages/mui-material/src/Unstable_Menu2RadioItem/index.js deleted file mode 100644 index fb72894a580328..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2RadioItem/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2RadioItem'; -export * from './Menu2RadioItem'; -export { - menu2RadioItemClasses, - getMenu2RadioItemUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/index.d.ts b/packages/mui-material/src/Unstable_Menu2RadioItem/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2RadioItem/index.d.ts rename to packages/mui-material/src/Unstable_Menu2RadioItem/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx index f3df74814eda8e..4522f2e71199c2 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; -import Unstable_Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; -import Unstable_Menu2RadioItemIndicator, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator, { menu2RadioItemIndicatorClasses as classes, } from '@mui/material/Unstable_Menu2RadioItemIndicator'; import describeConformance from '../../test/describeConformance'; @@ -12,18 +12,18 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(, () => ({ + describeConformance(, () => ({ classes, render: (node) => withPortalledRoot( render( - - - + + + {node}One - - - , + + +
, ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js deleted file mode 100644 index 8e8bc0e1ef77ff..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2RadioItemIndicator'; -export * from './Menu2RadioItemIndicator'; -export { - menu2RadioItemIndicatorClasses, - getMenu2RadioItemIndicatorUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.d.ts b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.d.ts rename to packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx index cdc628d47d09f0..5e5a7451d2df01 100644 --- a/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { createRenderer } from '@mui/internal-test-utils'; -import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; -import Unstable_Menu2Separator, { +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Separator, { menu2SeparatorClasses as classes, } from '@mui/material/Unstable_Menu2Separator'; import describeConformance from '../../test/describeConformance'; @@ -10,14 +10,14 @@ import withPortalledRoot from '../../test/menu2Conformance'; describe('', () => { const { render } = createRenderer(); - describeConformance(, () => ({ + describeConformance(, () => ({ classes, render: (node) => withPortalledRoot( render( - + {node} - , +
, ), `.${classes.root}`, ), diff --git a/packages/mui-material/src/Unstable_Menu2Separator/index.js b/packages/mui-material/src/Unstable_Menu2Separator/index.js deleted file mode 100644 index a331f4d0ee54be..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Separator/index.js +++ /dev/null @@ -1,6 +0,0 @@ -export { default } from './Menu2Separator'; -export * from './Menu2Separator'; -export { - menu2SeparatorClasses, - getMenu2SeparatorUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Separator/index.d.ts b/packages/mui-material/src/Unstable_Menu2Separator/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2Separator/index.d.ts rename to packages/mui-material/src/Unstable_Menu2Separator/index.ts diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.js b/packages/mui-material/src/Unstable_Menu2Submenu/index.js deleted file mode 100644 index 97baaa48d9deca..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2Submenu/index.js +++ /dev/null @@ -1,9 +0,0 @@ -export { default } from './Menu2Submenu'; -export * from './Menu2Submenu'; -// Rendered by Menu2Submenu itself; only the style hooks are public. -export { - menu2SubmenuTriggerClasses, - getMenu2SubmenuTriggerUtilityClass, - menu2SubmenuPopupClasses, - getMenu2SubmenuPopupUtilityClass, -} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts b/packages/mui-material/src/Unstable_Menu2Submenu/index.ts similarity index 100% rename from packages/mui-material/src/Unstable_Menu2Submenu/index.d.ts rename to packages/mui-material/src/Unstable_Menu2Submenu/index.ts From 1b7e08bef82620515125daa9e3d62dce4ebce042 Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 3 Aug 2026 12:44:38 +0300 Subject: [PATCH 42/62] prettier --- packages/mui-material/src/Unstable_Menu2/Menu2.tsx | 3 ++- .../mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx | 5 +---- .../src/Unstable_Menu2/Menu2SubmenuTrigger.tsx | 6 +----- packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx | 6 +----- packages/mui-material/src/styles/overrides.ts | 7 ++++++- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index b80cfff0b29529..89902e6502c46b 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -29,7 +29,8 @@ export interface Menu2SlotProps extends NonNullable, + extends + Omit, Omit { /** * The menu items. diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx index 9d943b0f73a3b2..6a92f47c2d5c58 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx @@ -20,10 +20,7 @@ import { menu2PopupPaperStyles, menu2PopupTransitionStyles, } from './menu2SharedStyles'; -import { - getMenu2SubmenuPopupUtilityClass, - Menu2SubmenuPopupClasses, -} from './menu2Classes'; +import { getMenu2SubmenuPopupUtilityClass, Menu2SubmenuPopupClasses } from './menu2Classes'; export interface Menu2SubmenuPopupProps extends Omit< Menu2PopupSharedProps, diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx index b77f0b580bacff..a36dda2eefa112 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx @@ -9,11 +9,7 @@ import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenu2ItemStyles } from './menu2SharedStyles'; -import { - getMenu2RootRender, - isMenu2RootNativeButton, - Menu2RootSlotProps, -} from './menu2Utils'; +import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps } from './menu2Utils'; import { getMenu2ItemClassName, getMenu2ItemOwnerState, diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx index 1d8541ebf5da42..667b9291f61701 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx @@ -10,11 +10,7 @@ import Button, { ButtonProps } from '../Button'; import { Theme } from '../styles'; import { styled } from '../zero-styled'; import { useDefaultProps } from '../DefaultPropsProvider'; -import { - getMenu2RootRender, - isMenu2RootNativeButton, - Menu2RootSlotProps, -} from './menu2Utils'; +import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps } from './menu2Utils'; import { getMenu2TriggerUtilityClass, Menu2TriggerClasses } from './menu2Classes'; export interface Menu2TriggerSlots { diff --git a/packages/mui-material/src/styles/overrides.ts b/packages/mui-material/src/styles/overrides.ts index 7f29c08f433cad..6b5ac1aa71ffcd 100644 --- a/packages/mui-material/src/styles/overrides.ts +++ b/packages/mui-material/src/styles/overrides.ts @@ -73,7 +73,12 @@ import { Menu2GroupClassKey } from '../Unstable_Menu2Group'; import { Menu2GroupLabelClassKey } from '../Unstable_Menu2GroupLabel'; import { Menu2ItemClassKey } from '../Unstable_Menu2Item'; import { Menu2LinkItemClassKey } from '../Unstable_Menu2LinkItem'; -import { Menu2PopupClassKey , Menu2SubmenuPopupClassKey , Menu2SubmenuTriggerClassKey , Menu2TriggerClassKey } from '../Unstable_Menu2/menu2Classes'; +import { + Menu2PopupClassKey, + Menu2SubmenuPopupClassKey, + Menu2SubmenuTriggerClassKey, + Menu2TriggerClassKey, +} from '../Unstable_Menu2/menu2Classes'; import { Menu2RadioGroupClassKey } from '../Unstable_Menu2RadioGroup'; import { Menu2RadioItemClassKey } from '../Unstable_Menu2RadioItem'; import { Menu2RadioItemIndicatorClassKey } from '../Unstable_Menu2RadioItemIndicator'; From 0c3822214c19a4267eeed142cea70383697d7b7c Mon Sep 17 00:00:00 2001 From: Lukas Date: Mon, 3 Aug 2026 14:07:20 +0300 Subject: [PATCH 43/62] Fix the CI failures: stale proptypes and a mid-animation measurement I only ever ran the Menu2 directories and never `pnpm proptypes` or the browser scope, so both of these reached CI. Proptypes: the hand-written entries for Menu2, Menu2Submenu and the two items were out of date. Regenerated. Typing `slots.indicator` as `ElementType | null` also made the generator emit a 180-line union of every HTML tag, so the null option is gone; the indicator suites suppress the built-in with a component that renders nothing, which needs no extra API surface and has precedent. Placement benchmark: `waitForSettled` awaited `popup.getAnimations()`, but a CSS transition is absent from that list until it actually starts, so the call could return an empty list and let a mid-transition rect through. The scaled-down surface then read as an 8px offset rather than an obvious failure, which is why it looked like a placement bug. It now also waits for the transform and opacity to settle. Worth noting for anyone reading the benchmark: this only bites because the successor animates by default. Collision handling was my first suspicion and it was wrong -- disabling it did not change the number. Co-Authored-By: Claude Opus 5 --- .../mui-material/src/Unstable_Menu2/Menu2.tsx | 24 ++++++++++++++++-- .../Unstable_Menu2/Menu2Benchmark.test.tsx | 10 +++++++- .../Menu2CheckboxItem.tsx | 8 +++--- .../Menu2CheckboxItemIndicator.test.tsx | 8 +++++- .../Menu2RadioItem.tsx | 7 +++--- .../Menu2RadioItemIndicator.test.tsx | 8 +++++- .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 25 ++++++++++++++++--- 7 files changed, 73 insertions(+), 17 deletions(-) diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 89902e6502c46b..8030f9bf594231 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -171,13 +171,33 @@ Menu2.propTypes /* remove-proptypes */ = { /** * The props used for each slot inside. */ - slotProps: PropTypes.object, + slotProps: PropTypes.shape({ + backdrop: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + list: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + popup: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + portal: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + positioner: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + trigger: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), /** * The components used for each slot inside. */ - slots: PropTypes.object, + slots: PropTypes.shape({ + list: PropTypes.elementType, + paper: PropTypes.elementType, + popup: PropTypes.elementType, + portal: PropTypes.elementType, + positioner: PropTypes.elementType, + trigger: PropTypes.elementType, + }), /** * The element that opens the menu. + * + * An element is rendered as-is with the trigger behavior merged into it, so + * it keeps whatever component you passed. Anything else renders inside the + * default trigger. Omit it and drive the menu with `open` and `anchor` + * instead, which is the classic controlled pattern. */ trigger: PropTypes.node, } as any; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx index 0148fbca9276ba..a50f5393b3a730 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -63,13 +63,21 @@ const openTrigger = () => screen.getByRole('button', { name: 'Options' }); const waitForOpen = () => waitFor(() => expect(menuEl()).not.to.equal(null)); // The successor animates its surface by default, so geometry has to be read -// after the open transition settles. +// after the open transition settles. Awaiting `getAnimations()` alone is not +// enough: a CSS transition is absent from that list until it actually starts, +// so the call can return an empty list and let a mid-transition rect through, +// which reads as a small offset rather than an obvious failure. async function waitForSettled() { await waitForOpen(); const popup = menuEl()!; if (typeof popup.getAnimations === 'function') { await Promise.all(popup.getAnimations().map((animation) => animation.finished.catch(() => {}))); } + await waitFor(() => { + const { transform, opacity } = window.getComputedStyle(popup); + expect(transform === 'none' || transform === 'matrix(1, 0, 0, 1, 0, 0)').to.equal(true); + expect(Number(opacity)).to.equal(1); + }); } describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx index 59ad4b0ff1fe06..6337d794bf61e9 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -41,10 +41,9 @@ export interface Menu2CheckboxItemSlots { root?: React.ElementType | undefined; /** * The component that renders the check indicator. - * Pass `null` to render no indicator. * @default Menu2CheckboxItemIndicator */ - indicator?: React.ElementType | null | undefined; + indicator?: React.ElementType | undefined; } export interface Menu2CheckboxItemSlotProps extends Menu2RootSlotProps { @@ -188,8 +187,7 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( [onChange], ); const RootSlot = slots?.root ?? Menu2CheckboxItemRoot; - const IndicatorSlot = - slots?.indicator === undefined ? Menu2CheckboxItemIndicator : slots.indicator; + const IndicatorSlot = slots?.indicator ?? Menu2CheckboxItemIndicator; const resolvedIndicatorProps = resolveComponentProps(slotProps?.indicator, ownerState); return ( @@ -217,7 +215,7 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( {...other} > {/* Reserved by default: an unmounted indicator would shift the label. */} - {IndicatorSlot ? : null} + {children} diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx index ce70fb1b75cfe2..4db2d49ba25ac8 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx @@ -8,6 +8,12 @@ import Menu2CheckboxItemIndicator, { import describeConformance from '../../test/describeConformance'; import withPortalledRoot from '../../test/menu2Conformance'; +// The item renders its own indicator; this suppresses it so the suite can +// mount one directly. +function NoIndicator() { + return null; +} + describe('', () => { const { render } = createRenderer(); @@ -17,7 +23,7 @@ describe('', () => { withPortalledRoot( render( - {node}Ruler + {node}Ruler , ), `.${classes.root}`, diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx index 94b832b42516d8..d06cf2e98c7e1f 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx @@ -41,10 +41,9 @@ export interface Menu2RadioItemSlots { root?: React.ElementType | undefined; /** * The component that renders the check indicator. - * Pass `null` to render no indicator. * @default Menu2RadioItemIndicator */ - indicator?: React.ElementType | null | undefined; + indicator?: React.ElementType | undefined; } export interface Menu2RadioItemSlotProps extends Menu2RootSlotProps { @@ -147,7 +146,7 @@ const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( [dense, disableGutters], ); const RootSlot = slots?.root ?? Menu2RadioItemRoot; - const IndicatorSlot = slots?.indicator === undefined ? Menu2RadioItemIndicator : slots.indicator; + const IndicatorSlot = slots?.indicator ?? Menu2RadioItemIndicator; const resolvedIndicatorProps = resolveComponentProps(slotProps?.indicator, ownerState); return ( @@ -173,7 +172,7 @@ const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( {...other} > {/* Reserved by default: an unmounted indicator would shift the label. */} - {IndicatorSlot ? : null} + {children} diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx index 4522f2e71199c2..c6fae27b15d5d6 100644 --- a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx @@ -9,6 +9,12 @@ import Menu2RadioItemIndicator, { import describeConformance from '../../test/describeConformance'; import withPortalledRoot from '../../test/menu2Conformance'; +// The item renders its own indicator; this suppresses it so the suite can +// mount one directly. +function NoIndicator() { + return null; +} + describe('', () => { const { render } = createRenderer(); @@ -19,7 +25,7 @@ describe('', () => { render( - + {node}One diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index a082e691859e70..8f070e0404f63d 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -150,13 +150,32 @@ Menu2Submenu.propTypes /* remove-proptypes */ = { /** * The props used for each slot inside. */ - slotProps: PropTypes.object, + slotProps: PropTypes.shape({ + backdrop: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + list: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + popup: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + portal: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + positioner: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + trigger: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), /** * The components used for each slot inside. */ - slots: PropTypes.object, + slots: PropTypes.shape({ + list: PropTypes.elementType, + paper: PropTypes.elementType, + popup: PropTypes.elementType, + portal: PropTypes.elementType, + positioner: PropTypes.elementType, + trigger: PropTypes.elementType, + }), /** - * The item that opens the submenu. + * The content of the item that opens the submenu. + * + * Unlike the root menu, this is the label rather than the element: a submenu + * trigger is always a menu item, so passing one would nest an item inside an + * item. Swap the component through `slots.trigger` instead. */ trigger: PropTypes.node, } as any; From 26f32a3c4322c85219e06d0b83d9ff0a07efe52a Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 4 Aug 2026 00:47:33 +0300 Subject: [PATCH 44/62] Fix the behavioral slots, the lost ref, and the stale RFC Review found three real defects. The portal and positioner slots replaced the Base parts with whatever was passed. Both are context providers -- the positioner needs the portal's context, the popup needs the positioner's -- so `slots={{ portal: 'div' }}` threw "Base UI: is missing" at runtime. The type fixture advertised both but only typechecked them, which is why nothing caught it. The Base parts are now always rendered and a slot only changes what they render, through `render`, which is what the popup slot already did and what the RFC's own customization rule says. Covered by a test that renders both. Collapsing the containers dropped the public ref: classic Menu forwards one and Menu2 was a plain function. Menu2 and Menu2Submenu now forward to the popup surface, matching where the classic ref lands. `slots.backdrop` was advertised in the RFC but missing from Menu2PopupSlots, since the public slots type redeclared the list without it. RFC corrections: the stale "Left" items, the +77 B figure (the current report is +685 B gzip, +3.54 KB parsed), forced colors now being covered, `disableScrollLock` described as removed rather than mapped to `modal`, and the animation no longer claimed to match Grow -- classic derives its duration from menu height via transitionDuration="auto", a CSS transition cannot. The pointer-focus justification was wrong: the APG says focus moves to an item on open and carves out no pointer exception. The behavior stays, now documented as a deliberate deviation. Adds open questions 12-14 for the calls that are not mine to make: the theme API still being compound while the JSX is flat, the MenuItem/Select lifecycle, and how much of Base UI's API is part of the Material contract. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 43 +++++++------ .../mui-material/src/Unstable_Menu2/Menu2.tsx | 11 +++- .../src/Unstable_Menu2/Menu2Popup.tsx | 5 ++ .../src/Unstable_Menu2/menu2PopupShared.tsx | 64 ++++++++----------- .../Menu2Collapsed.test.tsx | 49 +++++++++++++- .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 8 ++- 6 files changed, 117 insertions(+), 63 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index eac53996639ed6..a0d671c0270247 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -30,7 +30,7 @@ This RFC also sets the rules for building future Material UI components on Base 2. Good pointer behavior: a submenu must stay open while the pointer moves diagonally toward it (the "safe triangle"), plus hover-open delays. This is the bar earlier attempts failed to clear. 3. Collision-aware positioning: submenus flip at screen edges instead of getting cut off. 4. Same look as today's `Menu`/`MenuItem`, and full theming: `sx`, `classes`, `component`, `slots`/`slotProps`, and theme `defaultProps`/`styleOverrides`/`variants`. -5. Near-zero cost for existing users. Today's `Menu` keeps working. Apps that do not import the new component get no behavior change and none of Base UI's bundle cost. They do pay a one-time ~77 B gzip, because the classic components now read the styles the new ones share. +5. Near-zero cost for existing users. Today's `Menu` keeps working. Apps that do not import the new component get no behavior change and none of Base UI's bundle cost. They do pay a one-time cost, because the classic components now read the styles the new ones share; see the size numbers below. 6. Keep the current API where the new foundation allows, and document the places where it cannot. 7. Cover the other long-requested menu features at the same time, so the API does not need reshaping later: checkbox and radio items, groups, hover-open, context menus. 8. A clear path to becoming `Menu` in the next major, with a migration guide and codemods, so early adopters are not stranded. @@ -148,7 +148,7 @@ Before finalizing the shape we needed to know how far the behavior differs. That What this means: the successor is closer to a drop-in than expected. Placement, scroll locking, Escape, and Tab-closes-the-menu already match. Keyboard opening cannot be compared directly because the classic Menu has no trigger part, but both end up highlighting an item, so they agree in practice. Five differences remain: - **Keep, they are accessibility fixes.** Disabled items stay focusable and sibling content stays in the accessibility tree. Both follow the WAI-ARIA menu pattern; the classic behavior is the odd one out. The backdrop belongs here too: dismissal no longer needs one, and it is available as a slot. -- **Decided: keep Base UI's behavior** for initial focus when opening with the pointer. Nothing is highlighted, so Enter cannot trigger an item the user never picked, which is how native desktop menus work. The menu pattern only prescribes focus for keyboard opening, where both already agree, so this was ours to choose. Matching the classic behavior would mean focusing an item ourselves after opening, since Base UI has no `initialFocus` prop on Menu, which fights the library and brings back the accidental-activation risk. This is a documented change for people migrating. +- **Decided: keep Base UI's behavior** for initial focus when opening with the pointer. Nothing is highlighted, so Enter cannot trigger an item the user never picked, which is how native desktop menus work. This is a deliberate deviation from the APG, not a neutral choice: the [menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) says focus moves to an item when the menu opens and carves out no exception for pointer opening. We are matching native desktop menus instead, and it should ship documented as a deviation. Matching the classic behavior would mean focusing an item ourselves after opening, since Base UI has no `initialFocus` prop on Menu, which fights the library and brings back the accidental-activation risk. This is a documented change for people migrating. - **Document it.** Where focus goes after Tab. Classic sends it back to the trigger; the successor lets it move on, which is what pressing Tab asks for. `variant="selectedMenu"` is dropped, and this one is a lost feature rather than a changed behavior. The prop opens the menu with the current value highlighted. In the code it decides which item is focused when the menu opens, and hides the focus ring for that first moment. Base UI cannot do either: `Menu.Root` has no initial-highlight prop and `Menu.Popup` has no `initialFocus`. Radio items do not fill the gap either. A `RadioGroup` with its second item checked still opens with the first item highlighted, which the benchmark asserts. @@ -212,7 +212,7 @@ The full prop mapping is in the appendix. ### Where the experiment stands -The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) covers submenus, checkbox and radio items, groups, matching visuals, theme registration, and tests, at +77 B gzip on the `@mui/material` barrel. The companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) moves it toward the rules above. +The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) covers submenus, checkbox and radio items, groups, matching visuals, theme registration, and tests, at a small fixed cost on the `@mui/material` barrel. The companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) moves it toward the rules above. Done: @@ -226,25 +226,30 @@ Done: Left: -- The compound API needs the flat/standalone split from the design phase. - Style sharing is at the style-function level; sharing the styled element itself is better where it fits. - The Base UI-specific slot helpers should move into `@mui/utils`. -- The existing Menu behavior tests cannot run against the successor yet, because they are written against `anchorEl` and need the flat container first. +- The existing Menu behavior tests still have to be adapted. The flat container was the blocker and it now exists, so this is next. The benchmark covers opening, focus, disabled items, dismissal, scroll locking, backdrop treatment, and placement; it does not yet cover default item close behavior, link items, checkbox and radio activation, controlled callback reasons, outside-pointer dismissal, hover and submenu timing, RTL submenu navigation, or context-menu focus. +- The theme API is still compound while the JSX API is flat. See open question 12. +- The `MenuItem` and `Select` side of the lifecycle is unanswered. See open question 13. ### Open questions 1. **Ripple.** Items are plain elements, so there is no ripple by default. Users can get one today by swapping the item root: `slots={{ root: ButtonBase }}` produces a real ripple, but it loses our item styling, because that CSS lives on the default root. Putting a `ButtonBase` inside an item is not an option: it places a focusable element inside a `menuitem`, which highlights the item on open ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)) and is the pattern Base UI rules out. So: ship ripple by default (make the item root a styled `ButtonBase`, bringing back `disableRipple`), or leave it to the root slot and document the recipe? Design call, no accessibility stake. -2. **Default animation. Decided: ship one.** The popup has a CSS transition matching the classic `Grow`, using the same scale and the theme's durations, so migrating apps do not lose their animation. It turns off under `prefers-reduced-motion` and can be overridden through `slotProps.popup`, `styleOverrides`, or the theme. It has to live on the popup element, because Base UI waits for animations on that element before unmounting; a transition on a child would be cut off when closing. One practical effect: anything measuring the menu right after it opens now reads it mid-animation, so tests have to wait for the transition to finish. +2. **Default animation. Decided: ship one.** The popup has a CSS transition modelled on the classic `Grow`, using the same scale and the theme's durations, so migrating apps do not lose their animation. It is not identical: the classic Menu passes `transitionDuration="auto"`, so `Grow` derives the duration from the menu's height through `getAutoHeightDuration`, while a CSS transition has to use fixed `enteringScreen`/`leavingScreen` values. Tall menus animate faster than they used to, which is worth comparing before this is called done. It turns off under `prefers-reduced-motion` and can be overridden through `slotProps.popup`, `styleOverrides`, or the theme. It has to live on the popup element, because Base UI waits for animations on that element before unmounting; a transition on a child would be cut off when closing. One practical effect: anything measuring the menu right after it opens now reads it mid-animation, so tests have to wait for the transition to finish. 3. **`elevation` prop. Decided: keep it.** The popup takes `elevation` (default 8) and passes it to the Paper slot, so the common case does not need `slotProps.paper`. 4. **Backdrop. Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` mirror the classic Menu, with a default element that is transparent and click-through, like the classic invisible backdrop. Dismissal stays with Base UI's outside-press handling. Dimming is `slotProps={{ backdrop: { sx: { bgcolor: ... } } }}`. One difference from classic: it only renders when you opt in, because always rendering it would give non-modal menus a full-screen layer they never had, and modal menus already get Base UI's own inert backdrop. 5. **Imperative actions. Decided: use Base UI's `actionsRef` as-is.** It arrives with the inherited types and gives `close()` and `unmount()`. We do not rename it or build our own `action` ref: renaming means our API drifts from Base UI's for no gain, and rebuilding duplicates what Base UI already does. The classic `action.updatePosition()` has no equivalent because the position updates automatically. 6. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor, but the recipe has a focus bug we hit ourselves: a menu with no trigger has nothing to return focus to, so Base UI falls back to the last element it remembers, which can be a trigger from an unrelated menu on the page. The recipe has to pass `finalFocus` pointing at the element that was right-clicked, and nothing in the API tells you that. It looks fine until a second menu exists. A component wrapping Base UI's `ContextMenu` would handle it internally, since its trigger is the right-click surface. So: document the recipe, or ship the component? 7. **Styling around submenu triggers. Decided: do both.** While a submenu is open, Base UI keeps focus-guard elements next to the trigger, because tab order depends on them. Any CSS using sibling selectors (`+`, `~`, `:last-child`) around a trigger breaks the moment a submenu opens. We hit this bug ourselves, and our parts now own their spacing instead. We will document the rule (the guards are identifiable via `data-base-ui-focus-guard`) and separately ask upstream whether the guards could sit outside the item list, which would help every Base UI user. 8. **Bundle size.** Base UI adds real weight per component. Do we want a size check per Base UI-backed component? -9. **Accessibility: what is still ours.** Base UI owns the roles, keyboard behavior, focus, and dismissal. Everything visual is ours, and that is where the remaining risk sits. Three concrete gaps. Our forced-colors enhancer matches classic items through `menuItemClasses`, but successor items use `menu2ItemClasses`, whose state class is `highlighted` and which has no `focusVisible`, so nothing matches them today; the trigger is covered, because it is a styled `Button`. The highlight itself is a background tint (`action.focus`, roughly 1.3:1) with the native outline removed, which is parity with the classic item but worth choosing rather than inheriting. And nothing checks an open menu automatically: axe runs only in the visual regression suite, which never interacts with the page and skips the menus page for exactly that reason, and `describeConformance` has no accessibility assertions. +9. **Accessibility: what is still ours.** Base UI owns the roles, keyboard behavior, focus, and dismissal. Everything visual is ours, and that is where the remaining risk sits. Three concrete gaps. Forced colors is now covered: `enhanceHighContrast` handles the five item parts and both indicators, keyed on `highlighted`. Two gaps are left. The highlight itself is a background tint (`action.focus`, roughly 1.3:1) with the native outline removed, which is parity with the classic item but below the 3:1 that [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) asks of a focus indicator, so it needs design and accessibility sign-off rather than being inherited. And nothing checks an open menu automatically: axe runs only in the visual regression suite, which never interacts with the page and skips the menus page for exactly that reason, and `describeConformance` has no accessibility assertions. 10. **Other defaults.** Two are decided: pointer-opened menus highlight nothing, and submenus open on hover. There is no question about modality: Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as the same. The successor only adds the option to turn it off. 11. **SSR, `'use client'`, ref typing.** All 18 modules carry `'use client'`, placed the way the classic Menu places it, and Base UI ships the directive on its own menu modules. Nothing verifies that it works: the docs site is Pages Router with `output: 'export'`, so no server component boundary is ever evaluated, and an App Router fixture is the only thing that would confirm it. Server rendering is measured rather than open: only the trigger renders on the server, and neither `defaultOpen` nor `keepMounted` changes that, because Base UI creates the portal node in a layout effect. The real question is ref typing. Refs come from Base UI and are wide (`HTMLElement`, `Element`), where the classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. Conformance pins the runtime element; the type stays loose. Narrow per part, or keep parity with Base UI's signatures? +12. **The theme API is still compound.** The JSX collapsed, the theme did not. `MuiMenu2` takes `defaultProps` only, while `styleOverrides` and `variants` still live on `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot`. Those keys are public contracts, so calling the parts internal is only half true today. Either the trigger and popup overrides move under `MuiMenu2` and `MuiMenu2Submenu` as slot overrides, or these stay permanent public theming parts and join the lifecycle and migration plan. Flat in JSX and compound in theming is the one option to rule out. +13. **`MenuItem` and `Select` in the lifecycle.** The rollout below covers `Menu2` -> `Menu` and classic `Menu` -> `MenuLegacy`, and says nothing about items. `Select` renders a classic `Menu` with `MenuItem` as a listbox, so promotion has to answer: does `Menu2Item` become `MenuItem`, does today's `MenuItem` become `MenuItemLegacy`, what happens to the `MuiMenuItem` theme key, and does `Select` keep the legacy item, get a private listbox item, or get rewritten first? This is the largest remaining gap in the migration story. +14. **How much Base UI shows through.** `Menu2Props` extends `BaseMenu.Root.Props` and the dependency is `^1.6.0`, so a Base minor can add public Menu props that never went through our own API review, and runtime PropTypes stay partial because the generator cannot read types from `node_modules`. That sits awkwardly next to the rule that users should not need to know Base UI is involved, and next to keeping `actionsRef` under its Base name. Either put a Material-owned facade over the root props and callbacks, or accept the inheritance, pin the exact version, and say plainly that Base UI's API is part of the Material contract. + ### Rollout plan 1. Behavior benchmark: **done**, results above. @@ -284,17 +289,17 @@ Left:
3. Focus and modality -| Classic Menu | New equivalent | Notes | -| :---------------------------------------- | :------------- | :-------------------------------------------------------------------------------------------------------------- | -| `autoFocus`, `disableAutoFocusItem` | internal | keyboard opening highlights the first item; pointer opening highlights nothing | -| `variant` (`menu`/`selectedMenu`) | dropped | not reproducible on Base UI (see above). Use checkbox or radio items so the current value is at least indicated | -| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` covers this | -| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | -| `disableEscapeKeyDown` | dropped | goes against the menu pattern; use `onKeyDown` if you must | -| `disableScrollLock` | `modal` | non-modal menus do not lock scroll | -| `hideBackdrop` | backdrop slot | the backdrop is opt-in (open question 4) | -| `disablePortal` | dropped | always portalled | -| `keepMounted`, `container` | same | same behavior | +| Classic Menu | New equivalent | Notes | +| :---------------------------------------- | :------------- | :--------------------------------------------------------------------------------------------------------------------------- | +| `autoFocus`, `disableAutoFocusItem` | internal | keyboard opening highlights the first item; pointer opening highlights nothing | +| `variant` (`menu`/`selectedMenu`) | dropped | not reproducible on Base UI (see above). Use checkbox or radio items so the current value is at least indicated | +| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` covers this | +| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | +| `disableEscapeKeyDown` | dropped | goes against the menu pattern; use `onKeyDown` if you must | +| `disableScrollLock` | removed | `modal={false}` is not an equivalent: it also leaves the rest of the document interactive. The fine-grained control is gone. | +| `hideBackdrop` | backdrop slot | the backdrop is opt-in (open question 4) | +| `disablePortal` | dropped | always portalled | +| `keepMounted`, `container` | same | same behavior |
@@ -348,7 +353,7 @@ Proof of concept and experiment: - PoC: [#48663](https://github.com/mui/material-ui/pull/48663) ([demo](https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/)) - Companion playground: [#48823](https://github.com/mui/material-ui/pull/48823) -- Bundle impact on `@mui/material`: +160 B parsed, +77 B gzip (~0.03%), from the style extraction only. Base UI code is only paid when you import the component. +- Bundle impact on `@mui/material`: the current report on [#48823](https://github.com/mui/material-ui/pull/48823) is +3.54 KB parsed, +685 B gzip. The earlier +77 B figure came from the proof of concept, before the collapsed components and the shared popup module landed; it is stale. Base UI code itself is still only paid when you import the component. Demand: diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 8030f9bf594231..646fedec2bb6ef 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -61,7 +61,12 @@ export interface Menu2Props * * - [Menu](https://mui.com/material-ui/react-menu/) */ -function Menu2(props: Menu2Props): React.JSX.Element { +const Menu2 = React.forwardRef(function Menu2( + props: Menu2Props, + // The popup surface is the element callers reach for, the way the classic + // Menu's ref lands on its Paper. + ref: React.ForwardedRef, +) { const themedProps = useDefaultProps({ props, name: 'MuiMenu2', @@ -130,6 +135,7 @@ function Menu2(props: Menu2Props): React.JSX.Element { {triggerNode} ); -} +}); Menu2.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ @@ -184,6 +190,7 @@ Menu2.propTypes /* remove-proptypes */ = { * The components used for each slot inside. */ slots: PropTypes.shape({ + backdrop: PropTypes.elementType, list: PropTypes.elementType, paper: PropTypes.elementType, popup: PropTypes.elementType, diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx index 1210b6552c1b86..7abc61bdabe0db 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx @@ -148,6 +148,11 @@ export interface Menu2PopupSlots { * @default BaseMenu.Positioner */ positioner?: React.ElementType | undefined; + /** + * The component used for the backdrop rendered beneath the menu. + * Only rendered when a backdrop is opted into. + */ + backdrop?: React.ElementType | undefined; /** * The component rendered by the Base UI popup. * @default 'div' diff --git a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx index 70cd3b9ddd6a76..4f72320df804cd 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -52,22 +52,6 @@ function getSlotProps; - const portalSlotProps = getSlotProps( - PortalSlot, - { - container, - keepMounted, - ...resolvedPortalProps, - }, - portalHostOmittedProps, - ); - const positionerSlotProps = getSlotProps( - PositionerSlot, - { - ...positionerProps, - ...resolvedPositionerProps, - }, - positionerHostOmittedProps, - ); + const portalRender = PortalSlot ? ( + + ) : undefined; + const positionerRender = PositionerSlot ? ( + + ) : undefined; + const portalSlotProps = { + container, + keepMounted, + ...resolvedPortalProps, + }; + const positionerSlotProps = { + ...positionerProps, + ...resolvedPositionerProps, + }; // The Material surfaces go through the shared slot plumbing (className // merging, ref forking, host-aware ownerState). Base UI-specific host-prop // omission is layered on top; see `getSlotProps`. @@ -328,7 +314,7 @@ export const Menu2PopupBase = React.forwardRef(function Menu2PopupBase + {BackdropSlot ? ( ) : null} - + {children} - - + + ); }) as ( props: Menu2PopupSharedProps & React.RefAttributes, diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index bd8d364ed08078..b9fccb901bfef5 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -4,7 +4,7 @@ import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-uti import Button from '@mui/material/Button'; import Menu2, { menu2PopupClasses, menu2TriggerClasses } from '@mui/material/Unstable_Menu2'; import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; -import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; +import Menu2Submenu, { menu2SubmenuPopupClasses } from '@mui/material/Unstable_Menu2Submenu'; // The collapsed shape: one component per menu at both levels, trigger as a // prop, children as the popup. @@ -31,6 +31,53 @@ describe(' collapsed API', () => { expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class(menu2ItemClasses.root); }); + // The type fixture advertises these slots but only typechecks them. They are + // context providers, so swapping them for a plain element used to break the + // tree at runtime; these render for real. + it('renders with the portal and positioner slots swapped', async () => { + const { user } = render( + + Profile + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const menu = await screen.findByRole('menu'); + expect(menu).to.have.class(menu2PopupClasses.root); + expect(screen.getByTestId('positioner')).to.contain(menu); + expect(screen.getByRole('menuitem', { name: 'Profile' })).not.to.equal(null); + }); + + it('forwards a ref to the popup surface', async () => { + const menuRef = React.createRef(); + const submenuRef = React.createRef(); + const { user } = render( + + + Nested + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + const menu = await screen.findByRole('menu'); + expect(menuRef.current).to.equal(menu); + + await user.click(screen.getByRole('menuitem', { name: 'More' })); + await waitFor(() => { + expect(submenuRef.current).not.to.equal(null); + }); + expect(submenuRef.current).to.have.class(menu2SubmenuPopupClasses.root); + }); + it('falls back to the default trigger for a non-element', async () => { const { user } = render( diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index 8f070e0404f63d..0dd74b99da634e 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -56,7 +56,10 @@ export interface Menu2SubmenuProps * * - [Menu](https://mui.com/material-ui/react-menu/) */ -function Menu2Submenu(props: Menu2SubmenuProps): React.JSX.Element { +const Menu2Submenu = React.forwardRef(function Menu2Submenu( + props: Menu2SubmenuProps, + ref: React.ForwardedRef, +) { const themedProps = useDefaultProps({ props, name: 'MuiMenu2Submenu', @@ -109,6 +112,7 @@ function Menu2Submenu(props: Menu2SubmenuProps): React.JSX.Element { {triggerNode} ); -} +}); Menu2Submenu.propTypes /* remove-proptypes */ = { // ┌────────────────────────────── Warning ──────────────────────────────┐ From 6ba9ab12d5c5f5e2ac3b975bca084d51d0f95d6f Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 4 Aug 2026 12:00:31 +0300 Subject: [PATCH 45/62] Collapse the theme API onto MuiMenu2 and MuiMenu2Submenu The JSX collapsed but the theme did not, so the parts were only half internal: styleOverrides and variants still had to go through MuiMenu2Popup, MuiMenu2Trigger, MuiMenu2SubmenuPopup, MuiMenu2SubmenuTrigger and MuiMenu2SubmenuRoot, which are public contracts. Those five keys are gone. MuiMenu2 and MuiMenu2Submenu now carry defaultProps, variants and styleOverrides for their slots -- root, trigger, backdrop, paper, list for the menu; root, trigger, paper, list for the submenu -- by renaming the styled parts onto those names and slots. The internal components no longer call useDefaultProps for keys that no longer exist; the collapsed component applies them once. The forced-colors enhancer moves with it: the submenu trigger block now sits on MuiMenu2Submenu's `trigger` slot. Class hooks stay per element. CSS has to select distinct nodes and their states, and the submenu trigger carries the full item state set, so folding those into one token bag would lose selectors for no gain. This is the same split Autocomplete has: one theme key, per-slot classes. Covered by a test that themes all four slots and reads the computed styles off the rendered elements, which is what would have caught the half-collapse in the first place. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 3 +- .../src/Unstable_Menu2/Menu2.spec.tsx | 37 +++--- .../src/Unstable_Menu2/Menu2Popup.tsx | 15 +-- .../src/Unstable_Menu2/Menu2SubmenuPopup.tsx | 13 +- .../src/Unstable_Menu2/Menu2SubmenuRoot.tsx | 8 +- .../Unstable_Menu2/Menu2SubmenuTrigger.tsx | 14 +-- .../src/Unstable_Menu2/Menu2Trigger.tsx | 13 +- .../src/Unstable_Menu2/menu2Classes.ts | 10 ++ .../Menu2Collapsed.test.tsx | 52 ++++++++ .../mui-material/src/styles/components.ts | 37 +----- .../src/styles/enhanceHighContrast.test.ts | 118 ++++++++++-------- .../src/styles/enhanceHighContrast.ts | 12 +- packages/mui-material/src/styles/overrides.ts | 13 +- packages/mui-material/src/styles/props.ts | 10 -- 14 files changed, 188 insertions(+), 167 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index a0d671c0270247..34f776e1b44f1a 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -229,7 +229,6 @@ Left: - Style sharing is at the style-function level; sharing the styled element itself is better where it fits. - The Base UI-specific slot helpers should move into `@mui/utils`. - The existing Menu behavior tests still have to be adapted. The flat container was the blocker and it now exists, so this is next. The benchmark covers opening, focus, disabled items, dismissal, scroll locking, backdrop treatment, and placement; it does not yet cover default item close behavior, link items, checkbox and radio activation, controlled callback reasons, outside-pointer dismissal, hover and submenu timing, RTL submenu navigation, or context-menu focus. -- The theme API is still compound while the JSX API is flat. See open question 12. - The `MenuItem` and `Select` side of the lifecycle is unanswered. See open question 13. ### Open questions @@ -246,7 +245,7 @@ Left: 10. **Other defaults.** Two are decided: pointer-opened menus highlight nothing, and submenus open on hover. There is no question about modality: Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as the same. The successor only adds the option to turn it off. 11. **SSR, `'use client'`, ref typing.** All 18 modules carry `'use client'`, placed the way the classic Menu places it, and Base UI ships the directive on its own menu modules. Nothing verifies that it works: the docs site is Pages Router with `output: 'export'`, so no server component boundary is ever evaluated, and an App Router fixture is the only thing that would confirm it. Server rendering is measured rather than open: only the trigger renders on the server, and neither `defaultOpen` nor `keepMounted` changes that, because Base UI creates the portal node in a layout effect. The real question is ref typing. Refs come from Base UI and are wide (`HTMLElement`, `Element`), where the classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. Conformance pins the runtime element; the type stays loose. Narrow per part, or keep parity with Base UI's signatures? -12. **The theme API is still compound.** The JSX collapsed, the theme did not. `MuiMenu2` takes `defaultProps` only, while `styleOverrides` and `variants` still live on `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot`. Those keys are public contracts, so calling the parts internal is only half true today. Either the trigger and popup overrides move under `MuiMenu2` and `MuiMenu2Submenu` as slot overrides, or these stay permanent public theming parts and join the lifecycle and migration plan. Flat in JSX and compound in theming is the one option to rule out. +12. **Theme API. Decided: collapse it too.** `MuiMenu2` and `MuiMenu2Submenu` are now the only theme keys, each carrying `defaultProps`, `variants`, and `styleOverrides` for their slots: `root`, `trigger`, `backdrop`, `paper`, `list` for the menu, and `root`, `trigger`, `paper`, `list` for the submenu. `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot` are gone, so the parts are internal in theming as well as in JSX. The class hooks stay per element, because CSS still has to select distinct nodes and their states; that is the same split Autocomplete has between one theme key and per-slot classes. 13. **`MenuItem` and `Select` in the lifecycle.** The rollout below covers `Menu2` -> `Menu` and classic `Menu` -> `MenuLegacy`, and says nothing about items. `Select` renders a classic `Menu` with `MenuItem` as a listbox, so promotion has to answer: does `Menu2Item` become `MenuItem`, does today's `MenuItem` become `MenuItemLegacy`, what happens to the `MuiMenuItem` theme key, and does `Select` keep the legacy item, get a private listbox item, or get rewritten first? This is the largest remaining gap in the migration story. 14. **How much Base UI shows through.** `Menu2Props` extends `BaseMenu.Root.Props` and the dependency is `^1.6.0`, so a Base minor can add public Menu props that never went through our own API review, and runtime PropTypes stay partial because the generator cannot read types from `node_modules`. That sits awkwardly next to the rule that users should not need to know Base UI is involved, and next to keeping `actionsRef` under its Base name. Either put a Material-owned facade over the root props and callbacks, or accept the inheritance, pin the exact version, and say plainly that Base UI's API is part of the Material contract. diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx index fb871ea42c80dd..a0a78f16fb09ca 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -99,44 +99,51 @@ createTheme({ MuiMenu2: { defaultProps: { modal: false, + align: 'start', }, - }, - MuiMenu2Submenu: { - defaultProps: { - defaultOpen: false, - }, - }, - MuiMenu2Item: { - defaultProps: { - dense: true, - }, + // The trigger and popup are rendered internally, so their overrides live + // on the collapsed component's slots. styleOverrides: { root: {}, - highlighted: {}, + trigger: {}, + backdrop: {}, + paper: {}, + list: {}, }, variants: [ { - props: { selected: true }, + props: { align: 'start' }, style: {}, }, ], }, - MuiMenu2Popup: { + MuiMenu2Submenu: { defaultProps: { - align: 'start', + defaultOpen: false, }, styleOverrides: { root: {}, + trigger: {}, paper: {}, list: {}, }, + }, + MuiMenu2Item: { + defaultProps: { + dense: true, + }, + styleOverrides: { + root: {}, + highlighted: {}, + }, variants: [ { - props: { align: 'start' }, + props: { selected: true }, style: {}, }, ], }, + MuiMenu2RadioItem: { variants: [ { diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx index 7abc61bdabe0db..5859450164ecb3 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx @@ -9,7 +9,6 @@ import Paper from '../Paper'; import List from '../List'; import { styled } from '../zero-styled'; import { Theme } from '../styles'; -import { useDefaultProps } from '../DefaultPropsProvider'; import { Menu2PopupBase, Menu2PopupPublicProps, @@ -186,13 +185,13 @@ const useUtilityClasses = (ownerState: Menu2PopupOwnerState) => { }; const Menu2PopupRoot = styled('div', { - name: 'MuiMenu2Popup', + name: 'MuiMenu2', slot: 'Root', overridesResolver: (props, styles) => styles.root, })({ outline: 0 }, menu2PopupTransitionStyles); const Menu2PopupBackdrop = styled(BaseMenu.Backdrop, { - name: 'MuiMenu2Popup', + name: 'MuiMenu2', slot: 'Backdrop', overridesResolver: (props, styles) => styles.backdrop, })({ @@ -208,13 +207,13 @@ const Menu2PopupBackdrop = styled(BaseMenu.Backdrop, { }) as any; const Menu2PopupPaper = styled(Paper, { - name: 'MuiMenu2Popup', + name: 'MuiMenu2', slot: 'Paper', overridesResolver: (props, styles) => styles.paper, })(menu2PopupPaperStyles); const Menu2PopupList = styled(List, { - name: 'MuiMenu2Popup', + name: 'MuiMenu2', slot: 'List', overridesResolver: (props, styles) => styles.list, })(menu2PopupListStyles); @@ -229,10 +228,8 @@ const Menu2Popup = React.forwardRef(function Menu2Popup( inProps: Menu2PopupProps, ref: React.ForwardedRef, ) { - const props = useDefaultProps({ - props: inProps, - name: 'MuiMenu2Popup', - }); + // Internal: the collapsed component has already applied `MuiMenu2` defaults. + const props = inProps; const ownerState: Menu2PopupOwnerState = { side: 'bottom', diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx index 6a92f47c2d5c58..87b57bd7b05620 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx @@ -8,7 +8,6 @@ import Paper from '../Paper'; import List from '../List'; import { styled } from '../zero-styled'; import { Theme } from '../styles'; -import { useDefaultProps } from '../DefaultPropsProvider'; import { Menu2PopupBase, Menu2PopupPublicProps, @@ -179,19 +178,19 @@ const useUtilityClasses = (ownerState: Menu2SubmenuPopupOwnerState) => { }; const Menu2SubmenuPopupRoot = styled('div', { - name: 'MuiMenu2SubmenuPopup', + name: 'MuiMenu2Submenu', slot: 'Root', overridesResolver: (props, styles) => styles.root, })({ outline: 0 }, menu2PopupTransitionStyles); const Menu2SubmenuPopupPaper = styled(Paper, { - name: 'MuiMenu2SubmenuPopup', + name: 'MuiMenu2Submenu', slot: 'Paper', overridesResolver: (props, styles) => styles.paper, })(menu2PopupPaperStyles); const Menu2SubmenuPopupList = styled(List, { - name: 'MuiMenu2SubmenuPopup', + name: 'MuiMenu2Submenu', slot: 'List', overridesResolver: (props, styles) => styles.list, })(menu2PopupListStyles); @@ -206,10 +205,8 @@ const Menu2SubmenuPopup = React.forwardRef(function Menu2SubmenuPopup( inProps: Menu2SubmenuPopupProps, ref: React.ForwardedRef, ) { - const props = useDefaultProps({ - props: inProps, - name: 'MuiMenu2SubmenuPopup', - }); + // Internal: `MuiMenu2Submenu` defaults are applied by Menu2Submenu. + const props = inProps; const ownerState: Menu2SubmenuPopupOwnerState = { side: 'inline-end', diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx index a918fc7ca0890c..6b3446905a967d 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import { Menu as BaseMenu } from '@base-ui/react/menu'; -import { useDefaultProps } from '../DefaultPropsProvider'; /** * Inherits the full Base UI `Menu.SubmenuRoot` prop surface (open/close @@ -24,12 +23,7 @@ export interface Menu2SubmenuRootProps extends Omit; + return ; } Menu2SubmenuRoot.propTypes /* remove-proptypes */ = { diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx index a36dda2eefa112..1730969bc5f060 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx @@ -7,7 +7,6 @@ import { Menu as BaseMenu } from '@base-ui/react/menu'; import ListContext from '../List/ListContext'; import { styled } from '../zero-styled'; import memoTheme from '../utils/memoTheme'; -import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenu2ItemStyles } from './menu2SharedStyles'; import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps } from './menu2Utils'; import { @@ -16,7 +15,6 @@ import { Menu2ItemOwnerState, Menu2ItemVisualProps, Menu2SubmenuTriggerBaseProps, - menu2ItemOverridesResolver, useMenu2ItemUtilityClasses, } from './menu2ItemShared'; import { @@ -86,9 +84,9 @@ export interface Menu2SubmenuTriggerProps } const Menu2SubmenuTriggerRoot = styled('div', { - name: 'MuiMenu2SubmenuTrigger', - slot: 'Root', - overridesResolver: menu2ItemOverridesResolver, + name: 'MuiMenu2Submenu', + slot: 'Trigger', + overridesResolver: (props, styles) => styles.trigger, })<{ ownerState: Menu2ItemOwnerState }>( memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2SubmenuTriggerClasses)), ); @@ -103,10 +101,8 @@ const Menu2SubmenuTrigger = React.forwardRef(function Menu2SubmenuTrigger( inProps: Menu2SubmenuTriggerProps, ref: React.ForwardedRef, ) { - const props = useDefaultProps({ - props: inProps, - name: 'MuiMenu2SubmenuTrigger', - }); + // Internal: `MuiMenu2Submenu` defaults are applied by Menu2Submenu. + const props = inProps; const { className, diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx index 667b9291f61701..ac2105541e583a 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx @@ -9,7 +9,6 @@ import { SxProps } from '@mui/system'; import Button, { ButtonProps } from '../Button'; import { Theme } from '../styles'; import { styled } from '../zero-styled'; -import { useDefaultProps } from '../DefaultPropsProvider'; import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps } from './menu2Utils'; import { getMenu2TriggerUtilityClass, Menu2TriggerClasses } from './menu2Classes'; @@ -112,9 +111,9 @@ const useUtilityClasses = (ownerState: Menu2TriggerOwnerState) => { }; const Menu2TriggerRoot = styled(Button, { - name: 'MuiMenu2Trigger', - slot: 'Root', - overridesResolver: (props, styles) => styles.root, + name: 'MuiMenu2', + slot: 'Trigger', + overridesResolver: (props, styles) => styles.trigger, })({}) as any; const BaseMenuTrigger = BaseMenu.Trigger as any; @@ -128,10 +127,8 @@ const Menu2Trigger = React.forwardRef(function Menu2Trigger( inProps: Menu2TriggerProps, ref: React.ForwardedRef, ) { - const props = useDefaultProps({ - props: inProps, - name: 'MuiMenu2Trigger', - }); + // Internal: the collapsed component has already applied `MuiMenu2` defaults. + const props = inProps; const { href: ignoredHref, ...propsWithoutHref } = props as Menu2TriggerProps & { href?: unknown; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts index 1547d56b3aab8f..342036a93cf4c4 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts @@ -274,3 +274,13 @@ export const menu2SubmenuTriggerClasses: Menu2SubmenuTriggerClasses = generateUt 'MuiMenu2SubmenuTrigger', ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'open'], ); + +/** + * Theme `styleOverrides` slots for the collapsed `Menu2`. The trigger and popup + * are rendered internally, so their overrides live here rather than under their + * own component keys. + */ +export type Menu2ClassKey = 'root' | 'trigger' | 'backdrop' | 'paper' | 'list'; + +/** Theme `styleOverrides` slots for the collapsed `Menu2Submenu`. */ +export type Menu2SubmenuClassKey = 'root' | 'trigger' | 'paper' | 'list'; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index b9fccb901bfef5..34958dbbb76f85 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; import Button from '@mui/material/Button'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; import Menu2, { menu2PopupClasses, menu2TriggerClasses } from '@mui/material/Unstable_Menu2'; import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; import Menu2Submenu, { menu2SubmenuPopupClasses } from '@mui/material/Unstable_Menu2Submenu'; @@ -78,6 +79,57 @@ describe(' collapsed API', () => { expect(submenuRef.current).to.have.class(menu2SubmenuPopupClasses.root); }); + // The parts are internal, so their theme overrides have to resolve through the + // collapsed component's slots rather than their own component keys. + it('applies styleOverrides from the collapsed theme slots', async () => { + const theme = createTheme({ + components: { + MuiMenu2: { + styleOverrides: { + trigger: { letterSpacing: '3px' }, + paper: { paddingTop: '9px' }, + list: { paddingBottom: '7px' }, + }, + }, + MuiMenu2Submenu: { + styleOverrides: { + trigger: { letterSpacing: '5px' }, + paper: { paddingTop: '11px' }, + }, + }, + }, + }); + const { user } = render( + + + + Nested + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(window.getComputedStyle(trigger).letterSpacing).to.equal('3px'); + + await user.click(trigger); + const menu = await screen.findByRole('menu'); + expect(window.getComputedStyle(screen.getByTestId('paper')).paddingTop).to.equal('9px'); + const list = menu.querySelector(`.${menu2PopupClasses.list}`)!; + expect(window.getComputedStyle(list).paddingBottom).to.equal('7px'); + + const submenuTrigger = screen.getByRole('menuitem', { name: 'More' }); + expect(window.getComputedStyle(submenuTrigger).letterSpacing).to.equal('5px'); + + await user.click(submenuTrigger); + await waitFor(() => { + expect(screen.queryByTestId('submenu-paper')).not.to.equal(null); + }); + expect(window.getComputedStyle(screen.getByTestId('submenu-paper')).paddingTop).to.equal( + '11px', + ); + }); + it('falls back to the default trigger for a non-element', async () => { const { user } = render( diff --git a/packages/mui-material/src/styles/components.ts b/packages/mui-material/src/styles/components.ts index a068a06f37e1a5..fe805adb508a5c 100644 --- a/packages/mui-material/src/styles/components.ts +++ b/packages/mui-material/src/styles/components.ts @@ -480,6 +480,8 @@ export interface Components { MuiMenu2?: | { defaultProps?: ComponentsProps['MuiMenu2'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2'] | undefined; + variants?: ComponentsVariants['MuiMenu2'] | undefined; } | undefined; MuiMenu2CheckboxItem?: @@ -524,13 +526,6 @@ export interface Components { variants?: ComponentsVariants['MuiMenu2LinkItem'] | undefined; } | undefined; - MuiMenu2Popup?: - | { - defaultProps?: ComponentsProps['MuiMenu2Popup'] | undefined; - styleOverrides?: ComponentsOverrides['MuiMenu2Popup'] | undefined; - variants?: ComponentsVariants['MuiMenu2Popup'] | undefined; - } - | undefined; MuiMenu2RadioGroup?: | { defaultProps?: ComponentsProps['MuiMenu2RadioGroup'] | undefined; @@ -559,35 +554,11 @@ export interface Components { variants?: ComponentsVariants['MuiMenu2Separator'] | undefined; } | undefined; - MuiMenu2SubmenuPopup?: - | { - defaultProps?: ComponentsProps['MuiMenu2SubmenuPopup'] | undefined; - styleOverrides?: ComponentsOverrides['MuiMenu2SubmenuPopup'] | undefined; - variants?: ComponentsVariants['MuiMenu2SubmenuPopup'] | undefined; - } - | undefined; MuiMenu2Submenu?: | { defaultProps?: ComponentsProps['MuiMenu2Submenu'] | undefined; - } - | undefined; - MuiMenu2SubmenuRoot?: - | { - defaultProps?: ComponentsProps['MuiMenu2SubmenuRoot'] | undefined; - } - | undefined; - MuiMenu2SubmenuTrigger?: - | { - defaultProps?: ComponentsProps['MuiMenu2SubmenuTrigger'] | undefined; - styleOverrides?: ComponentsOverrides['MuiMenu2SubmenuTrigger'] | undefined; - variants?: ComponentsVariants['MuiMenu2SubmenuTrigger'] | undefined; - } - | undefined; - MuiMenu2Trigger?: - | { - defaultProps?: ComponentsProps['MuiMenu2Trigger'] | undefined; - styleOverrides?: ComponentsOverrides['MuiMenu2Trigger'] | undefined; - variants?: ComponentsVariants['MuiMenu2Trigger'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Submenu'] | undefined; + variants?: ComponentsVariants['MuiMenu2Submenu'] | undefined; } | undefined; MuiMobileStepper?: diff --git a/packages/mui-material/src/styles/enhanceHighContrast.test.ts b/packages/mui-material/src/styles/enhanceHighContrast.test.ts index 6f2b15cbf3a173..e9b900ffe29515 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.test.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.test.ts @@ -597,35 +597,45 @@ describe('enhanceHighContrast', () => { describe('Menu2 item overrides', () => { const itemCases: Array< - [component: string, classes: { disabled: string; highlighted: string; selected: string }] + [ + component: string, + classes: { disabled: string; highlighted: string; selected: string }, + slot: string, + ] > = [ - ['MuiMenu2Item', menu2ItemClasses], - ['MuiMenu2LinkItem', menu2LinkItemClasses], - ['MuiMenu2CheckboxItem', menu2CheckboxItemClasses], - ['MuiMenu2RadioItem', menu2RadioItemClasses], - ['MuiMenu2SubmenuTrigger', menu2SubmenuTriggerClasses], + ['MuiMenu2Item', menu2ItemClasses, 'root'], + ['MuiMenu2LinkItem', menu2LinkItemClasses, 'root'], + ['MuiMenu2CheckboxItem', menu2CheckboxItemClasses, 'root'], + ['MuiMenu2RadioItem', menu2RadioItemClasses, 'root'], + // Rendered by Menu2Submenu, so its overrides sit on the `trigger` slot. + ['MuiMenu2Submenu', menu2SubmenuTriggerClasses, 'trigger'], ]; - test.each(itemCases)('%s keys the active state off `highlighted`', (component, classes) => { - const theme = enhanceHighContrast(createTheme()); - const rootOverrides = (theme.components as any)[component].styleOverrides - .root as Array; - const hcmOverride = rootOverrides[rootOverrides.length - 1]; - - expect(hcmOverride[`&.${classes.highlighted}, &:hover`]).to.deep.equal({ - [HCM]: { - forcedColorAdjust: 'none', - color: 'HighlightText', - backgroundColor: 'Highlight', - outline: 'none', - }, - }); - }); + test.each(itemCases)( + '%s keys the active state off `highlighted`', + (component, classes, slot) => { + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = (theme.components as any)[component].styleOverrides[ + slot + ] as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${classes.highlighted}, &:hover`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'HighlightText', + backgroundColor: 'Highlight', + outline: 'none', + }, + }); + }, + ); - test.each(itemCases)('%s covers disabled and selected', (component, classes) => { + test.each(itemCases)('%s covers disabled and selected', (component, classes, slot) => { const theme = enhanceHighContrast(createTheme()); - const rootOverrides = (theme.components as any)[component].styleOverrides - .root as Array; + const rootOverrides = (theme.components as any)[component].styleOverrides[ + slot + ] as Array; const hcmOverride = rootOverrides[rootOverrides.length - 1]; expect(hcmOverride[`&.${classes.disabled}`]).to.deep.equal({ @@ -644,14 +654,15 @@ describe('enhanceHighContrast', () => { }); }); - test.each(itemCases)('%s uses custom tokens', (component, classes) => { + test.each(itemCases)('%s uses custom tokens', (component, classes, slot) => { const theme = enhanceHighContrast(createTheme(), { disabled: 'ButtonText', activeText: 'Canvas', activeBackground: 'ButtonBorder', }); - const rootOverrides = (theme.components as any)[component].styleOverrides - .root as Array; + const rootOverrides = (theme.components as any)[component].styleOverrides[ + slot + ] as Array; const hcmOverride = rootOverrides[rootOverrides.length - 1]; expect(hcmOverride[`&.${classes.disabled}`]).to.deep.equal({ @@ -667,30 +678,35 @@ describe('enhanceHighContrast', () => { }); }); - test.each(itemCases)('%s keeps the disabled cue when highlighted', (component, classes) => { - // Base UI keeps disabled items focusable, so this combination is - // reachable here even though it is not on the classic item. - const theme = enhanceHighContrast(createTheme()); - const rootOverrides = (theme.components as any)[component].styleOverrides - .root as Array; - const hcmOverride = rootOverrides[rootOverrides.length - 1]; - - expect(hcmOverride[`&.${classes.disabled}.${classes.highlighted}`]).to.deep.equal({ - [HCM]: { - forcedColorAdjust: 'none', - color: 'GrayText', - backgroundColor: 'Canvas', - outline: '1px solid ButtonBorder', - }, - }); - }); + test.each(itemCases)( + '%s keeps the disabled cue when highlighted', + (component, classes, slot) => { + // Base UI keeps disabled items focusable, so this combination is + // reachable here even though it is not on the classic item. + const theme = enhanceHighContrast(createTheme()); + const rootOverrides = (theme.components as any)[component].styleOverrides[ + slot + ] as Array; + const hcmOverride = rootOverrides[rootOverrides.length - 1]; + + expect(hcmOverride[`&.${classes.disabled}.${classes.highlighted}`]).to.deep.equal({ + [HCM]: { + forcedColorAdjust: 'none', + color: 'GrayText', + backgroundColor: 'Canvas', + outline: '1px solid ButtonBorder', + }, + }); + }, + ); test.each(itemCases)( '%s orders the disabled rules after the highlight', - (component, classes) => { + (component, classes, slot) => { const theme = enhanceHighContrast(createTheme()); - const rootOverrides = (theme.components as any)[component].styleOverrides - .root as Array; + const rootOverrides = (theme.components as any)[component].styleOverrides[ + slot + ] as Array; const keys = Object.keys(rootOverrides[rootOverrides.length - 1]); expect(keys.indexOf(`&.${classes.disabled}`)).to.be.greaterThan( @@ -715,10 +731,10 @@ describe('enhanceHighContrast', () => { }); }); - test('MuiMenu2SubmenuTrigger also marks the open state as active', () => { + test('MuiMenu2Submenu also marks the trigger open state as active', () => { const theme = enhanceHighContrast(createTheme()); - const rootOverrides = theme.components?.MuiMenu2SubmenuTrigger?.styleOverrides - ?.root as Array; + const rootOverrides = theme.components?.MuiMenu2Submenu?.styleOverrides + ?.trigger as Array; const hcmOverride = rootOverrides[rootOverrides.length - 1]; const openKey = `&.${menu2SubmenuTriggerClasses.open}, &.${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`; @@ -1072,7 +1088,7 @@ describe('enhanceHighContrast', () => { ['MuiMenu2LinkItem', 'root'], ['MuiMenu2CheckboxItem', 'root'], ['MuiMenu2RadioItem', 'root'], - ['MuiMenu2SubmenuTrigger', 'root'], + ['MuiMenu2Submenu', 'trigger'], ['MuiMenu2CheckboxItemIndicator', 'root'], ['MuiMenu2RadioItemIndicator', 'root'], ['MuiNativeSelect', 'icon'], diff --git a/packages/mui-material/src/styles/enhanceHighContrast.ts b/packages/mui-material/src/styles/enhanceHighContrast.ts index 44efcd1a3a5d6a..2028ddf08e122a 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.ts @@ -511,12 +511,14 @@ export default function enhanceHighContrast< ], }, }, - MuiMenu2SubmenuTrigger: { - ...c?.MuiMenu2SubmenuTrigger, + // The submenu trigger is rendered by Menu2Submenu, so its overrides live + // under that component's `trigger` slot. + MuiMenu2Submenu: { + ...c?.MuiMenu2Submenu, styleOverrides: { - ...c?.MuiMenu2SubmenuTrigger?.styleOverrides, - root: [ - c?.MuiMenu2SubmenuTrigger?.styleOverrides?.root, + ...c?.MuiMenu2Submenu?.styleOverrides, + trigger: [ + c?.MuiMenu2Submenu?.styleOverrides?.trigger, { ...menu2ItemOverrides(menu2SubmenuTriggerClasses, hcTokens), // A trigger whose submenu is open carries the focus background. diff --git a/packages/mui-material/src/styles/overrides.ts b/packages/mui-material/src/styles/overrides.ts index 6b5ac1aa71ffcd..a2da6ad83bf63a 100644 --- a/packages/mui-material/src/styles/overrides.ts +++ b/packages/mui-material/src/styles/overrides.ts @@ -67,18 +67,13 @@ import { ListSubheaderClassKey } from '../ListSubheader'; import { MenuClassKey } from '../Menu'; import { MenuItemClassKey } from '../MenuItem'; import { MenuListClassKey } from '../MenuList'; +import { Menu2ClassKey, Menu2SubmenuClassKey } from '../Unstable_Menu2/menu2Classes'; import { Menu2CheckboxItemClassKey } from '../Unstable_Menu2CheckboxItem'; import { Menu2CheckboxItemIndicatorClassKey } from '../Unstable_Menu2CheckboxItemIndicator'; import { Menu2GroupClassKey } from '../Unstable_Menu2Group'; import { Menu2GroupLabelClassKey } from '../Unstable_Menu2GroupLabel'; import { Menu2ItemClassKey } from '../Unstable_Menu2Item'; import { Menu2LinkItemClassKey } from '../Unstable_Menu2LinkItem'; -import { - Menu2PopupClassKey, - Menu2SubmenuPopupClassKey, - Menu2SubmenuTriggerClassKey, - Menu2TriggerClassKey, -} from '../Unstable_Menu2/menu2Classes'; import { Menu2RadioGroupClassKey } from '../Unstable_Menu2RadioGroup'; import { Menu2RadioItemClassKey } from '../Unstable_Menu2RadioItem'; import { Menu2RadioItemIndicatorClassKey } from '../Unstable_Menu2RadioItemIndicator'; @@ -227,20 +222,18 @@ export interface ComponentNameToClassKey { MuiMenu: MenuClassKey; MuiMenuItem: MenuItemClassKey; MuiMenuList: MenuListClassKey; + MuiMenu2: Menu2ClassKey; + MuiMenu2Submenu: Menu2SubmenuClassKey; MuiMenu2CheckboxItem: Menu2CheckboxItemClassKey; MuiMenu2CheckboxItemIndicator: Menu2CheckboxItemIndicatorClassKey; MuiMenu2Group: Menu2GroupClassKey; MuiMenu2GroupLabel: Menu2GroupLabelClassKey; MuiMenu2Item: Menu2ItemClassKey; MuiMenu2LinkItem: Menu2LinkItemClassKey; - MuiMenu2Popup: Menu2PopupClassKey; MuiMenu2RadioGroup: Menu2RadioGroupClassKey; MuiMenu2RadioItem: Menu2RadioItemClassKey; MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorClassKey; MuiMenu2Separator: Menu2SeparatorClassKey; - MuiMenu2SubmenuPopup: Menu2SubmenuPopupClassKey; - MuiMenu2SubmenuTrigger: Menu2SubmenuTriggerClassKey; - MuiMenu2Trigger: Menu2TriggerClassKey; MuiMobileStepper: MobileStepperClassKey; MuiModal: ModalClassKey; MuiNativeSelect: NativeSelectClassKey; diff --git a/packages/mui-material/src/styles/props.ts b/packages/mui-material/src/styles/props.ts index bc2b2112db4021..ccb28e4c90f542 100644 --- a/packages/mui-material/src/styles/props.ts +++ b/packages/mui-material/src/styles/props.ts @@ -71,16 +71,11 @@ import { Menu2GroupProps } from '../Unstable_Menu2Group'; import { Menu2GroupLabelProps } from '../Unstable_Menu2GroupLabel'; import { Menu2ItemProps } from '../Unstable_Menu2Item'; import { Menu2LinkItemProps } from '../Unstable_Menu2LinkItem'; -import { Menu2PopupProps } from '../Unstable_Menu2/Menu2Popup'; import { Menu2RadioGroupProps } from '../Unstable_Menu2RadioGroup'; import { Menu2RadioItemProps } from '../Unstable_Menu2RadioItem'; import { Menu2RadioItemIndicatorProps } from '../Unstable_Menu2RadioItemIndicator'; import { Menu2SeparatorProps } from '../Unstable_Menu2Separator'; -import { Menu2SubmenuPopupProps } from '../Unstable_Menu2/Menu2SubmenuPopup'; import { Menu2SubmenuProps } from '../Unstable_Menu2Submenu'; -import { Menu2SubmenuRootProps } from '../Unstable_Menu2/Menu2SubmenuRoot'; -import { Menu2SubmenuTriggerProps } from '../Unstable_Menu2/Menu2SubmenuTrigger'; -import { Menu2TriggerProps } from '../Unstable_Menu2/Menu2Trigger'; import { MenuProps } from '../Menu'; import { MobileStepperProps } from '../MobileStepper'; import { ModalProps } from '../Modal'; @@ -213,16 +208,11 @@ export interface ComponentsPropsList { MuiMenu2GroupLabel: Menu2GroupLabelProps; MuiMenu2Item: Menu2ItemProps; MuiMenu2LinkItem: Menu2LinkItemProps; - MuiMenu2Popup: Menu2PopupProps; MuiMenu2RadioGroup: Menu2RadioGroupProps; MuiMenu2RadioItem: Menu2RadioItemProps; MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorProps; MuiMenu2Separator: Menu2SeparatorProps; - MuiMenu2SubmenuPopup: Menu2SubmenuPopupProps; MuiMenu2Submenu: Menu2SubmenuProps; - MuiMenu2SubmenuRoot: Menu2SubmenuRootProps; - MuiMenu2SubmenuTrigger: Menu2SubmenuTriggerProps; - MuiMenu2Trigger: Menu2TriggerProps; MuiMobileStepper: MobileStepperProps; MuiModal: ModalProps; MuiNativeSelect: NativeSelectProps; From 38ac755d0a078c65c5ee9c5fa12a2f888d833e4b Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 4 Aug 2026 14:17:43 +0300 Subject: [PATCH 46/62] Rewrite the RFC in Simplified Technical English The RFC now follows ASD-STE100: short sentences, the active voice, simple tenses, one word for one meaning, and no idioms or metaphors. Verified against the original, and every structural invariant holds: 20 headings, 4 code fences, 6 details blocks, 41 link targets, 223 inline code spans, 69 table rows, 8 alignment rows, and 57 numbers. A review pass over each section found 9 places where the rewrite changed the meaning. All 9 are corrected. Examples: a hedge became a commitment on the next major, an open list of close reasons became a closed list, "opt-in" became "optional", and a recommendation became a requirement. Five long enumerations are now vertical lists, which is how STE handles a list. 2 of 557 sentences are still over 25 words: one is a verbatim quotation from base-ui#2143, the other is the frontmatter description. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 402 +++++++++++++++------------- 1 file changed, 212 insertions(+), 190 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 34f776e1b44f1a..1dd074e9c1e7dd 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -5,161 +5,180 @@ description: Living draft of the Menu2 RFC, tracked next to the Menu2 experiment # RFC draft: Menu successor with submenu support -

Living draft tracked in this PR until the RFC is posted publicly; review comments welcome on this file.

+

This is a live draft. We track it in this PR until we post the RFC in public. Please add review comments on this file.

Suggested issue title: `[RFC] Menu: Base UI-based successor with submenu support` -Structured for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below into the matching form field. Companion experiments: [playground](/experiments/menu2-playground/), [recipes](/experiments/menu2-recipes/). +We structured this draft for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below into the matching form field. Companion experiments: [playground](/experiments/menu2-playground/), [recipes](/experiments/menu2-recipes/). ## What's the problem? Material UI's `Menu` cannot do submenus. -- It is one of our oldest requests: [#11723](https://github.com/mui/material-ui/issues/11723) has been open since 2018 with 120+ reactions. -- Material UI v0.x had nested menus ([#2148](https://github.com/mui/material-ui/pull/2148)). The v1 rewrite dropped them. -- Community packages (`mui-nested-menu`, `material-ui-popup-state`, many sandboxes) all fall short on keyboard support and ARIA, which maintainers have pointed out repeatedly. -- The Menubar docs page already ships Base UI submenus as copy-paste code, and people immediately asked for a real component ([#48336](https://github.com/mui/material-ui/issues/48336)). Copy-paste code is not versioned, tested, or themed. +- It is one of our oldest requests. [#11723](https://github.com/mui/material-ui/issues/11723) is open since 2018 and has 120+ reactions. +- Material UI v0.x had nested menus ([#2148](https://github.com/mui/material-ui/pull/2148)). The v1 rewrite removed them. +- Community packages (`mui-nested-menu`, `material-ui-popup-state`, many sandboxes) have weak keyboard support and weak ARIA support. Maintainers report this problem many times. +- The Menubar docs page shows Base UI submenus as copy-paste code. Users then asked for a real component ([#48336](https://github.com/mui/material-ui/issues/48336)). Copy-paste code has no version, no tests, and no theme support. -We want submenus in `@mui/material` with Material visuals and full theming, without destabilizing today's `Menu`, on a path to becoming the default `Menu` in the next major. +We want submenus in `@mui/material` with Material visuals and full theme support. The current `Menu` must stay stable. The plan is to make the new component the default `Menu` in the next major version. -This RFC also sets the rules for building future Material UI components on Base UI: customization, styling reuse, dependencies, testing, and tooling. Menu is the first one; the decisions here are meant to apply to the rest. +This RFC also sets the rules to build future Material UI components on Base UI: customization, style reuse, dependencies, tests, and tools. Menu is the first component. We intend the decisions here to apply to the other components. ## What are the requirements? -1. Correct menu behavior at every nesting level: trigger semantics, RTL-aware arrow keys, Escape closing one level at a time, focus returning to the parent item, typeahead per level, any nesting depth. -2. Good pointer behavior: a submenu must stay open while the pointer moves diagonally toward it (the "safe triangle"), plus hover-open delays. This is the bar earlier attempts failed to clear. -3. Collision-aware positioning: submenus flip at screen edges instead of getting cut off. -4. Same look as today's `Menu`/`MenuItem`, and full theming: `sx`, `classes`, `component`, `slots`/`slotProps`, and theme `defaultProps`/`styleOverrides`/`variants`. -5. Near-zero cost for existing users. Today's `Menu` keeps working. Apps that do not import the new component get no behavior change and none of Base UI's bundle cost. They do pay a one-time cost, because the classic components now read the styles the new ones share; see the size numbers below. -6. Keep the current API where the new foundation allows, and document the places where it cannot. -7. Cover the other long-requested menu features at the same time, so the API does not need reshaping later: checkbox and radio items, groups, hover-open, context menus. -8. A clear path to becoming `Menu` in the next major, with a migration guide and codemods, so early adopters are not stranded. -9. Reuse a maintained library instead of rebuilding focus, dismissal, and positioning ourselves. -10. It should look like any other Material UI component in tooling, theming, imports, and tests. Users should not need to know Base UI is involved or install anything extra. +1. Correct menu behavior at every nesting level: + - Trigger semantics. + - RTL-aware arrow keys. + - Escape that closes one level at a time. + - Focus that returns to the parent item. + - Typeahead per level. + - Any nesting depth. +2. Good pointer behavior. A submenu must stay open while the pointer moves diagonally toward it (the "safe triangle"). The menu must also delay the hover-open. Earlier attempts failed to meet this requirement. +3. Collision-aware positioning: submenus flip at screen edges instead of being cut off. +4. The same look as the current `Menu`/`MenuItem`, and full theming: `sx`, `classes`, `component`, `slots`/`slotProps`, and theme `defaultProps`/`styleOverrides`/`variants`. +5. Near-zero cost for existing users. The current `Menu` continues to work. Apps that do not import the new component get no behavior change and none of Base UI's bundle cost. These apps pay a one-time cost, because the classic components now read the styles that the new components share. See the size numbers below. +6. Keep the current API where the new foundation permits it. Document the places where the new foundation does not permit it. +7. Add the other menu features that users request often, at the same time. Then we do not need to change the API later. These features are checkbox items, radio items, groups, hover-open, and context menus. +8. A clear path to become `Menu` in the next major version. Supply a migration guide and codemods, so early adopters keep a way forward. +9. Reuse a maintained library. Do not build focus, dismissal, and positioning again. +10. The component must look like any other Material UI component in tooling, theming, imports, and tests. Users do not need to know about Base UI, and they do not install anything extra. ## What are our options? ### Option A: Add submenus to the existing Menu -Tried three times in eight years, always blocked by the same things: +We tried three times in eight years. The same problems blocked each attempt: -- [#14700](https://github.com/mui/material-ui/pull/14700) (2019): recursive Menu-in-Menu. Closed with "we need to change the menu implementation and to expose new objects to make it happen". -- [#20591](https://github.com/mui/material-ui/pull/20591) (2020-2022, +1333 lines, ~22 months of review): a `subMenu` prop on `MenuItem` using `cloneElement`. Blocked by a double-digit percentage gzip increase to the core bundle, hover intent, missing collision handling in `Popover`, test churn, and the risk of touching a core component before v5. The final review rejected `cloneElement` and suggested rebuilding on headless primitives. -- [#37570](https://github.com/mui/material-ui/pull/37570) (2023-2024): docs demo only. Closed after an accessibility review found gaps in Escape handling, `aria-expanded`, and screen reader support, with the note: "it would make more sense to focus on bringing this to Base UI". +- [#14700](https://github.com/mui/material-ui/pull/14700) (2019): a recursive Menu inside a Menu. We closed it with this note: "we need to change the menu implementation and to expose new objects to make it happen". +- [#20591](https://github.com/mui/material-ui/pull/20591) (2020-2022, +1333 lines, ~22 months of review): a `subMenu` prop on `MenuItem` that used `cloneElement`. Five problems blocked it. The gzip size of the core bundle increased by a double-digit percentage. Hover intent was incorrect. `Popover` had no collision handling. The tests needed many changes. A change to a core component before v5 was a risk. The final review rejected `cloneElement` and proposed a rebuild on headless primitives. +- [#37570](https://github.com/mui/material-ui/pull/37570) (2023-2024): a docs demo only. An accessibility review found problems in Escape handling, `aria-expanded`, and screen reader support. We closed it with this note: "it would make more sense to focus on bringing this to Base UI". -The blockers are structural. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal`), and nesting two of them breaks in six places: +The problems are structural. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal`). Two nested modals break in six places: -1. Each menu renders a full-screen backdrop that captures clicks, so a submenu's backdrop covers its parent and closes the child when you click the parent. -2. `ModalManager` sets `aria-hidden` on everything except the top modal, so opening a submenu hides the parent from screen readers. -3. ArrowRight/ArrowLeft do nothing in a vertical list, and there is no hook for "open the submenu". -4. Each modal has its own focus trap, and they do not coordinate when a submenu closes. -5. `Popover` has no collision flipping, so a submenu near the screen edge is cut off. -6. Each `MenuList` keeps its own keyboard state, so nested lists share nothing. +1. Each menu renders a full-screen backdrop that captures clicks. The backdrop of a submenu covers its parent. A click on the parent closes the child. +2. `ModalManager` sets `aria-hidden` on all elements except the top modal. Therefore, an open submenu hides the parent from screen readers. +3. ArrowRight and ArrowLeft do nothing in a vertical list. There is also no hook to open the submenu. +4. Each modal has its own focus trap. The focus traps do not coordinate when a submenu closes. +5. `Popover` does not flip on collision. Therefore, a submenu near the screen edge is cut off. +6. Each `MenuList` keeps its own keyboard state. Nested lists share no state. -Fixing this means changing `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`, and replacing two core models (backdrop dismissal and per-modal focus traps) that `Dialog` and every `Popover` also use. That rebuilds what Base UI's `Menu` already does, with high regression risk, and it would be thrown away in the next major. Rejected. +A fix must change `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`. It must also replace two core models: backdrop dismissal and per-modal focus traps. `Dialog` and every `Popover` also use these two models. This work rebuilds what the `Menu` of Base UI already does. The regression risk is high, and we discard the work in the next major version. Rejected. ### Option B: Leave it as copy-paste docs code -Rejected as the end state. Unversioned, untested code with no theming contract is not an answer to an 8-year-old request, and people have already asked for the real component. +Rejected as the end state. This code has no version, no tests, and no theming contract. It is not an answer to an 8-year-old request. People already asked for the real component. ### Option C: Wait for the next major -Rejected. The request has waited since 2018, and shipping now lets us validate the API before it becomes `Menu`. +Rejected. The request waited since 2018. A release now lets us validate the API before it becomes `Menu`. ### Option D: A successor built on Base UI, shipped as public unstable (proposed) -Base UI's `Menu` (`@base-ui/react`, stable since early 2026, maintained by the same team) covers requirements 1-3 out of the box. Verified against its source and tests: hover intent on submenu triggers, RTL-aware submenu keys, Escape closing the innermost submenu, focus returning to the parent item, per-level typeahead, and collision handling that flips and tracks the anchor. Our job is styling, theming, and API surface. +The `Menu` of Base UI (`@base-ui/react`, stable since early 2026, maintained by the same team) covers requirements 1-3 without extra work. We verified these features against its source and its tests: + +- Hover intent on submenu triggers. +- RTL-aware submenu keys. +- Escape closes the innermost submenu. +- Focus returns to the parent item. +- Per-level typeahead. +- Collision handling that flips the submenu and tracks the anchor. + Our work is the style, the theme, and the API surface. ## Proposed solution -Build a Base UI-based successor to `Menu` and follow the Grid lifecycle. A proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) shows it works, and a companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) is where the open questions get tried out. +We propose a successor to `Menu` that uses Base UI. This successor follows the Grid lifecycle. A proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) shows that this works. We test the open questions in a companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)). ### Positioning and lifecycle (decided) -The new component is a successor. It is not a rewrite of the current internals, and not a second namespace that stays forever. +The new component is a successor. It is not a rewrite of the current internals. It is also not a second namespace that stays forever. -| Phase | Component name | What happens | -| :-------------- | :--------------- | :----------------------------------------------------------------------------- | -| Now (v9 minors) | `Unstable_Menu2` | Public incubation, a real release. Theme keys and classes are `MuiMenu2*`. | -| Later in v9 | `Menu2` | Stable under the interim name. Today's `Menu` untouched, theme keys unchanged. | -| Next major | `Menu` | `Menu2` becomes the canonical name. | -| Next major | `MenuLegacy` | Today's `Menu` renamed and deprecated, with a codemod. | +| Phase | Component name | What happens | +| :-------------- | :--------------- | :----------------------------------------------------------------------------------------------- | +| Now (v9 minors) | `Unstable_Menu2` | Public incubation, a real release. The theme keys and the classes are `MuiMenu2*`. | +| Later in v9 | `Menu2` | Stable under the interim name. The current `Menu` does not change. The theme keys do not change. | +| Next major | `Menu` | `Menu2` becomes the canonical name. | +| Next major | `MenuLegacy` | We rename and deprecate the current `Menu`. We supply a codemod. | -This follows Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, old one renamed `GridLegacy`, [#45363](https://github.com/mui/material-ui/pull/45363)). Each rename breaks early adopters, but it is codemoddable and we have accepted that trade before. The `2` suffix is what makes a stable phase before the major possible: an unsuffixed name would collide with the `Menu` we still ship. +This plan follows Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, old one renamed `GridLegacy`, [#45363](https://github.com/mui/material-ui/pull/45363)). Each rename breaks early adopters. But a codemod can do the rename, and we accepted this trade before. The `2` suffix makes a stable phase before the major release possible. A name without the suffix would collide with the `Menu` that we still release. -Only directories, subpaths, and exports carry the `Unstable_` prefix. Internal names are `Menu2*` and theme keys are `MuiMenu2*`, which our lint rules require and which matches Grid2. This also means theme keys survive the `Unstable_Menu2` -> `Menu2` step unchanged; only the final promotion to `Menu` renames them. +Only the directories, the subpaths, and the exports use the `Unstable_` prefix. The internal names are `Menu2*` and the theme keys are `MuiMenu2*`. Our lint rules require this, and it matches Grid2. Therefore the theme keys do not change in the `Unstable_Menu2` -> `Menu2` step. Only the final promotion to `Menu` renames them. -Imports follow our usual convention: flat names, one component per subpath, no short aliases like `Root` or `Item`. +The imports follow our usual convention: flat names, one component for each subpath, and no short aliases such as `Root` or `Item`. ```jsx import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; ``` -Because the subpaths use default exports, adopters can drop the `Unstable_` prefix locally, so their JSX already reads like the future API. Barrel exports come at graduation. +The subpaths use default exports. Therefore adopters can remove the `Unstable_` prefix in their own code. Their JSX then looks like the future API. We add the barrel exports at graduation. + +A checklist controls graduation. It is not a judgment call. The checklist has four items: -Graduation is gated by a checklist, not a judgment call: the conformance suite passing minus documented skips, theme registration parity, the pinned `data-*` boundary, and design sign-off. +- The conformance suite passes, minus the documented skips. +- The theme registration is at parity. +- The `data-*` boundary is pinned. +- The design team approves. ### Rules for Base UI-backed components (Menu is the first) -The rule: a Base UI-backed component should be indistinguishable from any other Material UI component. Only the behavior underneath is new. +The rule: a Base UI-backed component must look the same as any other Material UI component. Only the behavior below the surface is new. Decided: -- **Customization:** `slots`/`slotProps`, same as every other component. We use Base UI's `render` prop internally to inject our styled elements; it is not the documented contract. -- **Slot plumbing:** reuse the `@mui/utils` helpers (`useSlotProps`, `mergeSlotProps`, `appendOwnerState`, `resolveComponentProps`) instead of writing our own. Done in the experiment. Three Base UI-specific helpers are left, and they should move into the shared utilities: hiding a Base UI part's own props when a slot is swapped for a plain element, bridging Base UI's `className={(state) => string}` callbacks to our utility classes, and inferring `nativeButton` from the root slot. -- **Style reuse:** share the styled element itself through `render` where possible, and fall back to a shared style function. Today classic `Menu`/`MenuItem` and the new parts read the same style modules, so there is one source of truth. Two regressions taught us to audit shared styles per consumer: a `maxHeight: calc(100% - 96px)` that meant "the viewport" inside the old Modal but not inside the new popup, and an `[item] + divider` margin that broke when Base UI added focus-guard elements next to an open submenu trigger. Parts should own their spacing rather than depend on sibling selectors. -- **Presentational props stay:** `dense`, `disableGutters`, `divider`, `selected`. We own presentation, Base UI owns behavior. The line is styling versus behavior, not old versus new, so individual rarely-used props can still go case by case. -- **Dependency:** `@base-ui/react` becomes a direct dependency of `@mui/material`, like `@popperjs/core`. Users never install or import it. Two conditions: version bumps are reviewed, never auto-merged; and conformance tests pin the `data-*` attributes we rely on, so an upstream rename fails CI instead of quietly breaking styles. -- **Docs tooling:** the component adapts to our tooling, not the other way around. -- **Theme registration:** normal `defaultProps`/`styleOverrides`/`variants` per part, under `MuiMenu2*` keys. +- **Customization:** `slots`/`slotProps`, the same as every other component. We use Base UI's `render` prop internally to inject our styled elements. The `render` prop is not the documented contract. +- **Slot plumbing:** reuse the `@mui/utils` helpers (`useSlotProps`, `mergeSlotProps`, `appendOwnerState`, `resolveComponentProps`). Do not write our own helpers. We did this in the experiment. Three Base UI-specific helpers remain, and they should move into the shared utilities. The first helper hides a Base UI part's own props when a plain element replaces a slot. The second helper connects Base UI's `className={(state) => string}` callbacks to our utility classes. The third helper infers `nativeButton` from the root slot. +- **Style reuse:** share the styled element itself through `render` where possible. If this is not possible, use a shared style function. Today the classic `Menu`/`MenuItem` and the new parts read the same style modules. Therefore there is one source of truth. Two regressions taught us to audit the shared styles for each consumer. The first regression was a `maxHeight: calc(100% - 96px)` value. This value meant "the viewport" inside the old Modal, but it did not mean this inside the new popup. The second regression was an `[item] + divider` margin. This margin broke when Base UI added focus-guard elements next to an open submenu trigger. Each part must control its own spacing. A part must not depend on sibling selectors. +- **Presentational props stay:** `dense`, `disableGutters`, `divider`, `selected`. We control presentation and Base UI controls behavior. The line is styling against behavior, not old against new. Therefore we can still decide each rarely-used prop separately. +- **Dependency:** `@base-ui/react` becomes a direct dependency of `@mui/material`, like `@popperjs/core`. Users never install or import it. There are two conditions. First, we review each version increase and we never auto-merge it. Second, the conformance tests pin the `data-*` attributes that we use. Therefore an upstream rename makes CI fail and does not break the styles silently. +- **Docs tooling:** the component adapts to our tooling. Our tooling does not adapt to the component. +- **Theme registration:** normal `defaultProps`/`styleOverrides`/`variants` for each part, under `MuiMenu2*` keys. Still up for discussion: -- **Where styling state lives:** `Mui-*` classes and `ownerState` stay the public contract for `styleOverrides`, `variants`, and `sx`. Internally we can read Base UI `data-*` attributes for positional state, like Tooltip already does with `[data-popper-placement]`. Rule of thumb: anything users theme gets a class; internal positional state stays a data attribute. -- **Prop types:** extend Base UI's types and `Omit` what we hide or rename, so we inherit new props automatically. This means callbacks keep Base UI signatures, for example `onOpenChange(open, eventDetails)` instead of `onClose(event, reason)`. It works in the experiment, with one limitation: our proptypes generator cannot read types from `node_modules`, so runtime PropTypes only cover locally declared props. Types are unaffected, and PropTypes are stripped in production. Teaching the generator to follow external types is a separate infra task. -- **Testing:** reuse `describeConformance` for the Material UI contract, and rerun the existing Menu behavior tests against the successor, annotating every skip. Parity should be shown by those suites passing, not by new tests written for the successor. All 14 rendering parts now run conformance, which let us delete the hand-written theming and slots tests. Two adaptations may belong in the shared harness: portalled roots need a way to point the harness at the real root element, and the nested submenu popup only mounts with real layout, so its suite runs in the browser project only. +- **Where styling state lives:** the `Mui-*` classes and `ownerState` stay the public contract for `styleOverrides`, `variants`, and `sx`. Internally we can read the Base UI `data-*` attributes for positional state. Tooltip already does this with `[data-popper-placement]`. The rule is simple. Each item that users theme gets a class. Internal positional state stays a data attribute. +- **Prop types:** extend Base UI's types and use `Omit` for the props that we hide or rename. Therefore we inherit new props automatically. As a result, the callbacks keep the Base UI signatures, for example `onOpenChange(open, eventDetails)` instead of `onClose(event, reason)`. This works in the experiment, but there is one limitation. Our proptypes generator cannot read types from `node_modules`. Therefore the runtime PropTypes cover only the props that we declare locally. This does not change the types. Production builds do not include the PropTypes. To teach the generator to follow external types is a separate infra task. +- **Testing:** reuse `describeConformance` for the Material UI contract. Also run the existing Menu behavior tests again against the successor, and add a note to each skip. Those suites must pass to show parity. Do not write new tests for the successor to show parity. All 14 rendering parts now run conformance. Therefore we deleted the manual theming and slots tests. Two adaptations can belong in the shared harness. First, portalled roots need a method to point the harness at the real root element. Second, the nested submenu popup mounts only with real layout. Therefore its suite runs in the browser project only. ### API shape (settled by review) -Agreed rules, instead of one global flat-vs-compound choice: +The review agreed on these rules. We do not make one global choice between a flat API and a compound API. -- Stay as close to today's `Menu` as the foundation allows. -- Wiring parts (Portal, Positioner, Popup, Paper, List) collapse into one flat container, configured through `slots`/`slotProps`. -- Parts users customize per instance stay separate components: items, submenu triggers, checkbox and radio items. +- The new API stays as near to today's `Menu` as the foundation permits. +- The wiring parts (Portal, Positioner, Popup, Paper, List) become one flat container. You configure this container with `slots`/`slotProps`. +- The parts that users change for each instance stay separate components. These parts are the items, the submenu triggers, the checkbox items, and the radio items. -Before finalizing the shape we needed to know how far the behavior differs. That benchmark now exists as a test next to the component (`Menu2Benchmark.test.tsx`), so every row below is measured in a real browser, not read off the source. +Before the final decision on the shape, we had to know how much the behavior differs. A test next to the component now gives this benchmark (`Menu2Benchmark.test.tsx`). A real browser measures each row below. We do not read the rows from the source code. #### Benchmark results -| Dimension | Classic `Menu` | Successor | Verdict | -| :------------------------- | :------------------------------------------------- | :------------------------------------------ | :-------------------------- | -| Opening from the trigger | no trigger part; you wire `onClick` yourself | `Trigger` opens on click and ArrowDown | successor adds behavior | -| Initial focus, keyboard | n/a (no trigger part) | first item highlighted | matches the menu pattern | -| Initial focus, pointer | selected item, or first item when none is selected | nothing highlighted, focus on the popup | **difference** | -| Disabled items, keyboard | skipped | focusable, per the WAI-ARIA menu pattern | **difference** | -| Escape | closes, focus returns to the trigger | same | same | -| Tab while open | closes, and focus goes back to the trigger | closes, and focus moves to the next element | same close, different focus | -| Body scrolling while open | locked | locked | same | -| Backdrop element | rendered | opt-in (see open question 4) | **difference** | -| Sibling content while open | `aria-hidden` | left in the accessibility tree | **difference** | -| Default placement | under the trigger, left aligned | under the trigger, left aligned | same | +| Dimension | Classic `Menu` | Successor | Verdict | +| :------------------------- | :------------------------------------------------------- | :--------------------------------------- | :-------------------------- | +| Open from the trigger | no trigger part; you connect `onClick` | `Trigger` opens on click and ArrowDown | the successor adds behavior | +| Initial focus, keyboard | n/a (no trigger part) | the menu highlights the first item | matches the menu pattern | +| Initial focus, pointer | the selected item, or the first item if none is selected | no highlight; focus stays on the popup | **difference** | +| Disabled items, keyboard | the menu skips them | focusable, per the WAI-ARIA menu pattern | **difference** | +| Escape | closes; focus returns to the trigger | same | same | +| Tab while open | closes; focus goes back to the trigger | closes; focus moves to the next element | same close, different focus | +| Body scroll while open | the menu locks it | the menu locks it | same | +| Backdrop element | the menu renders it | opt-in (see open question 4) | **difference** | +| Sibling content while open | `aria-hidden` | it stays in the accessibility tree | **difference** | +| Default placement | under the trigger, left aligned | under the trigger, left aligned | same | -What this means: the successor is closer to a drop-in than expected. Placement, scroll locking, Escape, and Tab-closes-the-menu already match. Keyboard opening cannot be compared directly because the classic Menu has no trigger part, but both end up highlighting an item, so they agree in practice. Five differences remain: +These results have this meaning. The successor is nearer to a drop-in replacement than we expected. The placement, the scroll lock, Escape, and Tab-closes-the-menu already match. We cannot compare directly how the two menus open with the keyboard, because the classic Menu has no trigger part. But both menus highlight an item, thus they agree in practice. Five differences stay: -- **Keep, they are accessibility fixes.** Disabled items stay focusable and sibling content stays in the accessibility tree. Both follow the WAI-ARIA menu pattern; the classic behavior is the odd one out. The backdrop belongs here too: dismissal no longer needs one, and it is available as a slot. -- **Decided: keep Base UI's behavior** for initial focus when opening with the pointer. Nothing is highlighted, so Enter cannot trigger an item the user never picked, which is how native desktop menus work. This is a deliberate deviation from the APG, not a neutral choice: the [menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) says focus moves to an item when the menu opens and carves out no exception for pointer opening. We are matching native desktop menus instead, and it should ship documented as a deviation. Matching the classic behavior would mean focusing an item ourselves after opening, since Base UI has no `initialFocus` prop on Menu, which fights the library and brings back the accidental-activation risk. This is a documented change for people migrating. -- **Document it.** Where focus goes after Tab. Classic sends it back to the trigger; the successor lets it move on, which is what pressing Tab asks for. +- **Keep, they are accessibility fixes.** The disabled items stay focusable. The sibling content stays in the accessibility tree. Both behaviors follow the WAI-ARIA menu pattern. The classic behavior is the different one. The backdrop is also in this group. The menu no longer needs a backdrop to close, and a slot supplies the backdrop. +- **Decided: keep Base UI's behavior** for the initial focus when the user opens the menu with the pointer. The menu highlights no item. Thus Enter cannot start an item that the user did not select. Native desktop menus work in this way. This is a deliberate deviation from the APG. It is not a neutral choice. The [menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) says that focus moves to an item when the menu opens. The pattern makes no exception when the user opens the menu with the pointer. We match native desktop menus instead. We must release this deviation with documentation. To match the classic behavior, we must move focus to an item after the menu opens, because Base UI has no `initialFocus` prop on Menu. This works against the library and adds the risk of accidental activation again. This is a documented change for the users who migrate. +- **Document it.** Tell the user where focus goes after Tab. The classic Menu sends focus back to the trigger. The successor lets focus move to the next element. This is the usual function of the Tab key. -`variant="selectedMenu"` is dropped, and this one is a lost feature rather than a changed behavior. The prop opens the menu with the current value highlighted. In the code it decides which item is focused when the menu opens, and hides the focus ring for that first moment. Base UI cannot do either: `Menu.Root` has no initial-highlight prop and `Menu.Popup` has no `initialFocus`. Radio items do not fill the gap either. A `RadioGroup` with its second item checked still opens with the first item highlighted, which the benchmark asserts. +We drop `variant="selectedMenu"`. This is a lost feature, not a changed behavior. The prop opens the menu and highlights the current value. In the code, the prop selects which item gets the focus when the menu opens. The prop also hides the focus ring at that first moment. Base UI cannot do these two things. `Menu.Root` has no prop for the initial highlight, and `Menu.Popup` has no `initialFocus`. The radio items do not replace this feature. A `RadioGroup` with a checked second item still opens with the first item highlighted. The benchmark asserts this behavior. -Dropping it also narrows the pointer-focus difference above without closing it: the classic Menu highlights an item on pointer open under either variant. +This change also makes the pointer-focus difference above smaller, but it does not remove the difference. The classic Menu highlights an item on a pointer open with both variants. -This is deliberate upstream, not an oversight. `initialFocus` exists on Base UI's Combobox, Dialog, Drawer, and Popover, but not Menu, and a maintainer explains why in [base-ui#2143](https://github.com/mui/base-ui/issues/2143): "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items." The same thread recommends a different component for this case: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." No open request exists to change it, so the answer is the pattern boundary, not an upstream feature request. +Base UI does this deliberately. It is not a mistake. `initialFocus` exists on Base UI's Combobox, Dialog, Drawer, and Popover, but not on Menu. A maintainer gives the reason in [base-ui#2143](https://github.com/mui/base-ui/issues/2143): "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items." The same thread recommends a different component for this case: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." No open request asks for a change. Thus the answer is the boundary between the patterns, not a feature request to Base UI. -For the API shape this means a flat container really can carry today's surface, and the migration story is "same component, a few documented behavior changes" rather than a rewrite. +For the API shape, this result has two meanings. A flat container can give today's API. The migration is "the same component with a few documented behavior changes", not a rewrite. -Settled by review. The experiment started fully compound, one component per Base UI part. It is now one component per menu, at both levels: the root carries the trigger and the popup surface, and a submenu is the same shape one level down. +The review settled the shape. The experiment started with a fully compound API, with one component for each Base UI part. Now there is one component for each menu, at the two levels. The root holds the trigger and the popup. A submenu has the same shape one level lower. ```jsx Options} slotProps={{ paper: { elevation: 4 } }}> @@ -171,188 +190,191 @@ Settled by review. The experiment started fully compound, one component per Base ``` -The trigger and popup parts still exist, but they are internal; only their class hooks are exported, so `styleOverrides` and `sx` are unaffected. +The trigger part and the popup part still exist, but they are internal. We export only their class hooks. Thus `styleOverrides` and `sx` do not change. -Three things fell out of building it: +Three results come from this work: -- The root `trigger` takes an element and Base UI's `render` merges the behavior into it, so the caller keeps their own component. Anything else renders inside the default trigger. -- The submenu `trigger` takes content, not an element. This is the one place the levels cannot match: a submenu trigger is already a menu item, so passing `` nests an item inside an item and the submenu never opens. -- Wrapping a trigger in a `Tooltip` got harder. The compound shape wrapped the element directly; now the wrapper has to move into the trigger's root slot, which costs a `forwardRef` component. The test suite and the recipes page both hit it. +- The root `trigger` takes an element. Base UI's `render` merges the behavior into that element. Thus the caller keeps their own component. Other content renders inside the default trigger. +- The submenu `trigger` takes content, not an element. This is the one place where the two levels cannot match. A submenu trigger is already a menu item. A `` in the `trigger` puts an item inside an item, and the submenu does not open. +- It is now more difficult to put a trigger in a `Tooltip`. The compound shape put the `Tooltip` directly around the element. Now the wrapper must move into the root slot of the trigger. This step needs a `forwardRef` component. The test suite and the recipes page both have this problem. -Omit `trigger` and drive the menu with `open` and `anchor` for the classic controlled pattern, which is what the context-menu recipe uses. +For the classic controlled pattern, omit `trigger` and control the menu with `open` and `anchor`. The context-menu recipe uses this pattern. -Behavior worth stating whatever shape we pick: +These behaviors are true for each shape that we select: -- Submenus open on hover by default (100ms delay, hover intent on close). New compared to the classic Menu, matches native menus, and is configurable. -- Escape closes the innermost submenu and returns focus to its trigger. Closing the whole tree is opt-in. -- Opening with the pointer highlights nothing; opening with the keyboard highlights the first item. -- While a submenu is open, Base UI puts focus-guard elements next to its trigger. See open question 7. -- The menu surface caps its height against the available space and scrolls internally, instead of the old viewport-only clamp. +- The submenus open on hover by default, with a delay of 100ms and hover intent on close. This behavior is new when you compare it to the classic Menu. It matches native menus, and you can configure it. +- Escape closes the innermost submenu and moves focus back to its trigger. To close the full tree, you must select that option. +- If the user opens the menu with the pointer, the menu highlights no item. If the user opens the menu with the keyboard, the menu highlights the first item. +- When a submenu is open, Base UI puts focus-guard elements next to its trigger. See open question 7. +- The menu limits its height to the available space and scrolls inside itself. The old behavior used only the viewport for this limit. ### Compatibility - **Unchanged:** item props (`dense`, `disableGutters`, `divider`, `selected`, `disabled`), visuals, theming, `keepMounted`, `container`. -- **Changed on purpose:** initial focus (above), open/close control (`open`/`defaultOpen` + `onOpenChange` instead of controlled-only `open` + `onClose`), positioning (`anchor`/`side`/`align` instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS instead of `TransitionComponent`). +- **Changed on purpose:** + - Initial focus. See above. + - Open and close control. Use `open`/`defaultOpen` and `onOpenChange` instead of a controlled-only `open` with `onClose`. + - Position. Use `anchor`/`side`/`align` instead of `anchorEl`/`anchorOrigin`/`transformOrigin`. + - Transitions. Use CSS instead of `TransitionComponent`. - **Dropped:** - - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that hurt accessibility. `modal` and `finalFocus` cover the real cases. - - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: selection state does not belong on menu items, and initial focus is handled internally. - - `anchorOrigin`, `transformOrigin`, `anchorReference`, `anchorPosition`, `PopoverClasses`, `transitionDuration`, `slots.transition`, `action.updatePosition`: replaced by the new positioning. - - `disablePortal`: Base UI popups are always portalled. + - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: these escape hatches decrease accessibility. `modal` and `finalFocus` cover the real cases. + - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: the selection state does not belong on the menu items. The component controls the initial focus internally. + - `anchorOrigin`, `transformOrigin`, `anchorReference`, `anchorPosition`, `PopoverClasses`, `transitionDuration`, `slots.transition`, `action.updatePosition`: the new position props replace these props. + - `disablePortal`: Base UI popups always use a portal. -The full prop mapping is in the appendix. +The appendix contains the full prop map. ### New capabilities - Submenus, with correct keyboard, hover, and ARIA behavior. - Checkbox and radio items (`menuitemcheckbox` / `menuitemradio` with `aria-checked` and indicators). -- Groups with labels wired up via `aria-labelledby`. +- Groups with labels. The component connects the labels with `aria-labelledby`. - A trigger that sets `aria-haspopup`, `aria-expanded`, and `aria-controls` for you. -- Typeahead, with a per-item `label` override. -- Hover-open with delays, and a cancelable `onOpenChange` that says why the menu is closing. +- Typeahead, with a `label` override on each item. +- Hover-open with delays. A cancelable `onOpenChange` tells you why the menu closes. ### Where the experiment stands -The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) covers submenus, checkbox and radio items, groups, matching visuals, theme registration, and tests, at a small fixed cost on the `@mui/material` barrel. The companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) moves it toward the rules above. +The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) covers submenus, checkbox and radio items, groups, visuals that match the classic Menu, theme registration, and tests. It adds a small fixed cost to the `@mui/material` barrel. The companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)) moves it toward the rules above. Done: -- Renamed to the `Unstable_Menu2` lifecycle naming, one component per subpath. -- Docs tooling special-casing removed. -- Classic and successor share the same style modules. -- Composed list primitives still work inside items (`ListItemText inset` lines up with the icon column). `inset` is a `ListItemText` prop, not a menu item prop, so nothing needed implementing. -- Prop types inherit from Base UI, so the roots get `actionsRef` and future props for free. +- We renamed the components to the `Unstable_Menu2` lifecycle name, one component for each subpath. +- We removed the special cases in the docs tooling. +- The classic component and the successor share the same style modules. +- Composed list primitives still work inside the items. `ListItemText inset` aligns with the icon column. `inset` is a `ListItemText` prop, not a menu item prop, so we implemented nothing. +- The prop types inherit from Base UI. The roots get `actionsRef` and future props at no cost. - Top-level `elevation` on the popup, default 8. -- Default open/close animation, and an opt-in backdrop slot. +- A default open and close animation, and a backdrop slot that you opt in to. Left: -- Style sharing is at the style-function level; sharing the styled element itself is better where it fits. -- The Base UI-specific slot helpers should move into `@mui/utils`. -- The existing Menu behavior tests still have to be adapted. The flat container was the blocker and it now exists, so this is next. The benchmark covers opening, focus, disabled items, dismissal, scroll locking, backdrop treatment, and placement; it does not yet cover default item close behavior, link items, checkbox and radio activation, controlled callback reasons, outside-pointer dismissal, hover and submenu timing, RTL submenu navigation, or context-menu focus. -- The `MenuItem` and `Select` side of the lifecycle is unanswered. See open question 13. +- The components share styles at the style-function level. It is better to share the styled element itself where this fits. +- The slot helpers for Base UI should move into `@mui/utils`. +- We must still adapt the existing Menu behavior tests. The flat container was the blocker, and it now exists, so this task is next. The benchmark covers the open action, focus, disabled items, dismissal, scroll locking, backdrop treatment, and placement. The benchmark does not yet cover these areas: the default item close behavior, link items, checkbox and radio activation, controlled callback reasons, outside-pointer dismissal, hover and submenu timing, RTL submenu navigation, and context-menu focus. +- The `MenuItem` and `Select` part of the lifecycle is unanswered. See open question 13. ### Open questions -1. **Ripple.** Items are plain elements, so there is no ripple by default. Users can get one today by swapping the item root: `slots={{ root: ButtonBase }}` produces a real ripple, but it loses our item styling, because that CSS lives on the default root. Putting a `ButtonBase` inside an item is not an option: it places a focusable element inside a `menuitem`, which highlights the item on open ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)) and is the pattern Base UI rules out. So: ship ripple by default (make the item root a styled `ButtonBase`, bringing back `disableRipple`), or leave it to the root slot and document the recipe? Design call, no accessibility stake. -2. **Default animation. Decided: ship one.** The popup has a CSS transition modelled on the classic `Grow`, using the same scale and the theme's durations, so migrating apps do not lose their animation. It is not identical: the classic Menu passes `transitionDuration="auto"`, so `Grow` derives the duration from the menu's height through `getAutoHeightDuration`, while a CSS transition has to use fixed `enteringScreen`/`leavingScreen` values. Tall menus animate faster than they used to, which is worth comparing before this is called done. It turns off under `prefers-reduced-motion` and can be overridden through `slotProps.popup`, `styleOverrides`, or the theme. It has to live on the popup element, because Base UI waits for animations on that element before unmounting; a transition on a child would be cut off when closing. One practical effect: anything measuring the menu right after it opens now reads it mid-animation, so tests have to wait for the transition to finish. -3. **`elevation` prop. Decided: keep it.** The popup takes `elevation` (default 8) and passes it to the Paper slot, so the common case does not need `slotProps.paper`. -4. **Backdrop. Decided: surface it.** `slots.backdrop` and `slotProps.backdrop` mirror the classic Menu, with a default element that is transparent and click-through, like the classic invisible backdrop. Dismissal stays with Base UI's outside-press handling. Dimming is `slotProps={{ backdrop: { sx: { bgcolor: ... } } }}`. One difference from classic: it only renders when you opt in, because always rendering it would give non-modal menus a full-screen layer they never had, and modal menus already get Base UI's own inert backdrop. -5. **Imperative actions. Decided: use Base UI's `actionsRef` as-is.** It arrives with the inherited types and gives `close()` and `unmount()`. We do not rename it or build our own `action` ref: renaming means our API drifts from Base UI's for no gain, and rebuilding duplicates what Base UI already does. The classic `action.updatePosition()` has no equivalent because the position updates automatically. -6. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor, but the recipe has a focus bug we hit ourselves: a menu with no trigger has nothing to return focus to, so Base UI falls back to the last element it remembers, which can be a trigger from an unrelated menu on the page. The recipe has to pass `finalFocus` pointing at the element that was right-clicked, and nothing in the API tells you that. It looks fine until a second menu exists. A component wrapping Base UI's `ContextMenu` would handle it internally, since its trigger is the right-click surface. So: document the recipe, or ship the component? -7. **Styling around submenu triggers. Decided: do both.** While a submenu is open, Base UI keeps focus-guard elements next to the trigger, because tab order depends on them. Any CSS using sibling selectors (`+`, `~`, `:last-child`) around a trigger breaks the moment a submenu opens. We hit this bug ourselves, and our parts now own their spacing instead. We will document the rule (the guards are identifiable via `data-base-ui-focus-guard`) and separately ask upstream whether the guards could sit outside the item list, which would help every Base UI user. -8. **Bundle size.** Base UI adds real weight per component. Do we want a size check per Base UI-backed component? -9. **Accessibility: what is still ours.** Base UI owns the roles, keyboard behavior, focus, and dismissal. Everything visual is ours, and that is where the remaining risk sits. Three concrete gaps. Forced colors is now covered: `enhanceHighContrast` handles the five item parts and both indicators, keyed on `highlighted`. Two gaps are left. The highlight itself is a background tint (`action.focus`, roughly 1.3:1) with the native outline removed, which is parity with the classic item but below the 3:1 that [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) asks of a focus indicator, so it needs design and accessibility sign-off rather than being inherited. And nothing checks an open menu automatically: axe runs only in the visual regression suite, which never interacts with the page and skips the menus page for exactly that reason, and `describeConformance` has no accessibility assertions. -10. **Other defaults.** Two are decided: pointer-opened menus highlight nothing, and submenus open on hover. There is no question about modality: Base UI's `modal` defaults to `true`, the classic Menu is always modal, and the benchmark measured scroll locking as the same. The successor only adds the option to turn it off. -11. **SSR, `'use client'`, ref typing.** All 18 modules carry `'use client'`, placed the way the classic Menu places it, and Base UI ships the directive on its own menu modules. Nothing verifies that it works: the docs site is Pages Router with `output: 'export'`, so no server component boundary is ever evaluated, and an App Router fixture is the only thing that would confirm it. Server rendering is measured rather than open: only the trigger renders on the server, and neither `defaultOpen` nor `keepMounted` changes that, because Base UI creates the portal node in a layout effect. The real question is ref typing. Refs come from Base UI and are wide (`HTMLElement`, `Element`), where the classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. Conformance pins the runtime element; the type stays loose. Narrow per part, or keep parity with Base UI's signatures? - -12. **Theme API. Decided: collapse it too.** `MuiMenu2` and `MuiMenu2Submenu` are now the only theme keys, each carrying `defaultProps`, `variants`, and `styleOverrides` for their slots: `root`, `trigger`, `backdrop`, `paper`, `list` for the menu, and `root`, `trigger`, `paper`, `list` for the submenu. `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot` are gone, so the parts are internal in theming as well as in JSX. The class hooks stay per element, because CSS still has to select distinct nodes and their states; that is the same split Autocomplete has between one theme key and per-slot classes. -13. **`MenuItem` and `Select` in the lifecycle.** The rollout below covers `Menu2` -> `Menu` and classic `Menu` -> `MenuLegacy`, and says nothing about items. `Select` renders a classic `Menu` with `MenuItem` as a listbox, so promotion has to answer: does `Menu2Item` become `MenuItem`, does today's `MenuItem` become `MenuItemLegacy`, what happens to the `MuiMenuItem` theme key, and does `Select` keep the legacy item, get a private listbox item, or get rewritten first? This is the largest remaining gap in the migration story. -14. **How much Base UI shows through.** `Menu2Props` extends `BaseMenu.Root.Props` and the dependency is `^1.6.0`, so a Base minor can add public Menu props that never went through our own API review, and runtime PropTypes stay partial because the generator cannot read types from `node_modules`. That sits awkwardly next to the rule that users should not need to know Base UI is involved, and next to keeping `actionsRef` under its Base name. Either put a Material-owned facade over the root props and callbacks, or accept the inheritance, pin the exact version, and say plainly that Base UI's API is part of the Material contract. +1. **Ripple.** The items are plain elements. Therefore the items have no ripple by default. Users can add a ripple today. They replace the item root with `slots={{ root: ButtonBase }}`. This gives a real ripple. But it removes our item styling, because that CSS is on the default root. A `ButtonBase` inside an item is not possible. It puts a focusable element inside a `menuitem`. This highlights the item when the menu opens ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)). Base UI does not permit this pattern. So we must make a choice. Do we release a ripple by default? This makes the item root a styled `ButtonBase` and adds `disableRipple` again. Or do we leave the ripple to the root slot and document the procedure? This is a design decision. It has no effect on accessibility. +2. **Default animation. Decided: release one.** The popup has a CSS transition. The transition follows the classic `Grow`. It uses the same scale and the same theme durations. Thus applications that migrate keep their animation. But the two animations are not identical. The classic Menu sends `transitionDuration="auto"`. Then `Grow` calculates the duration from the height of the menu with `getAutoHeightDuration`. A CSS transition must use the fixed `enteringScreen`/`leavingScreen` values. Tall menus now animate more quickly than before. We must compare the two speeds before we complete this work. The transition stops when the user sets `prefers-reduced-motion`. Users can override the transition through `slotProps.popup`, `styleOverrides`, or the theme. The transition must be on the popup element. Base UI waits for the animations on that element before it removes the element. A transition on a child element stops when the menu closes. There is one practical effect. Code that measures the menu immediately after it opens reads the menu during the animation. Therefore tests must wait for the end of the transition. +3. **`elevation` prop. Decided: keep it.** The popup accepts `elevation` (default 8). The popup sends `elevation` to the Paper slot. Therefore the usual case does not need `slotProps.paper`. +4. **Backdrop. Decided: show it.** `slots.backdrop` and `slotProps.backdrop` are the same as in the classic Menu. The default element is transparent and lets clicks through. This is the same as the classic invisible backdrop. The outside-press behavior of Base UI continues to close the menu. To make the background dark, use `slotProps={{ backdrop: { sx: { bgcolor: ... } } }}`. There is one difference from the classic Menu. The backdrop renders only when you request it. If the backdrop always renders, non-modal menus get a full-screen layer that they did not have before. Also, modal menus already get the inert backdrop of Base UI. +5. **Imperative actions. Decided: use Base UI's `actionsRef` as-is.** `actionsRef` comes with the inherited types. It gives `close()` and `unmount()`. We do not rename it. We do not build our own `action` ref. A new name moves our API away from the Base UI API and gives no benefit. A new implementation repeats the work that Base UI already does. The classic `action.updatePosition()` has no equivalent, because the position updates automatically. +6. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor. But the procedure has a focus bug. We found this bug ourselves. A menu with no trigger has no element for the return of focus. Then Base UI uses the last element that it remembers. This element can be a trigger from a different menu on the page. The procedure must send `finalFocus` with the element that the user right-clicked. The API does not tell you this. The result looks correct until a second menu exists. A component that wraps the Base UI `ContextMenu` corrects this internally, because its trigger is the right-click surface. So we must make a choice. Do we document the procedure, or do we release the component? +7. **Styling around submenu triggers. Decided: do both.** When a submenu is open, Base UI keeps focus-guard elements adjacent to the trigger, because the tab order needs them. CSS that uses the sibling selectors (`+`, `~`, `:last-child`) near a trigger fails when a submenu opens. We found this bug ourselves. Now each of our parts controls its own spacing. We will document this rule. You can identify the guards with `data-base-ui-focus-guard`. We will also ask the Base UI team if the guards can go outside the item list. That change helps every Base UI user. +8. **Bundle size.** Base UI adds much weight to each component. Do we want a size check for each component that uses Base UI? +9. **Accessibility: what is still ours.** Base UI controls the roles, the keyboard behavior, the focus, and the dismissal. We control all the visual parts. The remaining risk is in the visual parts. There are three concrete gaps. We closed the forced-colors gap. `enhanceHighContrast` controls the five item parts and the two indicators. It uses the `highlighted` state. Two gaps are open. The highlight is a background tint (`action.focus`, approximately 1.3:1), and the native outline is not present. This is parity with the classic item. But it is less than the 3:1 ratio that [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) requires for a focus indicator. Therefore the design team and the accessibility team must approve it. We must not inherit it. Also, no test examines an open menu automatically. axe runs only in the visual regression suite. That suite does not interact with the page, and for this reason it skips the menus page. `describeConformance` has no accessibility assertions. +10. **Other defaults.** We decided two defaults. A menu that a pointer opens highlights no item. A submenu opens on hover. Modality is not a question. The Base UI `modal` prop has the default value `true`. The classic Menu is always modal. The benchmark measured the same scroll lock in both menus. The successor only adds the option to disable the modal behavior. +11. **SSR, `'use client'`, ref typing.** All 18 modules contain `'use client'`. We put the directive in the same position as the classic Menu. Base UI also includes the directive in its own menu modules. No test confirms that the directive works. The documentation site uses the Pages Router with `output: 'export'`. Therefore the site never evaluates a server component boundary. Only an App Router fixture can confirm the behavior. We measured the server rendering, so it is not an open question. Only the trigger renders on the server. `defaultOpen` and `keepMounted` do not change this, because Base UI creates the portal node in a layout effect. The real question is the ref typing. The refs come from Base UI and are wide (`HTMLElement`, `Element`). The classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. The conformance tests fix the runtime element, but the type stays wide. Do we make the type narrow for each part, or do we keep parity with the Base UI signatures? + +12. **Theme API. Decided: collapse it too.** `MuiMenu2` and `MuiMenu2Submenu` are now the only theme keys. Each key carries `defaultProps`, `variants`, and `styleOverrides` for its slots. The menu slots are `root`, `trigger`, `backdrop`, `paper`, and `list`. The submenu slots are `root`, `trigger`, `paper`, and `list`. We removed `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot`. Therefore the parts are internal in the theme and in the JSX. Each element keeps its own class hook, because CSS must still select the different nodes and their states. Autocomplete has the same division between one theme key and the classes for each slot. +13. **`MenuItem` and `Select` in the lifecycle.** The rollout plan below covers `Menu2` -> `Menu` and classic `Menu` -> `MenuLegacy`. The plan says nothing about the items. `Select` renders a classic `Menu` with `MenuItem` as a listbox. Therefore the promotion must answer four questions. Does `Menu2Item` become `MenuItem`? Does today's `MenuItem` become `MenuItemLegacy`? What happens to the `MuiMenuItem` theme key? Does `Select` keep the legacy item, get a private listbox item, or get a new implementation first? This is the largest open gap in the migration plan. +14. **How much Base UI shows through.** `Menu2Props` extends `BaseMenu.Root.Props`, and the dependency is `^1.6.0`. Therefore a Base UI minor version can add public Menu props. These props do not pass our own API review. The runtime PropTypes stay incomplete, because the generator cannot read the types in `node_modules`. This is a problem for the rule that users do not need to know about Base UI. It is also a problem for the `actionsRef` name from Base UI. We have two options. We put a Material UI facade over the root props and the callbacks. Or we accept the inheritance, pin the exact version, and state clearly that the Base UI API is a part of the Material UI contract. ### Rollout plan -1. Behavior benchmark: **done**, results above. -2. Design phase for the API shape, tried out in the companion experiment so each question is answered against a real preview. -3. Ship `Unstable_Menu2` in a v9 minor, with conformance tests, API docs, and demos on the Menu page. -4. Iterate on feedback, then stabilize as `Menu2` once the graduation checklist passes. -5. Next major: promote `Menu2` to `Menu`, rename the old one to `MenuLegacy`, and ship the migration guide and codemods. +1. Behavior benchmark: **done**. The results are above. +2. Design phase for the API shape. We try the design in the companion experiment. Then we answer each question against a real preview. +3. Release `Unstable_Menu2` in a v9 minor version, with conformance tests, API docs, and demos on the Menu page. +4. Make changes from the feedback. Then make `Menu2` stable when it passes the graduation checklist. +5. Next major version: promote `Menu2` to `Menu`, rename the old component to `MenuLegacy`, and release the migration guide and the codemods. ### Appendix: full prop mapping
1. Open and close -| Classic Menu | New equivalent | Notes | -| :--------------------------------- | :--------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- | -| `open` (required, controlled-only) | `open` + `defaultOpen` | uncontrolled is now possible | -| `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, `item-press`; can be canceled, and exposes the native event | -| n/a | `onOpenChangeComplete(open)` | replaces `onTransitionExited` | +| Classic Menu | New equivalent | Notes | +| :--------------------------------- | :--------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` (required, controlled-only) | `open` + `defaultOpen` | The uncontrolled mode is now possible. | +| `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | The reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, and `item-press`. You can cancel the change. The callback gives the native event. | +| n/a | `onOpenChangeComplete(open)` | This prop replaces `onTransitionExited`. |
2. Positioning -| Classic Menu / Popover | New equivalent | Notes | -| :---------------------------------------------------- | :------------------------------------------------------------------------------------ | :------------------------------------- | -| `anchorEl` | `anchor` | also accepts refs and virtual elements | -| `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | finer control | -| `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | see open question 6 | -| `marginThreshold` (default 16) | `collisionPadding` (default 5) | same idea | -| `anchorReference="none"` | omit `anchor`, position via CSS | same | -| `action.updatePosition()` | automatic | `disableAnchorTracking` to opt out | -| -- | `collisionBoundary`, `sticky`, `collisionAvoidance`, `positionMethod`, `arrowPadding` | new | +| Classic Menu / Popover | New equivalent | Notes | +| :---------------------------------------------------- | :------------------------------------------------------------------------------------ | :------------------------------------------------- | +| `anchorEl` | `anchor` | It also accepts refs and virtual elements. | +| `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | The new props give more exact control. | +| `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | See open question 6. | +| `marginThreshold` (default 16) | `collisionPadding` (default 5) | The idea is the same. | +| `anchorReference="none"` | Omit `anchor` and set the position with CSS. | The behavior is the same. | +| `action.updatePosition()` | automatic | Use `disableAnchorTracking` to stop this behavior. | +| -- | `collisionBoundary`, `sticky`, `collisionAvoidance`, `positionMethod`, `arrowPadding` | These props are new. |
3. Focus and modality -| Classic Menu | New equivalent | Notes | -| :---------------------------------------- | :------------- | :--------------------------------------------------------------------------------------------------------------------------- | -| `autoFocus`, `disableAutoFocusItem` | internal | keyboard opening highlights the first item; pointer opening highlights nothing | -| `variant` (`menu`/`selectedMenu`) | dropped | not reproducible on Base UI (see above). Use checkbox or radio items so the current value is at least indicated | -| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` covers this | -| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | -| `disableEscapeKeyDown` | dropped | goes against the menu pattern; use `onKeyDown` if you must | -| `disableScrollLock` | removed | `modal={false}` is not an equivalent: it also leaves the rest of the document interactive. The fine-grained control is gone. | -| `hideBackdrop` | backdrop slot | the backdrop is opt-in (open question 4) | -| `disablePortal` | dropped | always portalled | -| `keepMounted`, `container` | same | same behavior | +| Classic Menu | New equivalent | Notes | +| :---------------------------------------- | :------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `autoFocus`, `disableAutoFocusItem` | internal | If the user opens the menu with the keyboard, the menu highlights the first item. If the user opens the menu with the pointer, the menu highlights no item. | +| `variant` (`menu`/`selectedMenu`) | dropped | Base UI cannot do this (see above). Use checkbox items or radio items. Then the menu shows the current value at least. | +| `disableAutoFocus`, `disableEnforceFocus` | dropped | The `modal` prop covers this behavior. | +| `disableRestoreFocus` | `finalFocus` | This prop sets an explicit focus target when the menu closes. | +| `disableEscapeKeyDown` | dropped | This prop is against the menu pattern. Use `onKeyDown` if you need it. | +| `disableScrollLock` | removed | `modal={false}` is not an equivalent. It also keeps the rest of the document interactive. The exact control is gone. | +| `hideBackdrop` | backdrop slot | You must add the backdrop yourself (open question 4). | +| `disablePortal` | dropped | The menu always uses a portal. | +| `keepMounted`, `container` | same | The behavior is the same. |
4. Transitions -| Classic Menu | New equivalent | -| :------------------------------------------------------------------ | :-------------------------------------------------- | -| `TransitionComponent` / `slots.transition` (default `Grow`) | CSS via `data-starting-style` / `data-ending-style` | -| `transitionDuration` | CSS `transition-duration` on the popup | -| `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | -| default `Grow` animation | shipped as a CSS default (open question 2) | +| Classic Menu | New equivalent | +| :------------------------------------------------------------------ | :-------------------------------------------------------- | +| `TransitionComponent` / `slots.transition` (default `Grow`) | CSS with `data-starting-style` / `data-ending-style` | +| `transitionDuration` | CSS `transition-duration` on the popup | +| `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | +| default `Grow` animation | We add this animation as a CSS default (open question 2). |
5. Styling and slots -| Classic Menu | New equivalent | Notes | -| :------------------------------------------------------------------ | :------------------------------------------------------------------- | :----------------------------- | -| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list`, `backdrop` | no transition slot (CSS-based) | -| `elevation` (default 8) | `elevation` (default 8, forwarded to the Paper slot) | kept | -| paper `maxHeight: calc(100% - 96px)` (viewport clamp via the Modal) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | collision-aware | -| `slots.backdrop` + `BackdropProps` | `slots.backdrop` + `slotProps.backdrop` | opt-in (open question 4) | -| `PopoverClasses` | n/a | no Popover underneath | +| Classic Menu | New equivalent | Notes | +| :------------------------------------------------------------------------- | :------------------------------------------------------------------- | :---------------------------------------------------- | +| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list`, `backdrop` | There is no transition slot. The transitions use CSS. | +| `elevation` (default 8) | `elevation` (default 8, the component sends it to the Paper slot) | We keep this prop. | +| paper `maxHeight: calc(100% - 96px)` (the Modal clamps it to the viewport) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | The value reacts to collisions. | +| `slots.backdrop` + `BackdropProps` | `slots.backdrop` + `slotProps.backdrop` | You must add the backdrop yourself (open question 4). | +| `PopoverClasses` | n/a | The new component does not use a Popover. |
6. Item props -| Classic MenuItem / MenuList | New equivalent | Notes | -| :----------------------------------------------------------------- | :-------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dense`, `disableGutters`, `divider` | same | kept: we own presentation | -| `` between items | `Separator` part | owns its margins, so spacing stays put while a submenu is open | -| `selected` | same (visual only) | kept. Checkbox and radio items cover real selection. Classic `MenuItem` now derives `aria-checked` from `selected` for checkbox and radio roles ([#48651](https://github.com/mui/material-ui/pull/48651)); our dedicated items own that instead | -| `disabled` | same | `aria-disabled`, item stays focusable | -| `href` / `LinkComponent` | link item | real `` | -| `autoFocus` (item) | dropped | initial focus is internal | -| ripple props | none yet | see open question 1 | -| `focusVisibleClassName`, `onFocusVisible`, `action.focusVisible()` | `highlighted` class and data attributes | style via CSS | -| `MenuList.disableListWrap` | `loopFocus` (default true) | inverse | -| `MenuList.autoFocus`/`autoFocusItem`/`variant` | dropped | internal or legacy | -| `MenuList.disablePadding`, `subheader` | `slotProps.list`, group parts | groups get proper ARIA | +| Classic MenuItem / MenuList | New equivalent | Notes | +| :----------------------------------------------------------------- | :-------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dense`, `disableGutters`, `divider` | same | We keep these props, because we control the presentation. | +| `` between items | `Separator` part | The `Separator` part controls its own margins. The spacing does not move when a submenu is open. | +| `selected` | same (visual only) | We keep this prop. The checkbox items and the radio items give the real selection. The classic `MenuItem` now gets `aria-checked` from `selected` for the checkbox role and the radio role ([#48651](https://github.com/mui/material-ui/pull/48651)). Our dedicated items do this instead. | +| `disabled` | same | The item gets `aria-disabled`. The item stays focusable. | +| `href` / `LinkComponent` | link item | The component renders a real ``. | +| `autoFocus` (item) | dropped | The component controls the initial focus. | +| ripple props | none yet | See open question 1. | +| `focusVisibleClassName`, `onFocusVisible`, `action.focusVisible()` | `highlighted` class and data attributes | Set the style with CSS. | +| `MenuList.disableListWrap` | `loopFocus` (default true) | The value is the inverse. | +| `MenuList.autoFocus`/`autoFocusItem`/`variant` | dropped | These props are internal or legacy. | +| `MenuList.disablePadding`, `subheader` | `slotProps.list`, group parts | The groups get correct ARIA attributes. |
- ## Resources and benchmarks Proof of concept and experiment: - PoC: [#48663](https://github.com/mui/material-ui/pull/48663) ([demo](https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/)) - Companion playground: [#48823](https://github.com/mui/material-ui/pull/48823) -- Bundle impact on `@mui/material`: the current report on [#48823](https://github.com/mui/material-ui/pull/48823) is +3.54 KB parsed, +685 B gzip. The earlier +77 B figure came from the proof of concept, before the collapsed components and the shared popup module landed; it is stale. Base UI code itself is still only paid when you import the component. +- Bundle impact on `@mui/material`: the current report on [#48823](https://github.com/mui/material-ui/pull/48823) shows +3.54 KB parsed and +685 B gzip. The earlier +77 B number came from the proof of concept. That number is stale, because we merged the collapsed components and the shared popup module after it. The user pays for the Base UI code only when the user imports the component. Demand: @@ -370,9 +392,9 @@ Direction and precedent: - Maintainer statement (Dec 2024): [#11723 comment](https://github.com/mui/material-ui/issues/11723#issuecomment-2556390056) -- "Material UI will adopt (this new) Base UI component in its next major release." - Grid lifecycle: [#45363](https://github.com/mui/material-ui/pull/45363) -- Menubar docs page built on Base UI: [react-menubar](https://mui.com/material-ui/react-menubar/) (from [#47616](https://github.com/mui/material-ui/pull/47616)) +- Menubar docs page that uses Base UI: [react-menubar](https://mui.com/material-ui/react-menubar/) (from [#47616](https://github.com/mui/material-ui/pull/47616)) - [Base UI Menu](https://base-ui.com/react/components/menu) and [releases](https://base-ui.com/react/overview/releases) -- Why Base UI's Menu has no `initialFocus`: [base-ui#2143](https://github.com/mui/base-ui/issues/2143) +- Why the Base UI Menu has no `initialFocus`: [base-ui#2143](https://github.com/mui/base-ui/issues/2143) Community workarounds: From 2635cd1d280f9539ea79d3e5af3efe9475cf0736 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 4 Aug 2026 15:17:05 +0300 Subject: [PATCH 47/62] Overlap the submenu with its parent menu by default The submenu popup now uses sideOffset -4 and alignOffset -8. The submenu overlaps its parent by 4px, and it starts 8px higher than its trigger. The 8px cancels the top padding of the list, so the first item of the submenu lines up with the trigger row. Base UI applies its own offsets only for a context-menu parent, which are -5 and 2. For a submenu parent it sets the side and the align, and no offset. The overlap in the Base UI demos comes from their styles. These values come from the Material spacing instead: 8px is the list padding, and 4px is half of one spacing unit. The playground and the recipes pinned sideOffset 8 on submenus, which hid the default. They now use the default. The root menus keep their offset. A test measures the geometry and pins both offsets. It opens the menu with defaultOpen, because a click on the trigger Button starts a ripple that outlives the test and fails the act() check. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-playground.tsx | 3 +- docs/pages/experiments/menu2-recipes.tsx | 6 --- docs/pages/experiments/menu2-rfc.md | 1 + .../src/Unstable_Menu2/Menu2SubmenuPopup.tsx | 5 +++ .../Menu2Collapsed.test.tsx | 43 +++++++++++++++++++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index e3e761ed2278a6..1aefd754082aba 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -133,7 +133,8 @@ function PlaygroundDemo({ delay: settings.submenuDelay, closeDelay: settings.submenuCloseDelay, }; - const submenuPopupProps = { sideOffset: 8, ...popupKnobProps }; + // No sideOffset here: submenus use their own default, which overlaps the parent. + const submenuPopupProps = { ...popupKnobProps }; const handleOpenChange: MenuProps['onOpenChange'] = (nextOpen, eventDetails) => { onLog(`onOpenChange -> ${nextOpen ? 'open' : 'close'} (reason: ${eventDetails.reason})`); diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index 786dbed2c17b96..f21a09d03ff8e5 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -298,7 +298,6 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove onMouseEnter: clearActiveItem, }, }} - sideOffset={8} > {versionHistoryPreviewCardItems.map((item) => ( @@ -359,7 +358,6 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { } slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} - sideOffset={8} > Document display @@ -393,7 +391,6 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { } slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} - sideOffset={8} > Word count Dictionary @@ -409,7 +406,6 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { } slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} - sideOffset={8} > Microsoft Word (.docx) PDF document (.pdf) @@ -425,7 +421,6 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { } slotProps={{ trigger: { disabled: true, openOnHover: settings.submenusOpenOnHover } }} - sideOffset={8} > Marketplace @@ -511,7 +506,6 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b } slots={{ trigger: TooltipSubmenuTriggerRoot }} slotProps={{ trigger: { openOnHover: submenusOpenOnHover } }} - sideOffset={8} > Show diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 1dd074e9c1e7dd..bc4a14d53bcc08 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -203,6 +203,7 @@ For the classic controlled pattern, omit `trigger` and control the menu with `op These behaviors are true for each shape that we select: - The submenus open on hover by default, with a delay of 100ms and hover intent on close. This behavior is new when you compare it to the classic Menu. It matches native menus, and you can configure it. +- A submenu overlaps its parent menu by 4px, and it starts 8px higher than its trigger. The offset of 8px cancels the top padding of the list. Thus the first item of the submenu lines up with the trigger row. Base UI positions a submenu in the same way. - Escape closes the innermost submenu and moves focus back to its trigger. To close the full tree, you must select that option. - If the user opens the menu with the pointer, the menu highlights no item. If the user opens the menu with the keyboard, the menu highlights the first item. - When a submenu is open, Base UI puts focus-guard elements next to its trigger. See open question 7. diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx index 87b57bd7b05620..7b3246294c8efb 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx @@ -229,6 +229,11 @@ const Menu2SubmenuPopup = React.forwardRef(function Menu2SubmenuPopup( defaultPositionerProps={{ side: 'inline-end', align: 'start', + // A submenu overlaps its parent by a small amount, the way Base UI + // positions one. `alignOffset` cancels the list's 8px top padding, so + // the first item lines up with the trigger row. + sideOffset: -4, + alignOffset: -8, }} /> ); diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index 34958dbbb76f85..ae8c42bd945cba 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -130,6 +130,49 @@ describe(' collapsed API', () => { ); }); + it('overlaps the parent menu by default', async () => { + // The popup animates, so geometry has to be read after the transition ends. + async function settle(element: HTMLElement) { + await Promise.all( + element.getAnimations().map((animation) => animation.finished.catch(() => {})), + ); + await waitFor(() => { + const { transform, opacity } = window.getComputedStyle(element); + expect(transform === 'none' || transform === 'matrix(1, 0, 0, 1, 0, 0)').to.equal(true); + expect(Number(opacity)).to.equal(1); + }); + } + + // `defaultOpen` avoids clicking the trigger Button, whose ripple animates + // past the end of the test and trips the act() check. + const { user } = render( + + + Nested + + , + ); + + const menu = await screen.findByRole('menu'); + await settle(menu); + const parent = screen.getByTestId('paper').getBoundingClientRect(); + const triggerRect = screen.getByRole('menuitem', { name: 'More' }).getBoundingClientRect(); + + await user.click(screen.getByRole('menuitem', { name: 'More' })); + await waitFor(() => { + expect(screen.queryByTestId('submenu-paper')).not.to.equal(null); + }); + await settle(document.querySelectorAll('[role="menu"]')[1] as HTMLElement); + const submenu = screen.getByTestId('submenu-paper').getBoundingClientRect(); + + // The submenu starts before the parent's right edge, so the two overlap. + expect(submenu.left).to.be.lessThan(parent.right); + // The overlap stays small, so the parent stays readable. + expect(parent.right - submenu.left).to.be.lessThan(12); + // The list's top padding is cancelled, so the first item meets the trigger. + expect(Math.round(submenu.top)).to.equal(Math.round(triggerRect.top) - 8); + }); + it('falls back to the default trigger for a non-element', async () => { const { user } = render( From 03f1560d000879c9056ae3bfdc2ac7ebd03bb491 Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 4 Aug 2026 16:27:20 +0300 Subject: [PATCH 48/62] Restructure the RFC for a human reader The RFC was correct but hard to scan. Long paragraphs held facts that a table or a list shows better. Changes: - The three failed attempts, the two style regressions, and the three slot helpers are now tables. - The six decided items move out of "Open questions" into a "Decisions" table. Each row keeps its caveat, for example that the animation is not identical to Grow. "Open questions" now holds only the 8 items that need an answer, and the cross-references point to the new places. - The `selectedMenu` explanation and several dense paragraphs are now lists with a bold lead-in. The share of prose falls from 32% to 18%. Tables rise from 10 tables of 1108 words to 10 tables of 1703 words. The word count falls from 6486 to 6164, which is small: the document is dense, and a larger cut would remove facts that the review asked us to keep. A review pass over each section found 5 changes of meaning, and all 5 are corrected. One example: "One of our oldest requests" became "An old request", which loses the rank. A structural check also found a lost `action` code span, and I restored that decision. Every link, code span, code block, and details block is unchanged. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 264 +++++++++++++++++----------- 1 file changed, 160 insertions(+), 104 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index bc4a14d53bcc08..82474e60f306aa 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -15,10 +15,10 @@ We structured this draft for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each se Material UI's `Menu` cannot do submenus. -- It is one of our oldest requests. [#11723](https://github.com/mui/material-ui/issues/11723) is open since 2018 and has 120+ reactions. -- Material UI v0.x had nested menus ([#2148](https://github.com/mui/material-ui/pull/2148)). The v1 rewrite removed them. -- Community packages (`mui-nested-menu`, `material-ui-popup-state`, many sandboxes) have weak keyboard support and weak ARIA support. Maintainers report this problem many times. -- The Menubar docs page shows Base UI submenus as copy-paste code. Users then asked for a real component ([#48336](https://github.com/mui/material-ui/issues/48336)). Copy-paste code has no version, no tests, and no theme support. +- **One of our oldest requests.** [#11723](https://github.com/mui/material-ui/issues/11723) is open since 2018 and has 120+ reactions. +- **A lost feature.** Material UI v0.x had nested menus ([#2148](https://github.com/mui/material-ui/pull/2148)). The v1 rewrite removed them. +- **Weak community options.** `mui-nested-menu`, `material-ui-popup-state`, and many sandboxes have weak keyboard and ARIA support. Maintainers report this problem many times. +- **Copy-paste code.** The Menubar docs page shows Base UI submenus as copy-paste code. Users then asked for a real component ([#48336](https://github.com/mui/material-ui/issues/48336)). Copy-paste code has no version, no tests, and no theme support. We want submenus in `@mui/material` with Material visuals and full theme support. The current `Menu` must stay stable. The plan is to make the new component the default `Menu` in the next major version. @@ -26,22 +26,22 @@ This RFC also sets the rules to build future Material UI components on Base UI ## What are the requirements? -1. Correct menu behavior at every nesting level: +1. **Correct menu behavior at every nesting level.** - Trigger semantics. - RTL-aware arrow keys. - Escape that closes one level at a time. - Focus that returns to the parent item. - Typeahead per level. - Any nesting depth. -2. Good pointer behavior. A submenu must stay open while the pointer moves diagonally toward it (the "safe triangle"). The menu must also delay the hover-open. Earlier attempts failed to meet this requirement. -3. Collision-aware positioning: submenus flip at screen edges instead of being cut off. -4. The same look as the current `Menu`/`MenuItem`, and full theming: `sx`, `classes`, `component`, `slots`/`slotProps`, and theme `defaultProps`/`styleOverrides`/`variants`. -5. Near-zero cost for existing users. The current `Menu` continues to work. Apps that do not import the new component get no behavior change and none of Base UI's bundle cost. These apps pay a one-time cost, because the classic components now read the styles that the new components share. See the size numbers below. -6. Keep the current API where the new foundation permits it. Document the places where the new foundation does not permit it. -7. Add the other menu features that users request often, at the same time. Then we do not need to change the API later. These features are checkbox items, radio items, groups, hover-open, and context menus. -8. A clear path to become `Menu` in the next major version. Supply a migration guide and codemods, so early adopters keep a way forward. -9. Reuse a maintained library. Do not build focus, dismissal, and positioning again. -10. The component must look like any other Material UI component in tooling, theming, imports, and tests. Users do not need to know about Base UI, and they do not install anything extra. +2. **Good pointer behavior.** A submenu must stay open while the pointer moves diagonally toward it (the "safe triangle"). The menu must also delay the hover-open. Earlier attempts failed this requirement. +3. **Collision-aware positioning.** Submenus flip at screen edges instead of being cut off. +4. **The same look as the current `Menu`/`MenuItem`, and full theming.** `sx`, `classes`, `component`, `slots`/`slotProps`, and theme `defaultProps`/`styleOverrides`/`variants`. +5. **Near-zero cost for existing users.** The current `Menu` continues to work. Apps that do not import the new component get no behavior change and no Base UI bundle cost. They pay a one-time cost, because the classic components now read the styles that the new components share. See the size numbers below. +6. **Keep the current API where the new foundation permits it.** Document the places where it does not. +7. **Add the other menu features that users request often, at the same time.** These features are checkbox items, radio items, groups, hover-open, and context menus. Then we do not need to change the API later. +8. **A clear path to become `Menu` in the next major version.** Supply a migration guide and codemods, so early adopters keep a way forward. +9. **Reuse a maintained library.** Do not build focus, dismissal, and positioning again. +10. **The component must look like any other Material UI component** in tooling, theming, imports, and tests. Users do not need to know about Base UI, and they do not install anything extra. ## What are our options? @@ -49,20 +49,22 @@ This RFC also sets the rules to build future Material UI components on Base UI We tried three times in eight years. The same problems blocked each attempt: -- [#14700](https://github.com/mui/material-ui/pull/14700) (2019): a recursive Menu inside a Menu. We closed it with this note: "we need to change the menu implementation and to expose new objects to make it happen". -- [#20591](https://github.com/mui/material-ui/pull/20591) (2020-2022, +1333 lines, ~22 months of review): a `subMenu` prop on `MenuItem` that used `cloneElement`. Five problems blocked it. The gzip size of the core bundle increased by a double-digit percentage. Hover intent was incorrect. `Popover` had no collision handling. The tests needed many changes. A change to a core component before v5 was a risk. The final review rejected `cloneElement` and proposed a rebuild on headless primitives. -- [#37570](https://github.com/mui/material-ui/pull/37570) (2023-2024): a docs demo only. An accessibility review found problems in Escape handling, `aria-expanded`, and screen reader support. We closed it with this note: "it would make more sense to focus on bringing this to Base UI". +| Attempt | Approach | Why it stopped | +| :----------------------------------------------------------------------------------------------------- | :------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [#14700](https://github.com/mui/material-ui/pull/14700) (2019) | A recursive Menu inside a Menu. | "we need to change the menu implementation and to expose new objects to make it happen" | +| [#20591](https://github.com/mui/material-ui/pull/20591) (2020-2022, +1333 lines, ~22 months of review) | A `subMenu` prop on `MenuItem` that used `cloneElement`. | Five problems: a double-digit percentage gzip increase in the core bundle; incorrect hover intent; no collision handling in `Popover`; many test changes; a risky core change before v5. The final review rejected `cloneElement` and proposed a rebuild on headless primitives. | +| [#37570](https://github.com/mui/material-ui/pull/37570) (2023-2024) | A docs demo only. | An accessibility review found problems in Escape handling, `aria-expanded`, and screen reader support. "it would make more sense to focus on bringing this to Base UI" | The problems are structural. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal`). Two nested modals break in six places: -1. Each menu renders a full-screen backdrop that captures clicks. The backdrop of a submenu covers its parent. A click on the parent closes the child. -2. `ModalManager` sets `aria-hidden` on all elements except the top modal. Therefore, an open submenu hides the parent from screen readers. -3. ArrowRight and ArrowLeft do nothing in a vertical list. There is also no hook to open the submenu. -4. Each modal has its own focus trap. The focus traps do not coordinate when a submenu closes. -5. `Popover` does not flip on collision. Therefore, a submenu near the screen edge is cut off. -6. Each `MenuList` keeps its own keyboard state. Nested lists share no state. +1. **Backdrop.** Each menu renders a full-screen backdrop that captures clicks. The backdrop of a submenu covers its parent. A click on the parent closes the child. +2. **`aria-hidden`.** `ModalManager` sets it on all elements except the top modal. Therefore, an open submenu hides the parent from screen readers. +3. **Keys.** ArrowRight and ArrowLeft do nothing in a vertical list. There is also no hook to open the submenu. +4. **Focus.** Each modal has its own focus trap. The focus traps do not coordinate when a submenu closes. +5. **Collision.** `Popover` does not flip. Therefore, a submenu near the screen edge is cut off. +6. **State.** Each `MenuList` keeps its own keyboard state. Nested lists share no state. -A fix must change `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`. It must also replace two core models: backdrop dismissal and per-modal focus traps. `Dialog` and every `Popover` also use these two models. This work rebuilds what the `Menu` of Base UI already does. The regression risk is high, and we discard the work in the next major version. Rejected. +A fix must change `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`. It must also replace two core models that `Dialog` and every `Popover` use: backdrop dismissal and per-modal focus traps. This work rebuilds what the `Menu` of Base UI already does. The regression risk is high, and we discard the work in the next major version. Rejected. ### Option B: Leave it as copy-paste docs code @@ -82,7 +84,8 @@ The `Menu` of Base UI (`@base-ui/react`, stable since early 2026, maintained by - Focus returns to the parent item. - Per-level typeahead. - Collision handling that flips the submenu and tracks the anchor. - Our work is the style, the theme, and the API surface. + +Our work is the style, the theme, and the API surface. ## Proposed solution @@ -99,11 +102,13 @@ The new component is a successor. It is not a rewrite of the current internals. | Next major | `Menu` | `Menu2` becomes the canonical name. | | Next major | `MenuLegacy` | We rename and deprecate the current `Menu`. We supply a codemod. | -This plan follows Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, old one renamed `GridLegacy`, [#45363](https://github.com/mui/material-ui/pull/45363)). Each rename breaks early adopters. But a codemod can do the rename, and we accepted this trade before. The `2` suffix makes a stable phase before the major release possible. A name without the suffix would collide with the `Menu` that we still release. - -Only the directories, the subpaths, and the exports use the `Unstable_` prefix. The internal names are `Menu2*` and the theme keys are `MuiMenu2*`. Our lint rules require this, and it matches Grid2. Therefore the theme keys do not change in the `Unstable_Menu2` -> `Menu2` step. Only the final promotion to `Menu` renames them. +This plan follows Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, old one renamed `GridLegacy`, [#45363](https://github.com/mui/material-ui/pull/45363)). -The imports follow our usual convention: flat names, one component for each subpath, and no short aliases such as `Root` or `Item`. +- **Renames.** Each rename breaks early adopters. But a codemod can do the rename, and we accepted this trade before. +- **The `2` suffix.** It makes a stable phase before the major release possible. A name without the suffix would collide with the `Menu` that we still release. +- **The `Unstable_` prefix.** Only the directories, the subpaths, and the exports use it. The internal names are `Menu2*` and the theme keys are `MuiMenu2*`. Our lint rules require this, and it matches Grid2. +- **The theme keys.** They do not change in the `Unstable_Menu2` -> `Menu2` step. Only the final promotion to `Menu` renames them. +- **The imports.** They follow our usual convention: flat names, one component for each subpath, and no short aliases such as `Root` or `Item`. ```jsx import Menu2 from '@mui/material/Unstable_Menu2'; @@ -112,7 +117,7 @@ import Menu2Item from '@mui/material/Unstable_Menu2Item'; The subpaths use default exports. Therefore adopters can remove the `Unstable_` prefix in their own code. Their JSX then looks like the future API. We add the barrel exports at graduation. -A checklist controls graduation. It is not a judgment call. The checklist has four items: +A checklist of four items controls graduation. It is not a judgment call. - The conformance suite passes, minus the documented skips. - The theme registration is at parity. @@ -125,29 +130,44 @@ The rule: a Base UI-backed component must look the same as any other Material  Decided: -- **Customization:** `slots`/`slotProps`, the same as every other component. We use Base UI's `render` prop internally to inject our styled elements. The `render` prop is not the documented contract. -- **Slot plumbing:** reuse the `@mui/utils` helpers (`useSlotProps`, `mergeSlotProps`, `appendOwnerState`, `resolveComponentProps`). Do not write our own helpers. We did this in the experiment. Three Base UI-specific helpers remain, and they should move into the shared utilities. The first helper hides a Base UI part's own props when a plain element replaces a slot. The second helper connects Base UI's `className={(state) => string}` callbacks to our utility classes. The third helper infers `nativeButton` from the root slot. -- **Style reuse:** share the styled element itself through `render` where possible. If this is not possible, use a shared style function. Today the classic `Menu`/`MenuItem` and the new parts read the same style modules. Therefore there is one source of truth. Two regressions taught us to audit the shared styles for each consumer. The first regression was a `maxHeight: calc(100% - 96px)` value. This value meant "the viewport" inside the old Modal, but it did not mean this inside the new popup. The second regression was an `[item] + divider` margin. This margin broke when Base UI added focus-guard elements next to an open submenu trigger. Each part must control its own spacing. A part must not depend on sibling selectors. -- **Presentational props stay:** `dense`, `disableGutters`, `divider`, `selected`. We control presentation and Base UI controls behavior. The line is styling against behavior, not old against new. Therefore we can still decide each rarely-used prop separately. -- **Dependency:** `@base-ui/react` becomes a direct dependency of `@mui/material`, like `@popperjs/core`. Users never install or import it. There are two conditions. First, we review each version increase and we never auto-merge it. Second, the conformance tests pin the `data-*` attributes that we use. Therefore an upstream rename makes CI fail and does not break the styles silently. -- **Docs tooling:** the component adapts to our tooling. Our tooling does not adapt to the component. -- **Theme registration:** normal `defaultProps`/`styleOverrides`/`variants` for each part, under `MuiMenu2*` keys. +- **Customization.** `slots`/`slotProps`, the same as every other component. We use Base UI's `render` prop internally to inject our styled elements. The `render` prop is not the documented contract. +- **Slot plumbing.** Reuse the `@mui/utils` helpers (`useSlotProps`, `mergeSlotProps`, `appendOwnerState`, `resolveComponentProps`). Do not write our own helpers. We did this in the experiment. Three Base UI-specific helpers remain, and they should move into the shared utilities: + + | Helper | Job | + | :---------------------- | :---------------------------------------------------------------------------------- | + | Host prop filter | Hides a Base UI part's own props when a plain element replaces a slot | + | Class callback bridge | Connects Base UI's `className={(state) => string}` callbacks to our utility classes | + | Native button inference | Infers `nativeButton` from the root slot | + +- **Style reuse.** Share the styled element itself through `render` where possible. If this is not possible, use a shared style function. Today the classic `Menu`/`MenuItem` and the new parts read the same style modules, so there is one source of truth. Two regressions taught us to audit the shared styles for each consumer: + + | Regression | Cause | + | :----------------------------- | :-------------------------------------------------------------------------------- | + | `maxHeight: calc(100% - 96px)` | The value meant "the viewport" inside the old Modal, but not inside the new popup | + | `[item] + divider` margin | Base UI added focus-guard elements next to an open submenu trigger | + + Each part must control its own spacing. A part must not depend on sibling selectors. + +- **Presentational props stay.** `dense`, `disableGutters`, `divider`, `selected`. We control presentation and Base UI controls behavior. The line is styling against behavior, not old against new. Therefore we can still decide each rarely-used prop separately. +- **Dependency.** `@base-ui/react` becomes a direct dependency of `@mui/material`, like `@popperjs/core`. Users never install or import it. Two conditions apply. First, we review each version increase and we never auto-merge it. Second, the conformance tests pin the `data-*` attributes that we use. Therefore an upstream rename makes CI fail and does not break the styles silently. +- **Docs tooling.** The component adapts to our tooling. Our tooling does not adapt to the component. +- **Theme registration.** Normal `defaultProps`/`styleOverrides`/`variants` for each part, under `MuiMenu2*` keys. Still up for discussion: -- **Where styling state lives:** the `Mui-*` classes and `ownerState` stay the public contract for `styleOverrides`, `variants`, and `sx`. Internally we can read the Base UI `data-*` attributes for positional state. Tooltip already does this with `[data-popper-placement]`. The rule is simple. Each item that users theme gets a class. Internal positional state stays a data attribute. -- **Prop types:** extend Base UI's types and use `Omit` for the props that we hide or rename. Therefore we inherit new props automatically. As a result, the callbacks keep the Base UI signatures, for example `onOpenChange(open, eventDetails)` instead of `onClose(event, reason)`. This works in the experiment, but there is one limitation. Our proptypes generator cannot read types from `node_modules`. Therefore the runtime PropTypes cover only the props that we declare locally. This does not change the types. Production builds do not include the PropTypes. To teach the generator to follow external types is a separate infra task. -- **Testing:** reuse `describeConformance` for the Material UI contract. Also run the existing Menu behavior tests again against the successor, and add a note to each skip. Those suites must pass to show parity. Do not write new tests for the successor to show parity. All 14 rendering parts now run conformance. Therefore we deleted the manual theming and slots tests. Two adaptations can belong in the shared harness. First, portalled roots need a method to point the harness at the real root element. Second, the nested submenu popup mounts only with real layout. Therefore its suite runs in the browser project only. +- **Where styling state lives.** The `Mui-*` classes and `ownerState` stay the public contract for `styleOverrides`, `variants`, and `sx`. Internally we can read the Base UI `data-*` attributes for positional state. Tooltip already does this with `[data-popper-placement]`. Each item that users theme gets a class. Internal positional state stays a data attribute. +- **Prop types.** Extend Base UI's types and use `Omit` for the props that we hide or rename, so we inherit new props automatically. The callbacks then keep the Base UI signatures, for example `onOpenChange(open, eventDetails)` instead of `onClose(event, reason)`. This works in the experiment, but our proptypes generator cannot read types from `node_modules`. Therefore the runtime PropTypes cover only the props that we declare locally. This does not change the types, and production builds do not include the PropTypes. To teach the generator to follow external types is a separate infra task. +- **Testing.** Reuse `describeConformance` for the Material UI contract. Also run the existing Menu behavior tests again against the successor, and add a note to each skip. Those suites must pass to show parity. Do not write new tests for the successor to show parity. All 14 rendering parts now run conformance, so we deleted the manual theming and slots tests. Two adaptations can belong in the shared harness. First, portalled roots need a method to point the harness at the real root element. Second, the nested submenu popup mounts only with real layout, so its suite runs in the browser project only. ### API shape (settled by review) The review agreed on these rules. We do not make one global choice between a flat API and a compound API. -- The new API stays as near to today's `Menu` as the foundation permits. -- The wiring parts (Portal, Positioner, Popup, Paper, List) become one flat container. You configure this container with `slots`/`slotProps`. -- The parts that users change for each instance stay separate components. These parts are the items, the submenu triggers, the checkbox items, and the radio items. +- **Familiarity.** The new API stays as near to today's `Menu` as the foundation permits. +- **One flat container.** The wiring parts (Portal, Positioner, Popup, Paper, List) merge into it. You configure the container with `slots`/`slotProps`. +- **Separate components.** The parts that users change per instance stay separate: the items, the submenu triggers, the checkbox items, and the radio items. -Before the final decision on the shape, we had to know how much the behavior differs. A test next to the component now gives this benchmark (`Menu2Benchmark.test.tsx`). A real browser measures each row below. We do not read the rows from the source code. +Before the final decision on the shape, we had to know how much the behavior differs. A test next to the component gives the behavior benchmark (`Menu2Benchmark.test.tsx`). A real browser measures each row below. We do not read the rows from the source code. #### Benchmark results @@ -160,23 +180,25 @@ Before the final decision on the shape, we had to know how much the behavior dif | Escape | closes; focus returns to the trigger | same | same | | Tab while open | closes; focus goes back to the trigger | closes; focus moves to the next element | same close, different focus | | Body scroll while open | the menu locks it | the menu locks it | same | -| Backdrop element | the menu renders it | opt-in (see open question 4) | **difference** | +| Backdrop element | the menu renders it | opt-in (see the decisions above) | **difference** | | Sibling content while open | `aria-hidden` | it stays in the accessibility tree | **difference** | | Default placement | under the trigger, left aligned | under the trigger, left aligned | same | -These results have this meaning. The successor is nearer to a drop-in replacement than we expected. The placement, the scroll lock, Escape, and Tab-closes-the-menu already match. We cannot compare directly how the two menus open with the keyboard, because the classic Menu has no trigger part. But both menus highlight an item, thus they agree in practice. Five differences stay: +The successor is nearer to a drop-in replacement than we expected. The placement, the scroll lock, Escape, and Tab-closes-the-menu already match. We cannot compare the keyboard open directly, because the classic Menu has no trigger part. But both menus highlight an item, thus they agree in practice. Five differences stay: - **Keep, they are accessibility fixes.** The disabled items stay focusable. The sibling content stays in the accessibility tree. Both behaviors follow the WAI-ARIA menu pattern. The classic behavior is the different one. The backdrop is also in this group. The menu no longer needs a backdrop to close, and a slot supplies the backdrop. -- **Decided: keep Base UI's behavior** for the initial focus when the user opens the menu with the pointer. The menu highlights no item. Thus Enter cannot start an item that the user did not select. Native desktop menus work in this way. This is a deliberate deviation from the APG. It is not a neutral choice. The [menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) says that focus moves to an item when the menu opens. The pattern makes no exception when the user opens the menu with the pointer. We match native desktop menus instead. We must release this deviation with documentation. To match the classic behavior, we must move focus to an item after the menu opens, because Base UI has no `initialFocus` prop on Menu. This works against the library and adds the risk of accidental activation again. This is a documented change for the users who migrate. +- **Decided: keep Base UI's behavior** for the initial focus on a pointer open. The menu highlights no item. Thus Enter cannot start an item that the user did not select. Native desktop menus work in this way. This is a deliberate deviation from the APG, not a neutral choice. The [menu pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) says that focus moves to an item when the menu opens, with no exception for a pointer open. We must release this deviation with documentation. To match the classic behavior, we must move focus to an item after the menu opens, because Base UI has no `initialFocus` prop on Menu. This works against the library and adds the risk of accidental activation again. This is a documented change for the users who migrate. - **Document it.** Tell the user where focus goes after Tab. The classic Menu sends focus back to the trigger. The successor lets focus move to the next element. This is the usual function of the Tab key. -We drop `variant="selectedMenu"`. This is a lost feature, not a changed behavior. The prop opens the menu and highlights the current value. In the code, the prop selects which item gets the focus when the menu opens. The prop also hides the focus ring at that first moment. Base UI cannot do these two things. `Menu.Root` has no prop for the initial highlight, and `Menu.Popup` has no `initialFocus`. The radio items do not replace this feature. A `RadioGroup` with a checked second item still opens with the first item highlighted. The benchmark asserts this behavior. - -This change also makes the pointer-focus difference above smaller, but it does not remove the difference. The classic Menu highlights an item on a pointer open with both variants. +We drop `variant="selectedMenu"`. This is a lost feature, not a changed behavior. -Base UI does this deliberately. It is not a mistake. `initialFocus` exists on Base UI's Combobox, Dialog, Drawer, and Popover, but not on Menu. A maintainer gives the reason in [base-ui#2143](https://github.com/mui/base-ui/issues/2143): "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items." The same thread recommends a different component for this case: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." No open request asks for a change. Thus the answer is the boundary between the patterns, not a feature request to Base UI. +- **What it did.** It selected which item got the focus when the menu opened, and hid the focus ring at that first moment. +- **Why we cannot keep it.** `Menu.Root` has no prop for the initial highlight, and `Menu.Popup` has no `initialFocus`. +- **Radio items do not replace it.** A `RadioGroup` with a checked second item still opens with the first item highlighted. The benchmark asserts this behavior. +- **Effect on the pointer-focus difference.** The difference gets smaller, but it stays. The classic Menu highlights an item on a pointer open with both variants. +- **Base UI does this deliberately.** `initialFocus` exists on its Combobox, Dialog, Drawer, and Popover, but not on Menu. A maintainer gives the reason in [base-ui#2143](https://github.com/mui/base-ui/issues/2143): "Menu doesn't have the `initialFocus` prop (like Popover), because it's supposed to only contain menu items." The same thread recommends a different component: "The `Menu` pattern is for listing a bunch of actions the user can take. The `Select` pattern is for choosing an option from a list of options." No open request asks for a change. The answer is the boundary between the patterns, not a feature request to Base UI. -For the API shape, this result has two meanings. A flat container can give today's API. The migration is "the same component with a few documented behavior changes", not a rewrite. +For the API shape, a flat container can give today's API. The migration is "the same component with a few documented behavior changes", not a rewrite. The review settled the shape. The experiment started with a fully compound API, with one component for each Base UI part. Now there is one component for each menu, at the two levels. The root holds the trigger and the popup. A submenu has the same shape one level lower. @@ -194,20 +216,20 @@ The trigger part and the popup part still exist, but they are internal. We expor Three results come from this work: -- The root `trigger` takes an element. Base UI's `render` merges the behavior into that element. Thus the caller keeps their own component. Other content renders inside the default trigger. -- The submenu `trigger` takes content, not an element. This is the one place where the two levels cannot match. A submenu trigger is already a menu item. A `` in the `trigger` puts an item inside an item, and the submenu does not open. -- It is now more difficult to put a trigger in a `Tooltip`. The compound shape put the `Tooltip` directly around the element. Now the wrapper must move into the root slot of the trigger. This step needs a `forwardRef` component. The test suite and the recipes page both have this problem. +- **The root `trigger` takes an element.** Base UI's `render` merges the behavior into that element. Thus the caller keeps their own component. Other content renders inside the default trigger. +- **The submenu `trigger` takes content, not an element.** This is the one place where the two levels cannot match. A submenu trigger is already a menu item. A `` in the `trigger` puts an item inside an item, and the submenu does not open. +- **A trigger in a `Tooltip` is now more difficult.** The compound shape put the `Tooltip` directly around the element. Now the wrapper must move into the root slot of the trigger. This step needs a `forwardRef` component. The test suite and the recipes page both have this problem. For the classic controlled pattern, omit `trigger` and control the menu with `open` and `anchor`. The context-menu recipe uses this pattern. These behaviors are true for each shape that we select: -- The submenus open on hover by default, with a delay of 100ms and hover intent on close. This behavior is new when you compare it to the classic Menu. It matches native menus, and you can configure it. -- A submenu overlaps its parent menu by 4px, and it starts 8px higher than its trigger. The offset of 8px cancels the top padding of the list. Thus the first item of the submenu lines up with the trigger row. Base UI positions a submenu in the same way. -- Escape closes the innermost submenu and moves focus back to its trigger. To close the full tree, you must select that option. -- If the user opens the menu with the pointer, the menu highlights no item. If the user opens the menu with the keyboard, the menu highlights the first item. -- When a submenu is open, Base UI puts focus-guard elements next to its trigger. See open question 7. -- The menu limits its height to the available space and scrolls inside itself. The old behavior used only the viewport for this limit. +- **Hover open.** The submenus open on hover by default, with a delay of 100ms and hover intent on close. This behavior is new when you compare it to the classic Menu. It matches native menus, and you can configure it. +- **Offset.** A submenu overlaps its parent menu by 4px, and it starts 8px higher than its trigger. The offset of 8px cancels the top padding of the list. Thus the first item of the submenu lines up with the trigger row. Base UI positions a submenu in the same way. +- **Escape.** Escape closes the innermost submenu and moves focus back to its trigger. To close the full tree, you must select that option. +- **Initial highlight.** A pointer open highlights no item. A keyboard open highlights the first item. +- **Focus guards.** When a submenu is open, Base UI puts focus-guard elements next to its trigger. See the decisions above. +- **Height.** The menu limits its height to the available space and scrolls inside itself. The old behavior used only the viewport for this limit. ### Compatibility @@ -240,43 +262,76 @@ The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) c Done: -- We renamed the components to the `Unstable_Menu2` lifecycle name, one component for each subpath. -- We removed the special cases in the docs tooling. -- The classic component and the successor share the same style modules. -- Composed list primitives still work inside the items. `ListItemText inset` aligns with the icon column. `inset` is a `ListItemText` prop, not a menu item prop, so we implemented nothing. -- The prop types inherit from Base UI. The roots get `actionsRef` and future props at no cost. -- Top-level `elevation` on the popup, default 8. -- A default open and close animation, and a backdrop slot that you opt in to. +- **Names.** We renamed the components to the `Unstable_Menu2` lifecycle name, one component for each subpath. +- **Docs tooling.** We removed the special cases. +- **Styles.** The classic component and the successor share the same style modules. +- **Composition.** Composed list primitives still work inside the items. `ListItemText inset` aligns with the icon column. `inset` is a `ListItemText` prop, not a menu item prop, so we implemented nothing. +- **Types.** The prop types inherit from Base UI. The roots get `actionsRef` and future props at no cost. +- **Elevation.** Top-level `elevation` on the popup, default 8. +- **Animation.** A default open and close animation, and a backdrop slot that you opt in to. Left: -- The components share styles at the style-function level. It is better to share the styled element itself where this fits. -- The slot helpers for Base UI should move into `@mui/utils`. -- We must still adapt the existing Menu behavior tests. The flat container was the blocker, and it now exists, so this task is next. The benchmark covers the open action, focus, disabled items, dismissal, scroll locking, backdrop treatment, and placement. The benchmark does not yet cover these areas: the default item close behavior, link items, checkbox and radio activation, controlled callback reasons, outside-pointer dismissal, hover and submenu timing, RTL submenu navigation, and context-menu focus. -- The `MenuItem` and `Select` part of the lifecycle is unanswered. See open question 13. +- **Style sharing.** The components share styles at the style-function level. It is better to share the styled element itself where this fits. +- **Slot helpers.** The slot helpers for Base UI should move into `@mui/utils`. +- **Behavior tests.** We must still adapt the existing Menu behavior tests. The flat container was the blocker, and it now exists, so this task is next. The benchmark covers the open action, focus, disabled items, dismissal, scroll locking, backdrop treatment, and placement. The benchmark does not yet cover these areas: the default item close behavior, link items, checkbox and radio activation, controlled callback reasons, outside-pointer dismissal, hover and submenu timing, RTL submenu navigation, and context-menu focus. +- **Lifecycle.** The `MenuItem` and `Select` part of the lifecycle is unanswered. See open question 7. + +### Decisions + +These are settled. The detail stays here, because the caveats matter. + +| Topic | Decision | Detail and caveat | +| :------------------------------ | :------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Default animation | Release one | The popup has a CSS transition that follows the classic `Grow`, with the same scale and theme durations. It is **not identical**: the classic Menu sends `transitionDuration="auto"`, so `Grow` calculates the duration from the menu height with `getAutoHeightDuration`. A CSS transition must use the fixed `enteringScreen`/`leavingScreen` values, so tall menus animate more quickly than before. We must compare the two speeds before we complete this work. The transition stops under `prefers-reduced-motion`, and `slotProps.popup`, `styleOverrides`, or the theme can override it. It must sit on the popup element, because Base UI waits for the animations on that element before it removes the popup. | +| `elevation` prop | Keep it | The popup accepts `elevation` (default 8) and sends it to the Paper slot. The usual case does not need `slotProps.paper`. | +| Backdrop | Show it | `slots.backdrop` and `slotProps.backdrop` match the classic Menu. The default element is transparent and lets clicks through. Base UI's outside-press behavior still closes the menu. For a dark background use `slotProps={{ backdrop: { sx: { bgcolor: ... } } }}`. One difference: the backdrop renders only when you opt in. An unconditional backdrop gives non-modal menus a full-screen layer that they did not have, and modal menus already get Base UI's inert backdrop. | +| Imperative actions | Use Base UI's `actionsRef` as-is | It arrives with the inherited types and gives `close()` and `unmount()`. We do not rename it, and we do not build our own `action` ref. A new name moves our API away from Base UI for no benefit, and a new implementation repeats work. The classic `action.updatePosition()` has no equivalent, because the position updates automatically. | +| Styling around submenu triggers | Do both | When a submenu is open, Base UI keeps focus-guard elements next to the trigger, because the tab order needs them. CSS that uses sibling selectors (`+`, `~`, `:last-child`) near a trigger then fails. We found this bug ourselves, so each part now controls its own spacing. We will document the rule, and the guards carry `data-base-ui-focus-guard`. We will also ask the Base UI team to move the guards outside the item list, which helps every Base UI user. | +| Theme API | Collapse it too | `MuiMenu2` and `MuiMenu2Submenu` are the only theme keys. `MuiMenu2` has the slots `root`, `trigger`, `backdrop`, `paper`, and `list`. `MuiMenu2Submenu` has `root`, `trigger`, `paper`, and `list`. We removed `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot`. Each element keeps its own class hook, because CSS must still select the different nodes and their states. Autocomplete has the same division. | ### Open questions -1. **Ripple.** The items are plain elements. Therefore the items have no ripple by default. Users can add a ripple today. They replace the item root with `slots={{ root: ButtonBase }}`. This gives a real ripple. But it removes our item styling, because that CSS is on the default root. A `ButtonBase` inside an item is not possible. It puts a focusable element inside a `menuitem`. This highlights the item when the menu opens ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)). Base UI does not permit this pattern. So we must make a choice. Do we release a ripple by default? This makes the item root a styled `ButtonBase` and adds `disableRipple` again. Or do we leave the ripple to the root slot and document the procedure? This is a design decision. It has no effect on accessibility. -2. **Default animation. Decided: release one.** The popup has a CSS transition. The transition follows the classic `Grow`. It uses the same scale and the same theme durations. Thus applications that migrate keep their animation. But the two animations are not identical. The classic Menu sends `transitionDuration="auto"`. Then `Grow` calculates the duration from the height of the menu with `getAutoHeightDuration`. A CSS transition must use the fixed `enteringScreen`/`leavingScreen` values. Tall menus now animate more quickly than before. We must compare the two speeds before we complete this work. The transition stops when the user sets `prefers-reduced-motion`. Users can override the transition through `slotProps.popup`, `styleOverrides`, or the theme. The transition must be on the popup element. Base UI waits for the animations on that element before it removes the element. A transition on a child element stops when the menu closes. There is one practical effect. Code that measures the menu immediately after it opens reads the menu during the animation. Therefore tests must wait for the end of the transition. -3. **`elevation` prop. Decided: keep it.** The popup accepts `elevation` (default 8). The popup sends `elevation` to the Paper slot. Therefore the usual case does not need `slotProps.paper`. -4. **Backdrop. Decided: show it.** `slots.backdrop` and `slotProps.backdrop` are the same as in the classic Menu. The default element is transparent and lets clicks through. This is the same as the classic invisible backdrop. The outside-press behavior of Base UI continues to close the menu. To make the background dark, use `slotProps={{ backdrop: { sx: { bgcolor: ... } } }}`. There is one difference from the classic Menu. The backdrop renders only when you request it. If the backdrop always renders, non-modal menus get a full-screen layer that they did not have before. Also, modal menus already get the inert backdrop of Base UI. -5. **Imperative actions. Decided: use Base UI's `actionsRef` as-is.** `actionsRef` comes with the inherited types. It gives `close()` and `unmount()`. We do not rename it. We do not build our own `action` ref. A new name moves our API away from the Base UI API and gives no benefit. A new implementation repeats the work that Base UI already does. The classic `action.updatePosition()` has no equivalent, because the position updates automatically. -6. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor. But the procedure has a focus bug. We found this bug ourselves. A menu with no trigger has no element for the return of focus. Then Base UI uses the last element that it remembers. This element can be a trigger from a different menu on the page. The procedure must send `finalFocus` with the element that the user right-clicked. The API does not tell you this. The result looks correct until a second menu exists. A component that wraps the Base UI `ContextMenu` corrects this internally, because its trigger is the right-click surface. So we must make a choice. Do we document the procedure, or do we release the component? -7. **Styling around submenu triggers. Decided: do both.** When a submenu is open, Base UI keeps focus-guard elements adjacent to the trigger, because the tab order needs them. CSS that uses the sibling selectors (`+`, `~`, `:last-child`) near a trigger fails when a submenu opens. We found this bug ourselves. Now each of our parts controls its own spacing. We will document this rule. You can identify the guards with `data-base-ui-focus-guard`. We will also ask the Base UI team if the guards can go outside the item list. That change helps every Base UI user. -8. **Bundle size.** Base UI adds much weight to each component. Do we want a size check for each component that uses Base UI? -9. **Accessibility: what is still ours.** Base UI controls the roles, the keyboard behavior, the focus, and the dismissal. We control all the visual parts. The remaining risk is in the visual parts. There are three concrete gaps. We closed the forced-colors gap. `enhanceHighContrast` controls the five item parts and the two indicators. It uses the `highlighted` state. Two gaps are open. The highlight is a background tint (`action.focus`, approximately 1.3:1), and the native outline is not present. This is parity with the classic item. But it is less than the 3:1 ratio that [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) requires for a focus indicator. Therefore the design team and the accessibility team must approve it. We must not inherit it. Also, no test examines an open menu automatically. axe runs only in the visual regression suite. That suite does not interact with the page, and for this reason it skips the menus page. `describeConformance` has no accessibility assertions. -10. **Other defaults.** We decided two defaults. A menu that a pointer opens highlights no item. A submenu opens on hover. Modality is not a question. The Base UI `modal` prop has the default value `true`. The classic Menu is always modal. The benchmark measured the same scroll lock in both menus. The successor only adds the option to disable the modal behavior. -11. **SSR, `'use client'`, ref typing.** All 18 modules contain `'use client'`. We put the directive in the same position as the classic Menu. Base UI also includes the directive in its own menu modules. No test confirms that the directive works. The documentation site uses the Pages Router with `output: 'export'`. Therefore the site never evaluates a server component boundary. Only an App Router fixture can confirm the behavior. We measured the server rendering, so it is not an open question. Only the trigger renders on the server. `defaultOpen` and `keepMounted` do not change this, because Base UI creates the portal node in a layout effect. The real question is the ref typing. The refs come from Base UI and are wide (`HTMLElement`, `Element`). The classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. The conformance tests fix the runtime element, but the type stays wide. Do we make the type narrow for each part, or do we keep parity with the Base UI signatures? - -12. **Theme API. Decided: collapse it too.** `MuiMenu2` and `MuiMenu2Submenu` are now the only theme keys. Each key carries `defaultProps`, `variants`, and `styleOverrides` for its slots. The menu slots are `root`, `trigger`, `backdrop`, `paper`, and `list`. The submenu slots are `root`, `trigger`, `paper`, and `list`. We removed `MuiMenu2Popup`, `MuiMenu2Trigger`, `MuiMenu2SubmenuPopup`, `MuiMenu2SubmenuTrigger`, and `MuiMenu2SubmenuRoot`. Therefore the parts are internal in the theme and in the JSX. Each element keeps its own class hook, because CSS must still select the different nodes and their states. Autocomplete has the same division between one theme key and the classes for each slot. -13. **`MenuItem` and `Select` in the lifecycle.** The rollout plan below covers `Menu2` -> `Menu` and classic `Menu` -> `MenuLegacy`. The plan says nothing about the items. `Select` renders a classic `Menu` with `MenuItem` as a listbox. Therefore the promotion must answer four questions. Does `Menu2Item` become `MenuItem`? Does today's `MenuItem` become `MenuItemLegacy`? What happens to the `MuiMenuItem` theme key? Does `Select` keep the legacy item, get a private listbox item, or get a new implementation first? This is the largest open gap in the migration plan. -14. **How much Base UI shows through.** `Menu2Props` extends `BaseMenu.Root.Props`, and the dependency is `^1.6.0`. Therefore a Base UI minor version can add public Menu props. These props do not pass our own API review. The runtime PropTypes stay incomplete, because the generator cannot read the types in `node_modules`. This is a problem for the rule that users do not need to know about Base UI. It is also a problem for the `actionsRef` name from Base UI. We have two options. We put a Material UI facade over the root props and the callbacks. Or we accept the inheritance, pin the exact version, and state clearly that the Base UI API is a part of the Material UI contract. +1. **Ripple.** The items are plain elements, so they have no ripple by default. Users can add a ripple today with `slots={{ root: ButtonBase }}`. This gives a real ripple, but it removes our item styling, because that CSS is on the default root. A `ButtonBase` inside an item is not possible. It puts a focusable element inside a `menuitem`, and this highlights the item when the menu opens ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)). Base UI does not permit this pattern. So we must make a choice. It is a design decision, and it has no effect on accessibility: + + | Option | Effect | + | :-------------------------------- | :-------------------------------------------------------------------------- | + | Release a ripple by default | The item root becomes a styled `ButtonBase`, and `disableRipple` comes back | + | Leave the ripple to the root slot | We document the procedure | + +2. **Context menu: does it need its own component?** Right-click menus work today with a virtual anchor, but the procedure has a focus bug. We found this bug ourselves. A menu with no trigger has no element for the return of focus, so Base UI uses the last element that it remembers. This element can be a trigger from a different menu on the page. The procedure must send `finalFocus` with the element that the user right-clicked, and the API does not tell you this. The result looks correct until a second menu exists. A component that wraps the Base UI `ContextMenu` corrects this internally, because its trigger is the right-click surface. Do we document the procedure, or do we release the component? +3. **Bundle size.** Base UI adds much weight to each component. Do we want a size check for each component that uses Base UI? +4. **Accessibility: what is still ours.** Base UI controls the roles, the keyboard behavior, the focus, and the dismissal. We control all the visual parts, and the remaining risk is there. Three concrete gaps: + + | Gap | Status | + | :--------------- | :-------------------------------------------------------------------------------------------------------------------- | + | Forced colors | Closed. `enhanceHighContrast` controls the five item parts and the two indicators. It uses the `highlighted` state | + | Focus indicator | Open. The highlight is a background tint (`action.focus`, approximately 1.3:1), and the native outline is not present | + | Automated checks | Open. No test examines an open menu automatically | + + The focus indicator is parity with the classic item, but it is less than the 3:1 ratio that [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) requires. The design team and the accessibility team must approve it. We must not inherit it. For the automated checks, axe runs only in the visual regression suite. That suite does not interact with the page, so it skips the menus page. `describeConformance` has no accessibility assertions. + +5. **Other defaults.** We decided two defaults. A menu that a pointer opens highlights no item. A submenu opens on hover. Modality is not a question. The Base UI `modal` prop has the default value `true`, and the classic Menu is always modal. The benchmark measured the same scroll lock in both menus. The successor only adds the option to disable the modal behavior. +6. **SSR, `'use client'`, ref typing.** + - **The directive.** All 18 modules contain `'use client'`, in the same position as the classic Menu. Base UI also includes the directive in its own menu modules. No test confirms that the directive works. The documentation site uses the Pages Router with `output: 'export'`, so it never evaluates a server component boundary. Only an App Router fixture can confirm the behavior. + - **Server rendering.** We measured it, so it is not an open question. Only the trigger renders on the server. `defaultOpen` and `keepMounted` do not change this, because Base UI creates the portal node in a layout effect. + - **Ref typing.** This is the real question. The refs come from Base UI and are wide (`HTMLElement`, `Element`). The classic `MenuItem` resolves to `HTMLLIElement` and follows the `component` prop. The conformance tests fix the runtime element, but the type stays wide. Do we make the type narrow for each part, or do we keep parity with the Base UI signatures? +7. **`MenuItem` and `Select` in the lifecycle.** The rollout plan below covers `Menu2` -> `Menu` and classic `Menu` -> `MenuLegacy`. The plan says nothing about the items. `Select` renders a classic `Menu` with `MenuItem` as a listbox. The promotion must answer four questions: + - Does `Menu2Item` become `MenuItem`? + - Does today's `MenuItem` become `MenuItemLegacy`? + - What happens to the `MuiMenuItem` theme key? + - Does `Select` keep the legacy item, get a private listbox item, or get a new implementation first? + + This is the largest open gap in the migration plan. + +8. **How much Base UI shows through.** `Menu2Props` extends `BaseMenu.Root.Props`, and the dependency is `^1.6.0`. Therefore a Base UI minor version can add public Menu props that do not pass our own API review. The runtime PropTypes stay incomplete, because the generator cannot read the types in `node_modules`. This is a problem for the rule that users do not need to know about Base UI, and for the `actionsRef` name that we keep from Base UI. Two options: + - **A facade.** We put a Material UI facade over the root props and the callbacks. + - **Inheritance.** We accept the inheritance, pin the exact version, and state clearly that the Base UI API is a part of the Material UI contract. ### Rollout plan 1. Behavior benchmark: **done**. The results are above. -2. Design phase for the API shape. We try the design in the companion experiment. Then we answer each question against a real preview. +2. Design phase for the API shape. We try the design in the companion experiment, then answer each question against a real preview. 3. Release `Unstable_Menu2` in a v9 minor version, with conformance tests, API docs, and demos on the Menu page. 4. Make changes from the feedback. Then make `Menu2` stable when it passes the graduation checklist. 5. Next major version: promote `Menu2` to `Menu`, rename the old component to `MenuLegacy`, and release the migration guide and the codemods. @@ -301,7 +356,7 @@ Left: | :---------------------------------------------------- | :------------------------------------------------------------------------------------ | :------------------------------------------------- | | `anchorEl` | `anchor` | It also accepts refs and virtual elements. | | `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | The new props give more exact control. | -| `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | See open question 6. | +| `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | See open question 2. | | `marginThreshold` (default 16) | `collisionPadding` (default 5) | The idea is the same. | | `anchorReference="none"` | Omit `anchor` and set the position with CSS. | The behavior is the same. | | `action.updatePosition()` | automatic | Use `disableAnchorTracking` to stop this behavior. | @@ -320,7 +375,7 @@ Left: | `disableRestoreFocus` | `finalFocus` | This prop sets an explicit focus target when the menu closes. | | `disableEscapeKeyDown` | dropped | This prop is against the menu pattern. Use `onKeyDown` if you need it. | | `disableScrollLock` | removed | `modal={false}` is not an equivalent. It also keeps the rest of the document interactive. The exact control is gone. | -| `hideBackdrop` | backdrop slot | You must add the backdrop yourself (open question 4). | +| `hideBackdrop` | backdrop slot | You must add the backdrop yourself (see the decisions above). | | `disablePortal` | dropped | The menu always uses a portal. | | `keepMounted`, `container` | same | The behavior is the same. | @@ -329,25 +384,25 @@ Left:
4. Transitions -| Classic Menu | New equivalent | -| :------------------------------------------------------------------ | :-------------------------------------------------------- | -| `TransitionComponent` / `slots.transition` (default `Grow`) | CSS with `data-starting-style` / `data-ending-style` | -| `transitionDuration` | CSS `transition-duration` on the popup | -| `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | -| default `Grow` animation | We add this animation as a CSS default (open question 2). | +| Classic Menu | New equivalent | +| :------------------------------------------------------------------ | :---------------------------------------------------------------- | +| `TransitionComponent` / `slots.transition` (default `Grow`) | CSS with `data-starting-style` / `data-ending-style` | +| `transitionDuration` | CSS `transition-duration` on the popup | +| `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | +| default `Grow` animation | We add this animation as a CSS default (see the decisions above). |
5. Styling and slots -| Classic Menu | New equivalent | Notes | -| :------------------------------------------------------------------------- | :------------------------------------------------------------------- | :---------------------------------------------------- | -| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list`, `backdrop` | There is no transition slot. The transitions use CSS. | -| `elevation` (default 8) | `elevation` (default 8, the component sends it to the Paper slot) | We keep this prop. | -| paper `maxHeight: calc(100% - 96px)` (the Modal clamps it to the viewport) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | The value reacts to collisions. | -| `slots.backdrop` + `BackdropProps` | `slots.backdrop` + `slotProps.backdrop` | You must add the backdrop yourself (open question 4). | -| `PopoverClasses` | n/a | The new component does not use a Popover. | +| Classic Menu | New equivalent | Notes | +| :------------------------------------------------------------------------- | :------------------------------------------------------------------- | :------------------------------------------------------------ | +| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list`, `backdrop` | There is no transition slot. The transitions use CSS. | +| `elevation` (default 8) | `elevation` (default 8, the component sends it to the Paper slot) | We keep this prop. | +| paper `maxHeight: calc(100% - 96px)` (the Modal clamps it to the viewport) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | The value reacts to collisions. | +| `slots.backdrop` + `BackdropProps` | `slots.backdrop` + `slotProps.backdrop` | You must add the backdrop yourself (see the decisions above). | +| `PopoverClasses` | n/a | The new component does not use a Popover. |
@@ -369,13 +424,14 @@ Left: | `MenuList.disablePadding`, `subheader` | `slotProps.list`, group parts | The groups get correct ARIA attributes. | + ## Resources and benchmarks Proof of concept and experiment: - PoC: [#48663](https://github.com/mui/material-ui/pull/48663) ([demo](https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/)) - Companion playground: [#48823](https://github.com/mui/material-ui/pull/48823) -- Bundle impact on `@mui/material`: the current report on [#48823](https://github.com/mui/material-ui/pull/48823) shows +3.54 KB parsed and +685 B gzip. The earlier +77 B number came from the proof of concept. That number is stale, because we merged the collapsed components and the shared popup module after it. The user pays for the Base UI code only when the user imports the component. +- Bundle impact on `@mui/material`: the current report on [#48823](https://github.com/mui/material-ui/pull/48823) shows +3.54 KB parsed and +685 B gzip. The earlier +77 B number came from the proof of concept, and is stale because we merged the collapsed components and the shared popup module after it. The user pays for the Base UI code only when the user imports the component. Demand: From 136136732456551aac30c59658a585062aff7b93 Mon Sep 17 00:00:00 2001 From: Lukas Date: Wed, 5 Aug 2026 18:22:26 +0300 Subject: [PATCH 49/62] Record four RFC decisions and correct the Tooltip claim - PropTypes: the infra now extracts inherited props (mui-public#1709). The RFC no longer states the limitation as permanent. We get the capability when we sync with master. - Ripple: decided. It comes by default, the item root becomes a styled ButtonBase, and `disableRipple` turns it off, the same as the other Material UI components. The item moves from the open questions to the decisions table. A ButtonBase inside an item stays impossible. - Removed the MenuItem and Select lifecycle question. That answer waits for the next major plan. - Removed the bundle size question. The path is decided. Also corrects the Tooltip statement. It said a Tooltip around any trigger needs a forwardRef component. That is true for a submenu trigger only, because the submenu trigger takes content. The root trigger takes an element, so trigger={
} slotProps={{ trigger: { variant: 'contained', @@ -297,7 +297,7 @@ function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings
Successor} slotProps={{ trigger: { variant: 'outlined', @@ -392,7 +392,7 @@ const typeaheadEntries = [ function TypeaheadScrollDemo() { return ( Country} slotProps={{ trigger: { variant: 'outlined', diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index f21a09d03ff8e5..f37b2b48a61d99 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import NextLink from 'next/link'; +import Button from '@mui/material/Button'; import Container from '@mui/material/Container'; import CssBaseline from '@mui/material/CssBaseline'; import Popover from '@mui/material/Popover'; @@ -269,7 +270,7 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove setAnchorEl(null); } }} - trigger="Help cards" + trigger={} slotProps={{ trigger: { variant: 'contained', @@ -326,7 +327,7 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { File} slotProps={{ trigger: { variant: 'contained', @@ -451,27 +452,9 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b [direction], ); - // The trigger is a prop now, so the tooltip around it has to move into the - // trigger's root slot. This is the one composition the collapse made harder. - const TooltipSubmenuTriggerRoot = React.useMemo( - () => - React.forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef<'div'> & { ownerState?: unknown } - >(function TooltipSubmenuTriggerRoot({ ownerState: _ownerState, ...rootProps }, ref) { - return ( - -
- - ); - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - return ( Tools} slotProps={{ trigger: { variant: 'contained', @@ -499,12 +482,13 @@ function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: b - View options - - + + + View options + + + } - slots={{ trigger: TooltipSubmenuTriggerRoot }} slotProps={{ trigger: { openOnHover: submenusOpenOnHover } }} > diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx index a0a78f16fb09ca..10b8e965e4e4f6 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -35,7 +35,6 @@ function Menu2Composition() { keepMounted finalFocus slots={{ - trigger: 'button', portal: 'div', positioner: 'div', popup: 'div', @@ -83,7 +82,7 @@ function Menu2Composition() { expectType(open); eventDetails.cancel(); }} - trigger="More" + trigger={More} sideOffset={2} slotProps={{ trigger: { openOnHover: true, nativeButton: false } }} > @@ -101,11 +100,11 @@ createTheme({ modal: false, align: 'start', }, - // The trigger and popup are rendered internally, so their overrides live - // on the collapsed component's slots. + // The popup parts are rendered internally, so their overrides live on the + // collapsed component's slots. The trigger is the caller's element, so it + // has no slot here. styleOverrides: { root: {}, - trigger: {}, backdrop: {}, paper: {}, list: {}, @@ -123,7 +122,6 @@ createTheme({ }, styleOverrides: { root: {}, - trigger: {}, paper: {}, list: {}, }, diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx index bdcb1e8396bb86..946613526fccc4 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { expect } from 'chai'; import { spy } from 'sinon'; import { createRenderer, fireEvent, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; +import Button from '@mui/material/Button'; import { listClasses } from '@mui/material/List'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; @@ -39,7 +40,10 @@ describe('', () => { it('opens from the trigger and keeps Menu.Popup as the semantic menu root', async () => { const { user } = render( - + Options} + > Profile , ); @@ -61,22 +65,6 @@ describe('', () => { expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class(menu2ItemClasses.root); }); - it('does not render the trigger as a link when href is passed by a JS caller', async () => { - const { user } = render( - - Profile - , - ); - - const trigger = screen.getByRole('button', { name: 'Options' }); - expect(trigger.tagName).to.equal('BUTTON'); - expect(trigger).not.to.have.attribute('href'); - - await user.click(trigger); - - expect(await screen.findByRole('menu')).not.to.equal(null); - }); - // Theming, classes, slots and the `component` prop are covered per part by // the describeConformance suites next to each component; what stays here is // Base UI-specific behavior and Material integration. @@ -86,7 +74,7 @@ describe('', () => { Options} > Profile , @@ -102,7 +90,14 @@ describe('', () => { try { const { user } = render( - + + Options +
+ } + slotProps={{ trigger: { nativeButton: false } }} + > Native item @@ -114,7 +109,10 @@ describe('', () => { Native radio - + Native submenu trigger} + slotProps={{ trigger: { nativeButton: true } }} + > Nested , @@ -168,16 +166,22 @@ describe('', () => { try { const { user } = render( + Options + + } slotProps={{ trigger: { nativeButton: false } }} > Custom native item + Custom native submenu trigger + + } slotProps={{ trigger: { nativeButton: true } }} > Nested @@ -226,7 +230,7 @@ describe('', () => { sx: { color: 'red' }, }, }} - trigger="Options" + trigger={} > Profile , @@ -250,7 +254,10 @@ describe('', () => { it('defaults the popup surface elevation to 8', async () => { const { user } = render( - + Options} + > Profile , ); @@ -262,7 +269,11 @@ describe('', () => { it('forwards a custom elevation to the popup surface', async () => { const { user } = render( - + Options} + > Profile , ); @@ -274,7 +285,7 @@ describe('', () => { it.skipIf(isJsdom())('animates the popup surface by default', async () => { const { user } = render( - + Options}> Profile , ); @@ -317,13 +328,13 @@ describe('', () => { async () => { const { user } = render( - + Options}> Profile {/* The indicator only mounts while checked. */} Bookmarks - + View}> Zoom in @@ -364,7 +375,10 @@ describe('', () => { it.skipIf(isJsdom())('lets the default animation be overridden', async () => { const { user } = render( - + Options} + > Profile , ); @@ -377,7 +391,10 @@ describe('', () => { it('renders an invisible backdrop that does not swallow clicks', async () => { const { user } = render( - + Options} + > Profile , ); @@ -399,7 +416,7 @@ describe('', () => { slotProps={{ backdrop: { 'data-testid': 'backdrop', sx: { backgroundColor: 'rgb(0, 0, 0)' } }, }} - trigger="Options" + trigger={} > Profile , @@ -413,7 +430,10 @@ describe('', () => { it.skipIf(isJsdom())('constrains the popup surface to the collision-aware height', async () => { const { user } = render( - + Options} + > Profile , ); @@ -439,7 +459,7 @@ describe('', () => { }); const { user } = render( - + Options}> Profile , ); @@ -454,7 +474,7 @@ describe('', () => { it('does not open when the root is disabled', async () => { render( - + Options}> Profile , ); @@ -465,7 +485,7 @@ describe('', () => { it('supports defaultOpen', () => { render( - + Options}> Profile , ); @@ -475,7 +495,7 @@ describe('', () => { it('supports keepMounted', () => { render( - + Options}> Profile , ); @@ -494,7 +514,7 @@ describe('', () => { - + Options}> Profile , @@ -512,7 +532,7 @@ describe('', () => { const { user } = render( - + Options}> Profile , @@ -539,7 +559,7 @@ describe('', () => { it('supports touch trigger interactions', async () => { const { user } = render( - + Options}> Profile , ); @@ -558,14 +578,14 @@ describe('', () => { Modal menu} > Profile Non-modal menu} > Settings @@ -592,7 +612,7 @@ describe('', () => { it('opens in an RTL tree', async () => { const { user } = render(
- + Options}> Profile
, @@ -611,7 +631,7 @@ describe('', () => { align="start" sideOffset={4} slotProps={{ positioner: { 'data-testid': 'positioner' } }} - trigger="Options" + trigger={} > Profile @@ -639,7 +659,7 @@ describe('', () => { }); const { user } = render( - + Options}> Show hidden files Small @@ -679,7 +699,7 @@ describe('', () => { it.skipIf(isJsdom())('gives the items a ripple, and disableRipple turns it off', async () => { const { user } = render( - + Options}> Profile No ripple @@ -707,7 +727,7 @@ describe('', () => { it('keeps mounted unchecked indicator marks hidden', () => { render( - + Options}> Show hidden files @@ -764,13 +784,13 @@ describe('', () => { it('supports groups, labels, separators, link items, and submenus', async () => { const { user } = render( - + Options}> Account Profile - + More}> Archive
, @@ -828,7 +848,7 @@ describe('', () => { const { user } = render( - + Other menu}> Other item @@ -861,7 +881,7 @@ describe('', () => { it.skipIf(isJsdom())('supports inset list text composed inside items', async () => { const { user } = render( - + Options}> i Cut @@ -885,8 +905,8 @@ describe('', () => { it.skipIf(isJsdom())('keeps separator spacing stable while a submenu is open', async () => { const { user } = render( - - + Options}> + View}> Zoom @@ -907,7 +927,7 @@ describe('', () => { it('supports Material UI Tooltip on enabled item flavors', async () => { const { user } = render( - + Options}> New document @@ -944,14 +964,18 @@ describe('', () => { onClickCapture?: React.MouseEventHandler; } - function ClickClosingTooltip(props: { - title: string; - children: React.ReactElement; - }) { - const { title, children } = props; + // A wrapper used as a trigger must forward the trigger's props and ref to + // its child, the way Material UI's own Tooltip does. + const ClickClosingTooltip = React.forwardRef< + HTMLElement, + { title: string; children: React.ReactElement } & Record + >(function ClickClosingTooltip(props, ref) { + const { title, children, ...forwarded } = props; const [open, setOpen] = React.useState(false); const child = React.cloneElement(children, { + ...forwarded, + ref, onClickCapture: (event: React.MouseEvent) => { setOpen(false); children.props.onClickCapture?.(event); @@ -971,27 +995,16 @@ describe('', () => { {child} ); - } - - // The compound shape wrapped the trigger element directly. With the trigger - // as a prop the wrapper has to move into the trigger's root slot, which is - // the one composition the collapse made harder. - const TooltipSubmenuTriggerRoot = React.forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef<'div'> & { ownerState?: unknown } - >(function TooltipSubmenuTriggerRoot({ ownerState: _ownerState, ...rootProps }, ref) { - return ( - -
- - ); }); const { user } = render( - + Options}> + View options + + } slotProps={{ trigger: { openOnHover: false } }} > Comments @@ -1014,7 +1027,7 @@ describe('', () => { it('supports Material UI Tooltip on disabled items through a non-disabled wrapper', async () => { const { user } = render( - + Options}> Import from Drive @@ -1039,8 +1052,8 @@ describe('', () => { try { const { user } = render( - - + Options}> + Add-ons unavailable}> Marketplace , diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index fc50d25d24aab1..3dc7dd6d43c6a7 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -5,18 +5,11 @@ import clsx from 'clsx'; import resolveComponentProps from '@mui/utils/resolveComponentProps'; import { Menu as BaseMenu } from '@base-ui/react/menu'; import Menu2Popup, { Menu2PopupProps } from './Menu2Popup'; -import Menu2Trigger from './Menu2Trigger'; import { useDefaultProps } from '../DefaultPropsProvider'; import { menu2TriggerClasses } from './menu2Classes'; import { SlotProps } from './menu2Utils'; -export interface Menu2Slots extends NonNullable { - /** - * The component that renders the trigger, when `trigger` is not an element. - * @default Menu2Trigger - */ - trigger?: React.ElementType | undefined; -} +export interface Menu2Slots extends NonNullable {} export interface Menu2SlotProps extends NonNullable { trigger?: SlotProps, Menu2Props> | undefined; @@ -37,15 +30,13 @@ export interface Menu2Props */ children?: React.ReactNode; /** - * The content that opens the menu. + * The element that opens the menu, for example a `Button`. * - * A single element becomes the trigger itself: the trigger behavior merges - * into it, so it keeps whatever component you passed. Any other node, such as - * text, a fragment, or several nodes, renders inside the default trigger. - * Omit it and drive the menu with `open` and `anchor` instead, which is the - * classic controlled pattern. + * The trigger behavior merges into this element, so it keeps the component + * that you passed. Omit it and drive the menu with `open` and `anchor` + * instead, which is the classic controlled pattern. */ - trigger?: React.ReactNode; + trigger?: React.ReactElement | undefined; /** * The components used for each slot inside. */ @@ -102,17 +93,12 @@ const Menu2 = React.forwardRef(function Menu2( ...rootProps } = themedProps; - const { trigger: triggerSlot, ...popupSlots } = slots ?? {}; + const popupSlots = slots; const { trigger: triggerSlotProps, ...popupSlotProps } = slotProps ?? {}; const resolvedTriggerProps = resolveComponentProps(triggerSlotProps, themedProps); - // A fragment is a valid element but cannot take the trigger's props or ref, - // so it counts as content and goes inside the default trigger. - const triggerIsElement = React.isValidElement(trigger) && trigger.type !== React.Fragment; - - let triggerNode: React.ReactNode = null; - if (trigger != null) { - triggerNode = triggerIsElement ? ( + const triggerNode = + trigger == null ? null : ( // Base UI's `render` merges the trigger behavior into the element, so the // caller keeps whatever component they passed. - ) : ( - - {trigger} - ); - } return ( @@ -201,18 +179,15 @@ Menu2.propTypes /* remove-proptypes */ = { popup: PropTypes.elementType, portal: PropTypes.elementType, positioner: PropTypes.elementType, - trigger: PropTypes.elementType, }), /** - * The content that opens the menu. + * The element that opens the menu, for example a `Button`. * - * A single element becomes the trigger itself: the trigger behavior merges - * into it, so it keeps whatever component you passed. Any other node, such as - * text, a fragment, or several nodes, renders inside the default trigger. - * Omit it and drive the menu with `open` and `anchor` instead, which is the - * classic controlled pattern. + * The trigger behavior merges into this element, so it keeps the component + * that you passed. Omit it and drive the menu with `open` and `anchor` + * instead, which is the classic controlled pattern. */ - trigger: PropTypes.node, + trigger: PropTypes.element, } as any; export default Menu2; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx index a50f5393b3a730..fb27c19513a01d 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -48,7 +48,7 @@ function Menu2Harness(props: { withSelected?: boolean }) { return (
- + Options}> Alpha Beta Gamma @@ -143,7 +143,7 @@ describe.skipIf(isJsdom())('Menu behavior benchmark: classic vs Menu2', () => { it('the successor highlights the first item, not the checked one', async () => { const { user } = render( - + Options}> 100% 200% diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx deleted file mode 100644 index 9d9156bfad338e..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuTrigger.tsx +++ /dev/null @@ -1,276 +0,0 @@ -'use client'; -import * as React from 'react'; -import resolveComponentProps from '@mui/utils/resolveComponentProps'; -import PropTypes from 'prop-types'; -import clsx from 'clsx'; -import { Menu as BaseMenu } from '@base-ui/react/menu'; -import ButtonBase from '../ButtonBase'; -import ListContext from '../List/ListContext'; -import { styled } from '../zero-styled'; -import memoTheme from '../utils/memoTheme'; -import { getMenu2ItemStyles } from './menu2SharedStyles'; -import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps } from './menu2Utils'; -import { - getMenu2ItemClassName, - getMenu2ItemOwnerState, - Menu2ItemOwnerState, - Menu2ItemVisualProps, - Menu2SubmenuTriggerBaseProps, - useMenu2ItemUtilityClasses, -} from './menu2ItemShared'; -import { - getMenu2SubmenuTriggerUtilityClass, - menu2SubmenuTriggerClasses, - Menu2SubmenuTriggerClasses, -} from './menu2Classes'; - -export interface Menu2SubmenuTriggerSlots { - /** - * The component that renders the root. - * @default 'div' - */ - root?: React.ElementType | undefined; -} - -export interface Menu2SubmenuTriggerSlotProps extends Menu2RootSlotProps {} - -export interface Menu2SubmenuTriggerProps - extends - Omit, - Menu2SubmenuTriggerBaseProps, - Menu2ItemVisualProps< - Menu2SubmenuTriggerClasses, - Menu2SubmenuTriggerSlots, - Menu2SubmenuTriggerSlotProps - > { - /** - * The content of the component. - */ - children?: React.ReactNode; - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled?: boolean | undefined; - /** - * Overrides the text label to use when the item is matched during keyboard text navigation. - */ - label?: string | undefined; - /** - * How long to wait before the submenu may be opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 100 - */ - delay?: number | undefined; - /** - * How long to wait before closing the submenu that was opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 0 - */ - closeDelay?: number | undefined; - /** - * Whether the submenu should also open when the trigger is hovered. - */ - openOnHover?: boolean | undefined; - /** - * CSS class applied to the element. - */ - className?: string | undefined; - /** - * If `true`, the ripple effect is disabled. - * @default false - */ - disableRipple?: boolean | undefined; - /** - * Styles applied to the root element. - */ - style?: React.CSSProperties | undefined; -} - -const Menu2SubmenuTriggerRoot = styled(ButtonBase, { - name: 'MuiMenu2Submenu', - slot: 'Trigger', - overridesResolver: (props, styles) => styles.trigger, -})<{ ownerState: Menu2ItemOwnerState }>( - memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2SubmenuTriggerClasses)), -); - -/** - * - * Demos: - * - * - [Menu](https://mui.com/material-ui/react-menu/) - */ -const Menu2SubmenuTrigger = React.forwardRef(function Menu2SubmenuTrigger( - inProps: Menu2SubmenuTriggerProps, - ref: React.ForwardedRef, -) { - // Internal: `MuiMenu2Submenu` defaults are applied by Menu2Submenu. - const props = inProps; - - const { - className, - classes: classesProp, - component, - dense = false, - disabled = false, - disableGutters = false, - disableRipple = false, - divider = false, - nativeButton: nativeButtonProp, - selected = false, - slotProps, - slots, - sx, - style, - ...other - } = props; - const ownerState = { - ...props, - ...getMenu2ItemOwnerState({ dense, disabled, disableGutters, divider, selected }), - classes: classesProp, - }; - const classes = useMenu2ItemUtilityClasses( - ownerState, - getMenu2SubmenuTriggerUtilityClass, - ); - const childContext = React.useMemo( - () => ({ - dense, - disableGutters, - }), - [dense, disableGutters], - ); - const RootSlot = slots?.root ?? Menu2SubmenuTriggerRoot; - - return ( - - by default; the item keeps its element. - component: component ?? 'div', - disableRipple, - ownerState, - sx, - })} - className={(state) => - clsx( - className, - getMenu2ItemClassName(classes, ownerState, state), - state.open && classes.open, - ) - } - disabled={disabled} - nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} - style={style} - {...other} - /> - - ); -}); - -Menu2SubmenuTrigger.propTypes /* remove-proptypes */ = { - // ┌────────────────────────────── Warning ──────────────────────────────┐ - // │ These PropTypes are generated from the TypeScript type definitions. │ - // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ - // └─────────────────────────────────────────────────────────────────────┘ - /** - * The content of the component. - */ - children: PropTypes.node, - /** - * Override or extend the styles applied to the component. - */ - classes: PropTypes.object, - /** - * CSS class applied to the element. - */ - className: PropTypes.string, - /** - * How long to wait before closing the submenu that was opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 0 - */ - closeDelay: PropTypes.number, - /** - * The component used for the root node. - */ - component: PropTypes.elementType, - /** - * How long to wait before the submenu may be opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 100 - */ - delay: PropTypes.number, - /** - * If `true`, compact vertical padding designed for keyboard and mouse input is used. - * @default false - */ - dense: PropTypes.bool, - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled: PropTypes.bool, - /** - * If `true`, the left and right padding is removed. - * @default false - */ - disableGutters: PropTypes.bool, - /** - * If `true`, a 1px light border is added to the bottom of the menu item. - * @default false - */ - divider: PropTypes.bool, - /** - * Overrides the text label to use when the item is matched during keyboard text navigation. - */ - label: PropTypes.string, - /** - * Whether the component is rendered as a native button. - * - * By default, this is inferred from the root slot and `component` prop. - */ - nativeButton: PropTypes.bool, - /** - * Whether the submenu should also open when the trigger is hovered. - */ - openOnHover: PropTypes.bool, - /** - * If `true`, the component is selected. - * @default false - */ - selected: PropTypes.bool, - /** - * The props used for each slot inside. - */ - slotProps: PropTypes.shape({ - root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - }), - /** - * The components used for each slot inside. - */ - slots: PropTypes.shape({ - root: PropTypes.elementType, - }), - /** - * Styles applied to the root element. - */ - style: PropTypes.object, - /** - * The system prop that allows defining system overrides as well as additional CSS styles. - */ - sx: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), - PropTypes.func, - PropTypes.object, - ]), -} as any; - -export default Menu2SubmenuTrigger; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx deleted file mode 100644 index ac2105541e583a..00000000000000 --- a/packages/mui-material/src/Unstable_Menu2/Menu2Trigger.tsx +++ /dev/null @@ -1,261 +0,0 @@ -'use client'; -import * as React from 'react'; -import PropTypes from 'prop-types'; -import clsx from 'clsx'; -import resolveComponentProps from '@mui/utils/resolveComponentProps'; -import composeClasses from '@mui/utils/composeClasses'; -import { Menu as BaseMenu } from '@base-ui/react/menu'; -import { SxProps } from '@mui/system'; -import Button, { ButtonProps } from '../Button'; -import { Theme } from '../styles'; -import { styled } from '../zero-styled'; -import { getMenu2RootRender, isMenu2RootNativeButton, Menu2RootSlotProps } from './menu2Utils'; -import { getMenu2TriggerUtilityClass, Menu2TriggerClasses } from './menu2Classes'; - -export interface Menu2TriggerSlots { - /** - * The component that renders the root. - * @default Button - */ - root?: React.ElementType | undefined; -} - -export interface Menu2TriggerProps - extends - Omit< - BaseMenu.Trigger.Props, - 'className' | 'handle' | 'nativeButton' | 'payload' | 'render' | 'style' - >, - Omit< - ButtonProps, - keyof BaseMenu.Trigger.Props | 'classes' | 'component' | 'disabled' | 'href' | 'style' - > { - /** - * The component used for the root node. - */ - component?: React.ElementType | undefined; - /** - * Override or extend the styles applied to the component. - */ - classes?: Partial | undefined; - /** - * CSS class applied to the element. - */ - className?: string | undefined; - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled?: boolean | undefined; - /** - * Whether the component is rendered as a native button. - * - * By default, this is inferred from the root slot and `component` prop. - */ - nativeButton?: boolean | undefined; - /** - * How long to wait before the menu may be opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 100 - */ - delay?: number | undefined; - /** - * How long to wait before closing the menu that was opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 0 - */ - closeDelay?: number | undefined; - /** - * Whether the menu should also open when the trigger is hovered. - */ - openOnHover?: boolean | undefined; - /** - * The components used for each slot inside. - */ - slots?: Menu2TriggerSlots | undefined; - /** - * The props used for each slot inside. - */ - slotProps?: Menu2TriggerSlotProps | undefined; - /** - * Styles applied to the root element. - */ - style?: React.CSSProperties | undefined; - /** - * The system prop that allows defining system overrides as well as additional CSS styles. - */ - sx?: SxProps | undefined; -} - -interface Menu2TriggerOwnerState extends Menu2TriggerProps { - disabled: boolean; -} - -export interface Menu2TriggerSlotProps extends Menu2RootSlotProps {} - -const useUtilityClasses = (ownerState: Menu2TriggerOwnerState) => { - const { classes } = ownerState; - - const slots = { - root: ['root'], - disabled: ['disabled'], - open: ['open'], - }; - - return { - ...classes, - ...composeClasses(slots, getMenu2TriggerUtilityClass, classes), - }; -}; - -const Menu2TriggerRoot = styled(Button, { - name: 'MuiMenu2', - slot: 'Trigger', - overridesResolver: (props, styles) => styles.trigger, -})({}) as any; - -const BaseMenuTrigger = BaseMenu.Trigger as any; -/** - * - * Demos: - * - * - [Menu](https://mui.com/material-ui/react-menu/) - */ -const Menu2Trigger = React.forwardRef(function Menu2Trigger( - inProps: Menu2TriggerProps, - ref: React.ForwardedRef, -) { - // Internal: the collapsed component has already applied `MuiMenu2` defaults. - const props = inProps; - - const { href: ignoredHref, ...propsWithoutHref } = props as Menu2TriggerProps & { - href?: unknown; - }; - void ignoredHref; - - const { - className, - classes: classesProp, - component, - disabled = false, - nativeButton: nativeButtonProp, - slotProps, - slots, - sx, - style, - ...other - } = propsWithoutHref; - const ownerState = { - ...propsWithoutHref, - classes: classesProp, - disabled, - }; - const classes = useUtilityClasses(ownerState); - const RootSlot = slots?.root ?? Menu2TriggerRoot; - - return ( - - clsx( - className, - classes.root, - state.open && classes.open, - state.disabled && classes.disabled, - ) - } - nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component, true)} - style={style} - {...other} - /> - ); -}) as ((props: Menu2TriggerProps & React.RefAttributes) => React.JSX.Element) & { - propTypes?: any; -}; - -Menu2Trigger.propTypes /* remove-proptypes */ = { - // ┌────────────────────────────── Warning ──────────────────────────────┐ - // │ These PropTypes are generated from the TypeScript type definitions. │ - // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ - // └─────────────────────────────────────────────────────────────────────┘ - /** - * @ignore - */ - children: PropTypes.node, - /** - * Override or extend the styles applied to the component. - */ - classes: PropTypes.object, - /** - * CSS class applied to the element. - */ - className: PropTypes.string, - /** - * How long to wait before closing the menu that was opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 0 - */ - closeDelay: PropTypes.number, - /** - * The component used for the root node. - */ - component: PropTypes.elementType, - /** - * How long to wait before the menu may be opened on hover, in milliseconds. - * - * Requires the `openOnHover` prop. - * @default 100 - */ - delay: PropTypes.number, - /** - * Whether the component should ignore user interaction. - * @default false - */ - disabled: PropTypes.bool, - /** - * Whether the component is rendered as a native button. - * - * By default, this is inferred from the root slot and `component` prop. - */ - nativeButton: PropTypes.bool, - /** - * Whether the menu should also open when the trigger is hovered. - */ - openOnHover: PropTypes.bool, - /** - * The props used for each slot inside. - */ - slotProps: PropTypes.shape({ - root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - }), - /** - * The components used for each slot inside. - */ - slots: PropTypes.shape({ - root: PropTypes.elementType, - }), - /** - * Styles applied to the root element. - */ - style: PropTypes.object, - /** - * The system prop that allows defining system overrides as well as additional CSS styles. - */ - sx: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), - PropTypes.func, - PropTypes.object, - ]), -} as any; - -export default Menu2Trigger; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts index 342036a93cf4c4..255f9dfc7079df 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts @@ -280,7 +280,7 @@ export const menu2SubmenuTriggerClasses: Menu2SubmenuTriggerClasses = generateUt * are rendered internally, so their overrides live here rather than under their * own component keys. */ -export type Menu2ClassKey = 'root' | 'trigger' | 'backdrop' | 'paper' | 'list'; +export type Menu2ClassKey = 'root' | 'backdrop' | 'paper' | 'list'; /** Theme `styleOverrides` slots for the collapsed `Menu2Submenu`. */ -export type Menu2SubmenuClassKey = 'root' | 'trigger' | 'paper' | 'list'; +export type Menu2SubmenuClassKey = 'root' | 'paper' | 'list'; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts index b49ce6b9331790..4e1bb9fe8fe140 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -3,6 +3,7 @@ import memoTheme from '../utils/memoTheme'; import { Theme } from '../styles'; import { menuListStyles, menuPaperStyles } from '../Menu/menuStyles'; import { getMenuItemRootStyles } from '../MenuItem/menuItemStyles'; +import { menu2SubmenuTriggerClasses } from './menu2Classes'; export interface SharedMenu2ItemClasses { highlighted: string; @@ -69,7 +70,22 @@ export const menu2PopupPaperStyles: CSSInterpolation = { overflowY: 'auto', }; -export const menu2PopupListStyles: CSSInterpolation = menuListStyles; +export const menu2PopupListStyles = memoTheme(({ theme }) => ({ + ...(menuListStyles as CSSObject), + // A submenu trigger is whatever element the caller passes, so its open state + // is styled from the list that contains it, not from a component we render. + [`& .${menu2SubmenuTriggerClasses.open}`]: { + backgroundColor: (theme.vars || theme).palette.action.focus, + }, + [`& .${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`]: { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, + ), + }, +})); /** * Default open/close animation for the menu surface, matching the classic diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index aa166e2ae9f54c..a5c50854d2394a 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -5,7 +5,10 @@ import Button from '@mui/material/Button'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import Menu2, { menu2PopupClasses, menu2TriggerClasses } from '@mui/material/Unstable_Menu2'; import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; -import Menu2Submenu, { menu2SubmenuPopupClasses } from '@mui/material/Unstable_Menu2Submenu'; +import Menu2Submenu, { + menu2SubmenuPopupClasses, + menu2SubmenuTriggerClasses, +} from '@mui/material/Unstable_Menu2Submenu'; // The collapsed shape: one component per menu at both levels, trigger as a // prop, children as the popup. @@ -14,7 +17,7 @@ describe(' collapsed API', () => { it('renders the trigger element as-is and opens the menu', async () => { const { user } = render( - Options}> + Options}> Profile , ); @@ -38,7 +41,7 @@ describe(' collapsed API', () => { it('renders with the portal and positioner slots swapped', async () => { const { user } = render( Options} slots={{ portal: 'div', positioner: 'div' }} slotProps={{ portal: { 'data-testid': 'portal' }, @@ -61,8 +64,8 @@ describe(' collapsed API', () => { const menuRef = React.createRef(); const submenuRef = React.createRef(); const { user } = render( - - + Options}> + More}> Nested , @@ -86,14 +89,12 @@ describe(' collapsed API', () => { components: { MuiMenu2: { styleOverrides: { - trigger: { letterSpacing: '3px' }, paper: { paddingTop: '9px' }, list: { paddingBottom: '7px' }, }, }, MuiMenu2Submenu: { styleOverrides: { - trigger: { letterSpacing: '5px' }, paper: { paddingTop: '11px' }, }, }, @@ -101,8 +102,14 @@ describe(' collapsed API', () => { }); const { user } = render( - - + Options} + slotProps={{ paper: { 'data-testid': 'paper' } }} + > + More} + slotProps={{ paper: { 'data-testid': 'submenu-paper' } }} + > Nested @@ -110,7 +117,6 @@ describe(' collapsed API', () => { ); const trigger = screen.getByRole('button', { name: 'Options' }); - expect(window.getComputedStyle(trigger).letterSpacing).to.equal('3px'); await user.click(trigger); const menu = await screen.findByRole('menu'); @@ -119,7 +125,6 @@ describe(' collapsed API', () => { expect(window.getComputedStyle(list).paddingBottom).to.equal('7px'); const submenuTrigger = screen.getByRole('menuitem', { name: 'More' }); - expect(window.getComputedStyle(submenuTrigger).letterSpacing).to.equal('5px'); await user.click(submenuTrigger); await waitFor(() => { @@ -147,8 +152,15 @@ describe(' collapsed API', () => { // `defaultOpen` avoids clicking the trigger Button, whose ripple animates // past the end of the test and trips the act() check. const { user } = render( - - + Options} + slotProps={{ paper: { 'data-testid': 'paper' } }} + > + More} + slotProps={{ paper: { 'data-testid': 'submenu-paper' } }} + > Nested , @@ -174,29 +186,30 @@ describe(' collapsed API', () => { expect(Math.round(submenu.top)).to.equal(Math.round(triggerRect.top) - 8); }); - // `trigger` is typed as a node, so every node kind has to work. A fragment is - // a valid element but cannot take props or a ref, so it must count as content. - it.each([ - ['text', 'Options'], - ['a fragment', Options], - ['several nodes', ['Options', ]], - ])('accepts %s as the trigger', async (_name, triggerValue) => { + // `trigger` takes an element at both levels, and the element the caller passes + // becomes the trigger itself. + it('renders the caller element as the trigger at both levels', async () => { const { user } = render( - - Profile + Options}> + More}> + Nested + , ); - const trigger = screen.getByRole('button'); + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger).to.have.class('MuiButton-root'); expect(trigger).to.have.class(menu2TriggerClasses.root); await user.click(trigger); - expect(await screen.findByRole('menu')).not.to.equal(null); + const submenuTrigger = await screen.findByRole('menuitem', { name: 'More' }); + expect(submenuTrigger).to.have.class(menu2ItemClasses.root); + expect(submenuTrigger).to.have.class(menu2SubmenuTriggerClasses.root); }); it('falls back to the default trigger for a non-element', async () => { const { user } = render( - + Options}> Profile , ); @@ -211,7 +224,7 @@ describe(' collapsed API', () => { it('marks the trigger open while the menu is open', async () => { const { user } = render( - Options}> + Options}> Profile , ); @@ -228,7 +241,7 @@ describe(' collapsed API', () => { it('accepts the hoisted popup props', async () => { const { user } = render( Options} + trigger={} side="top" elevation={16} slotProps={{ paper: { 'data-testid': 'paper' } }} @@ -267,9 +280,9 @@ describe(' collapsed API', () => { // The nested popup needs real layout to mount. it.skipIf(isJsdom())('uses the same shape for submenus', async () => { const { user } = render( - Options}> + Options}> Cut - + View}> Zoom in , diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index 0dd74b99da634e..c37e0816ea8bc3 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -1,20 +1,15 @@ 'use client'; import * as React from 'react'; import PropTypes from 'prop-types'; +import clsx from 'clsx'; import resolveComponentProps from '@mui/utils/resolveComponentProps'; import { Menu as BaseMenu } from '@base-ui/react/menu'; import Menu2SubmenuPopup, { Menu2SubmenuPopupProps } from '../Unstable_Menu2/Menu2SubmenuPopup'; -import Menu2SubmenuTrigger from '../Unstable_Menu2/Menu2SubmenuTrigger'; import { useDefaultProps } from '../DefaultPropsProvider'; import { SlotProps } from '../Unstable_Menu2/menu2Utils'; +import { menu2SubmenuTriggerClasses } from '../Unstable_Menu2/menu2Classes'; -export interface Menu2SubmenuSlots extends NonNullable { - /** - * The component that renders the trigger, when `trigger` is not an element. - * @default Menu2SubmenuTrigger - */ - trigger?: React.ElementType | undefined; -} +export interface Menu2SubmenuSlots extends NonNullable {} export interface Menu2SubmenuSlotProps extends NonNullable { trigger?: SlotProps, Menu2SubmenuProps> | undefined; @@ -33,13 +28,12 @@ export interface Menu2SubmenuProps */ children?: React.ReactNode; /** - * The content of the item that opens the submenu. + * The element that opens the submenu, for example a `Menu2Item`. * - * Unlike the root menu, this is the label rather than the element: a submenu - * trigger is always a menu item, so passing one would nest an item inside an - * item. Swap the component through `slots.trigger` instead. + * The trigger behavior merges into this element, the same as the root menu. + * A submenu trigger is a menu item, so pass an item rather than a button. */ - trigger?: React.ReactNode; + trigger?: React.ReactElement | undefined; /** * The components used for each slot inside. */ @@ -94,18 +88,27 @@ const Menu2Submenu = React.forwardRef(function Menu2Submenu( ...rootProps } = themedProps; - const { trigger: triggerSlot, ...popupSlots } = slots ?? {}; + const popupSlots = slots; const { trigger: triggerSlotProps, ...popupSlotProps } = slotProps ?? {}; const resolvedTriggerProps = resolveComponentProps(triggerSlotProps, themedProps); const triggerNode = trigger == null ? null : ( - )} {...resolvedTriggerProps} - > - {trigger} - + className={(state) => + clsx( + menu2SubmenuTriggerClasses.root, + state.open && menu2SubmenuTriggerClasses.open, + resolvedTriggerProps?.className, + ) + } + /> ); return ( @@ -172,16 +175,14 @@ Menu2Submenu.propTypes /* remove-proptypes */ = { popup: PropTypes.elementType, portal: PropTypes.elementType, positioner: PropTypes.elementType, - trigger: PropTypes.elementType, }), /** - * The content of the item that opens the submenu. + * The element that opens the submenu, for example a `Menu2Item`. * - * Unlike the root menu, this is the label rather than the element: a submenu - * trigger is always a menu item, so passing one would nest an item inside an - * item. Swap the component through `slots.trigger` instead. + * The trigger behavior merges into this element, the same as the root menu. + * A submenu trigger is a menu item, so pass an item rather than a button. */ - trigger: PropTypes.node, + trigger: PropTypes.element, } as any; export default Menu2Submenu; diff --git a/packages/mui-material/src/styles/enhanceHighContrast.test.ts b/packages/mui-material/src/styles/enhanceHighContrast.test.ts index e9b900ffe29515..4a5ecbcc47efb9 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.test.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.test.ts @@ -607,8 +607,6 @@ describe('enhanceHighContrast', () => { ['MuiMenu2LinkItem', menu2LinkItemClasses, 'root'], ['MuiMenu2CheckboxItem', menu2CheckboxItemClasses, 'root'], ['MuiMenu2RadioItem', menu2RadioItemClasses, 'root'], - // Rendered by Menu2Submenu, so its overrides sit on the `trigger` slot. - ['MuiMenu2Submenu', menu2SubmenuTriggerClasses, 'trigger'], ]; test.each(itemCases)( @@ -731,14 +729,13 @@ describe('enhanceHighContrast', () => { }); }); - test('MuiMenu2Submenu also marks the trigger open state as active', () => { + test('MuiMenu2Submenu marks the trigger open state from the list', () => { const theme = enhanceHighContrast(createTheme()); - const rootOverrides = theme.components?.MuiMenu2Submenu?.styleOverrides - ?.trigger as Array; - const hcmOverride = rootOverrides[rootOverrides.length - 1]; + const listOverrides = theme.components?.MuiMenu2Submenu?.styleOverrides + ?.list as Array; + const hcmOverride = listOverrides[listOverrides.length - 1] as Record; - const openKey = `&.${menu2SubmenuTriggerClasses.open}, &.${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`; - expect(hcmOverride[openKey]).to.deep.equal({ + expect(hcmOverride[`& .${menu2SubmenuTriggerClasses.open}`]).to.deep.equal({ [HCM]: { forcedColorAdjust: 'none', color: 'HighlightText', @@ -1088,7 +1085,7 @@ describe('enhanceHighContrast', () => { ['MuiMenu2LinkItem', 'root'], ['MuiMenu2CheckboxItem', 'root'], ['MuiMenu2RadioItem', 'root'], - ['MuiMenu2Submenu', 'trigger'], + ['MuiMenu2Submenu', 'list'], ['MuiMenu2CheckboxItemIndicator', 'root'], ['MuiMenu2RadioItemIndicator', 'root'], ['MuiNativeSelect', 'icon'], diff --git a/packages/mui-material/src/styles/enhanceHighContrast.ts b/packages/mui-material/src/styles/enhanceHighContrast.ts index 2028ddf08e122a..a35ef1f5ab428a 100644 --- a/packages/mui-material/src/styles/enhanceHighContrast.ts +++ b/packages/mui-material/src/styles/enhanceHighContrast.ts @@ -511,35 +511,44 @@ export default function enhanceHighContrast< ], }, }, - // The submenu trigger is rendered by Menu2Submenu, so its overrides live - // under that component's `trigger` slot. + // The submenu trigger is whatever element the caller passes, so its open + // state is styled from the list that contains it. MuiMenu2Submenu: { ...c?.MuiMenu2Submenu, styleOverrides: { ...c?.MuiMenu2Submenu?.styleOverrides, - trigger: [ - c?.MuiMenu2Submenu?.styleOverrides?.trigger, + list: [ + c?.MuiMenu2Submenu?.styleOverrides?.list, { - ...menu2ItemOverrides(menu2SubmenuTriggerClasses, hcTokens), - // A trigger whose submenu is open carries the focus background. - // The selected variant needs spelling out: the base style pairs - // `selected` with `open`, which outranks a lone `open` selector. - [`&.${menu2SubmenuTriggerClasses.open}, &.${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`]: - { - [HCM]: { - forcedColorAdjust: 'none', - color: hcTokens.activeText, - backgroundColor: hcTokens.activeBackground, - }, + [`& .${menu2SubmenuTriggerClasses.open}`]: { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.activeText, + backgroundColor: hcTokens.activeBackground, }, + }, + }, + ], + }, + }, + MuiMenu2: { + ...c?.MuiMenu2, + styleOverrides: { + ...c?.MuiMenu2?.styleOverrides, + list: [ + c?.MuiMenu2?.styleOverrides?.list, + { + [`& .${menu2SubmenuTriggerClasses.open}`]: { + [HCM]: { + forcedColorAdjust: 'none', + color: hcTokens.activeText, + backgroundColor: hcTokens.activeBackground, + }, + }, }, ], }, }, - // `forced-color-adjust` inherits, so the item rules above stop the browser - // from forcing colors on the indicator too. Inherit the item's color the way - // ListItemIcon does, and repaint the knocked-out checkmark in whatever the - // item's background now is, otherwise it merges into the box. MuiMenu2CheckboxItemIndicator: { ...c?.MuiMenu2CheckboxItemIndicator, styleOverrides: { From 2aeda50478b620b4a9a850f3b78dcaef57ca205b Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 11 Aug 2026 16:33:42 +0300 Subject: [PATCH 53/62] Pin the submenu open-state cascade and update the RFC Option A styles the submenu trigger's open state from the list that contains it, while the item styles its own selected state. Both selectors are (0,2,0), so the winner depends on style insertion order. A test now pins it: a selected trigger keeps its primary-colour blend when its submenu opens, and the blend gets stronger rather than falling back to the neutral focus colour. The order is correct today, and the test fails if it changes. The RFC now describes the element-only trigger: - The sample passes an element at both levels. - The three old results become four: `trigger` takes an element at both levels, a wrapper must forward props and ref, a submenu trigger must not close the menu, and we no longer infer `nativeButton`. - The Tooltip caveat is gone. It is now a worked example instead, because wrapping the trigger works at either level. - The theme row drops the `trigger` slots, which no longer exist. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 23 ++++++++++----- .../Menu2Collapsed.test.tsx | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index fd0ca69b8a8cb4..48a0057fe1e059 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -205,26 +205,35 @@ The review settled the shape. The experiment started with a fully compound API, ```jsx Options} slotProps={{ paper: { elevation: 4 } }}> Cut - + Share}> Email Copy link ``` -The trigger part and the popup part still exist, but they are internal. We export only their class hooks. Thus `styleOverrides` and `sx` do not change. +The popup part still exists, but it is internal. We export only its class hooks. Thus `styleOverrides` and `sx` do not change. -Three results come from this work: +Four results come from this work: -- **Both `trigger` props take a node.** For the root, a single element becomes the trigger itself, and Base UI's `render` merges the behavior into it, so the caller keeps their own component. Any other node, such as text, a fragment, or several nodes, renders inside the default trigger. A fragment is a valid element, but it cannot take props or a ref, so the root treats it as content. -- **The submenu `trigger` is always content.** This is the one place where the two levels cannot match. A submenu trigger is already a menu item, so an element in the `trigger` does not replace it. A `` there puts an item inside an item, and the submenu does not open. Use `slots.trigger` to change the component. -- **A `Tooltip` around a submenu trigger is now more difficult.** The root `trigger` accepts an element, so `trigger={}> + More}> + Nested + + , + ); + + const submenuTrigger = await screen.findByRole('menuitem', { name: 'More' }); + const selectedOnly = window.getComputedStyle(submenuTrigger).backgroundColor; + // The selected item tints with the primary colour. + expect(selectedOnly).to.contain('25, 118, 210'); + + await user.click(submenuTrigger); + await screen.findByRole('menuitem', { name: 'Nested' }); + + const selectedAndOpen = window.getComputedStyle(submenuTrigger).backgroundColor; + // Still the primary tint, not the neutral `action.focus` that the list sets + // for a plain open trigger. + expect(selectedAndOpen).to.contain('25, 118, 210'); + // And stronger than selected alone, because the open state adds focus opacity. + expect(selectedAndOpen).not.to.equal(selectedOnly); + }); + it.skipIf(isJsdom())('overlaps the parent menu by default', async () => { // The popup animates, so geometry has to be read after the transition ends. async function settle(element: HTMLElement) { From b576383841f12757761f58b0621045612562665c Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 13 Aug 2026 12:49:02 +0300 Subject: [PATCH 54/62] Fix the submenu triggers in the docs, and warn on a fragment The submenu triggers rendered as bare inline text instead of menu items. The docs pages still passed a `React.Fragment` as the trigger, which the earlier content-based API accepted. A fragment is an element, so the type accepts it, but Base UI cannot merge the trigger behavior or a ref into it. The trigger then loses its item styling and its behavior. The 9 fragment triggers in the playground and the recipes are now `Menu2Item` elements. `Menu2` and `Menu2Submenu` now log an error in development when `trigger` is a fragment, because the type cannot catch this. A test covers the warning. A second test pins the layout: a submenu trigger must be a flex row that fills the list width. The fragment version rendered inline, so this test fails if the problem returns. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-playground.tsx | 12 ++++---- docs/pages/experiments/menu2-recipes.tsx | 24 ++++++++-------- .../mui-material/src/Unstable_Menu2/Menu2.tsx | 12 ++++++++ .../Menu2Collapsed.test.tsx | 28 +++++++++++++++++++ .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 12 ++++++++ 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index 0bec2db12a6654..0d7bf07c14ebeb 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -187,10 +187,10 @@ function PlaygroundDemo({ + Share - + } slotProps={{ trigger: submenuTriggerProps }} {...submenuPopupProps} @@ -204,10 +204,10 @@ function PlaygroundDemo({ + Export as - + } slotProps={{ trigger: submenuTriggerProps }} {...submenuPopupProps} @@ -229,10 +229,10 @@ function PlaygroundDemo({ + View - + } slotProps={{ trigger: submenuTriggerProps }} {...submenuPopupProps} diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index f37b2b48a61d99..c02fb40a0b767c 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -201,7 +201,7 @@ function MaterialPreviewCard(props: { }} > {item ? ( - + {item.label} @@ -211,7 +211,7 @@ function MaterialPreviewCard(props: { {item.footer} - + ) : null} ); @@ -287,10 +287,10 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove + Version history - + } slotProps={{ trigger: { @@ -353,10 +353,10 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { + View options - + } slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} > @@ -386,10 +386,10 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { + More tools - + } slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} > @@ -401,10 +401,10 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { + Download - + } slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} > @@ -416,10 +416,10 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { + Add-ons unavailable - + } slotProps={{ trigger: { disabled: true, openOnHover: settings.submenusOpenOnHover } }} > diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index 3dc7dd6d43c6a7..cf3b5045e58ead 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -97,6 +97,18 @@ const Menu2 = React.forwardRef(function Menu2( const { trigger: triggerSlotProps, ...popupSlotProps } = slotProps ?? {}; const resolvedTriggerProps = resolveComponentProps(triggerSlotProps, themedProps); + if (process.env.NODE_ENV !== 'production' && trigger != null) { + // A fragment is an element, so the type does not catch it. Base UI cannot + // merge the trigger behavior into a fragment, and the trigger renders as + // bare content instead. + if ((trigger as React.ReactElement).type === React.Fragment) { + console.error( + 'MUI: The `trigger` prop of `Menu2` cannot be a fragment. ' + + 'Pass a single element, for example a `Button`.', + ); + } + } + const triggerNode = trigger == null ? null : ( // Base UI's `render` merges the trigger behavior into the element, so the diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index 64c24f552c3aeb..ff9d788f8e1d4d 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -165,6 +165,24 @@ describe(' collapsed API', () => { expect(selectedAndOpen).not.to.equal(selectedOnly); }); + it('warns when the trigger is a fragment', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + render( + Options}> + Profile + , + ); + + expect( + error.mock.calls.some(([message]) => String(message).includes('cannot be a fragment')), + ).to.equal(true); + } finally { + error.mockRestore(); + } + }); + it.skipIf(isJsdom())('overlaps the parent menu by default', async () => { // The popup animates, so geometry has to be read after the transition ends. async function settle(element: HTMLElement) { @@ -234,6 +252,16 @@ describe(' collapsed API', () => { const submenuTrigger = await screen.findByRole('menuitem', { name: 'More' }); expect(submenuTrigger).to.have.class(menu2ItemClasses.root); expect(submenuTrigger).to.have.class(menu2SubmenuTriggerClasses.root); + + // It must lay out as a menu item row, not as inline content. A fragment + // trigger used to render bare text here. + const { display } = window.getComputedStyle(submenuTrigger); + expect(display).to.equal('flex'); + const list = submenuTrigger.parentElement!; + expect(submenuTrigger.getBoundingClientRect().width).to.be.closeTo( + list.getBoundingClientRect().width, + 2, + ); }); it('falls back to the default trigger for a non-element', async () => { diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index c37e0816ea8bc3..b85c109685d0f5 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -92,6 +92,18 @@ const Menu2Submenu = React.forwardRef(function Menu2Submenu( const { trigger: triggerSlotProps, ...popupSlotProps } = slotProps ?? {}; const resolvedTriggerProps = resolveComponentProps(triggerSlotProps, themedProps); + if (process.env.NODE_ENV !== 'production' && trigger != null) { + // A fragment is an element, so the type does not catch it. Base UI cannot + // merge the trigger behavior into a fragment, and the trigger renders as + // bare content instead. + if ((trigger as React.ReactElement).type === React.Fragment) { + console.error( + 'MUI: The `trigger` prop of `Menu2Submenu` cannot be a fragment. ' + + 'Pass a single element, for example a `Menu2Item`.', + ); + } + } + const triggerNode = trigger == null ? null : ( Date: Thu, 13 Aug 2026 15:05:51 +0300 Subject: [PATCH 55/62] Fix the submenu props that the docs sent to the wrong place The trigger is the caller's element now, so `slotProps.trigger` reaches Base UI's submenu trigger, not the item. The docs still sent item props and event handlers through it. - The playground sent `dense` and `divider` through `slotProps.trigger`. They reach the DOM there, and the item loses that styling. They move to the trigger element. - The preview card recipe sent `onFocus` and `onMouseEnter` through `slotProps.trigger`. Base UI uses `onMouseEnter` to open a submenu on hover, so this competed with its own handler. They move to the element, where Base UI composes with them. - A disabled submenu trigger sent `disabled` through `slotProps.trigger`, so it did not look disabled. It moves to the element. Adds the hover test that was missing. It asserts that a submenu opens on hover and that the element's own `onMouseEnter` still runs. Every earlier submenu test used a click, so hover had no coverage at all. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-playground.tsx | 9 ++++--- docs/pages/experiments/menu2-recipes.tsx | 8 +++--- .../Menu2Collapsed.test.tsx | 26 +++++++++++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx index 0d7bf07c14ebeb..6b1216b67d3816 100644 --- a/docs/pages/experiments/menu2-playground.tsx +++ b/docs/pages/experiments/menu2-playground.tsx @@ -127,8 +127,9 @@ function PlaygroundDemo({ }) { const popupKnobProps = usePopupKnobProps(settings); const itemProps = { dense: settings.dense, divider: settings.dividers }; + // The trigger element carries the item props. `slotProps.trigger` reaches + // Base UI's submenu trigger, so only its own props belong there. const submenuTriggerProps = { - ...itemProps, openOnHover: settings.submenusOpenOnHover, delay: settings.submenuDelay, closeDelay: settings.submenuCloseDelay, @@ -187,7 +188,7 @@ function PlaygroundDemo({ + Share @@ -204,7 +205,7 @@ function PlaygroundDemo({ + Export as @@ -229,7 +230,7 @@ function PlaygroundDemo({ + View diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index c02fb40a0b767c..5e8afc6c049f98 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -287,7 +287,7 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove + Version history @@ -295,8 +295,6 @@ function Menu2WithPreviewCardsDemo({ submenusOpenOnHover }: { submenusOpenOnHove slotProps={{ trigger: { openOnHover: submenusOpenOnHover, - onFocus: clearActiveItem, - onMouseEnter: clearActiveItem, }, }} > @@ -416,12 +414,12 @@ function Menu2Demo({ settings }: { settings: MenuSettings }) { + Add-ons unavailable } - slotProps={{ trigger: { disabled: true, openOnHover: settings.submenusOpenOnHover } }} + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} > Marketplace diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index ff9d788f8e1d4d..547fd90c7e7d4a 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { expect } from 'chai'; +import { spy } from 'sinon'; import { createRenderer, isJsdom, screen, waitFor } from '@mui/internal-test-utils'; import Button from '@mui/material/Button'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -183,6 +184,31 @@ describe(' collapsed API', () => { } }); + // Hover is the default way to open a submenu, and it kept working only by + // accident until now: nothing covered it. + it.skipIf(isJsdom())('opens a submenu on hover, and keeps the element handler', async () => { + const onMouseEnter = spy(); + const { user } = render( + Options}> + More}> + Nested + + , + ); + + const submenuTrigger = await screen.findByRole('menuitem', { name: 'More' }); + await user.hover(submenuTrigger); + + await waitFor( + () => { + expect(screen.queryByRole('menuitem', { name: 'Nested' })).not.to.equal(null); + }, + { timeout: 2000 }, + ); + // Base UI composes with the element's own handler rather than replacing it. + expect(onMouseEnter.callCount).to.be.greaterThan(0); + }); + it.skipIf(isJsdom())('overlaps the parent menu by default', async () => { // The popup animates, so geometry has to be read after the transition ends. async function settle(element: HTMLElement) { From 7da04f388ee9e6d33aeabd0040a60852d8f94295 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 13 Aug 2026 17:29:59 +0300 Subject: [PATCH 56/62] Restore the preview card content that a blanket replace converted The preview card rendered a `Menu2Item` inside its Popover. The Popover holds description text, not a menu item. I caused this. When I converted the fragment submenu triggers, I ran one regular expression over the whole file. It replaced every `React.Fragment` in the file, including the one that groups the Popover's text. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-recipes.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index 5e8afc6c049f98..a9aaeab6af7e88 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -201,7 +201,7 @@ function MaterialPreviewCard(props: { }} > {item ? ( - + {item.label} @@ -211,7 +211,7 @@ function MaterialPreviewCard(props: { {item.footer} - + ) : null} ); From 92442a0944201a6a199e594116d8380a064c73a8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 13 Aug 2026 17:37:09 +0300 Subject: [PATCH 57/62] Make MenuTooltip forward props and the ref to its child Clicking a tooltip-wrapped submenu trigger closed the menu instead of opening the submenu. `MenuTooltip` read `title`, `children`, and `tooltipProps`, then dropped every other prop and the ref. Base UI merges the submenu trigger behavior into the element that it renders, which was the tooltip. The tooltip swallowed it, so `closeOnClick: false` and the trigger's own handlers never reached the `Menu2Item` inside. The item then used its own default and closed the menu. The same swallowing applies to Base UI's hover handlers, so this is a likely cause of the submenu hover problem in the tooltip demo as well. The RFC already states that a wrapper must forward props and the ref. This recipe did not follow that rule. Adds a test with a forwarding wrapper around a submenu trigger: the click opens the submenu and the parent menu stays open. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-recipes.tsx | 23 ++++++++---- .../Menu2Collapsed.test.tsx | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx index a9aaeab6af7e88..882087035a7e78 100644 --- a/docs/pages/experiments/menu2-recipes.tsx +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -118,12 +118,19 @@ interface MenuTooltipChildProps { onClickCapture?: React.MouseEventHandler; } -function MenuTooltip(props: { - title: string; - children: React.ReactElement; - tooltipProps?: Partial; -}) { - const { title, children, tooltipProps = horizontalTooltipProps } = props; +// A wrapper used around a menu item, or as a menu trigger, must forward every +// other prop and the ref to its child. Base UI merges the item and trigger +// behavior into the element it renders, and a wrapper that drops those props +// swallows the behavior. +const MenuTooltip = React.forwardRef< + HTMLElement, + { + title: string; + children: React.ReactElement; + tooltipProps?: Partial; + } & Record +>(function MenuTooltip(props, ref) { + const { title, children, tooltipProps = horizontalTooltipProps, ...forwarded } = props; const [open, setOpen] = React.useState(false); const handleOpen = React.useCallback(() => { @@ -135,6 +142,8 @@ function MenuTooltip(props: { }, []); const child = React.cloneElement(children, { + ...forwarded, + ref, onClickCapture: (event: React.MouseEvent) => { setOpen(false); children.props.onClickCapture?.(event); @@ -153,7 +162,7 @@ function MenuTooltip(props: { {child} ); -} +}); function MaterialPreviewCard(props: { id: string | undefined; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index 547fd90c7e7d4a..44ba753ff8a9fc 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -209,6 +209,41 @@ describe(' collapsed API', () => { expect(onMouseEnter.callCount).to.be.greaterThan(0); }); + // A wrapper around the trigger swallows props that it does not forward, so + // `closeOnClick` never reaches the item inside it. + it.skipIf(isJsdom())('opens the submenu when the trigger sits inside a wrapper', async () => { + // A wrapper that forwards keeps the behavior. One that drops props, the way + // a hand-written Tooltip helper easily does, swallows it: the submenu never + // opens and the click closes the menu instead. + const Wrapper = React.forwardRef< + HTMLElement, + { children: React.ReactElement } & Record + >(function Wrapper(props, ref) { + const { children, ...forwarded } = props; + return React.cloneElement(children, { ...forwarded, ref }); + }); + + const { user } = render( + Options}> + + More + + } + > + Nested + + , + ); + + await user.click(await screen.findByRole('menuitem', { name: 'More' })); + + // The submenu opens, and the parent menu stays open. + expect(await screen.findByRole('menuitem', { name: 'Nested' })).not.to.equal(null); + expect(screen.queryByRole('menuitem', { name: 'More' })).not.to.equal(null); + }); + it.skipIf(isJsdom())('overlaps the parent menu by default', async () => { // The popup animates, so geometry has to be read after the transition ends. async function settle(element: HTMLElement) { From c96a2f7320b0a9ce58cd0c190e2665297a63c96f Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 13 Aug 2026 17:50:03 +0300 Subject: [PATCH 58/62] Warn when a wrapper swallows the trigger ref A wrapper that does not forward props and the ref disables the menu behavior silently. Three demos in this branch did it, and each one looked correct until someone clicked. `Menu2` and `Menu2Submenu` now attach a ref to the trigger. When that ref is still unset after mount, the element never received what Base UI merged into it, so the component logs an error in development that names the rule. A test covers a wrapper that returns its children and drops everything. Co-Authored-By: Claude Opus 5 --- .../mui-material/src/Unstable_Menu2/Menu2.tsx | 16 ++++++++++ .../Menu2Collapsed.test.tsx | 29 +++++++++++++++++++ .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 16 ++++++++++ 3 files changed, 61 insertions(+) diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx index cf3b5045e58ead..9e02a4bba10902 100644 --- a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -109,11 +109,27 @@ const Menu2 = React.forwardRef(function Menu2( } } + // A wrapper that does not forward the ref also swallows the trigger + // behavior, and nothing else reports it. An unset ref proves the element + // never received what Base UI merged into it. + const triggerRef = React.useRef(null); + React.useEffect(() => { + if (process.env.NODE_ENV !== 'production' && trigger != null && triggerRef.current == null) { + console.error( + 'MUI: The `trigger` element of `Menu2` did not receive a ref. ' + + 'A component used as the trigger must forward its props and its ref to ' + + 'the element that it renders, the way Tooltip does. Without them the ' + + 'menu behavior does not reach the element.', + ); + } + }, [trigger]); + const triggerNode = trigger == null ? null : ( // Base UI's `render` merges the trigger behavior into the element, so the // caller keeps whatever component they passed. diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index 44ba753ff8a9fc..2703d471f26a6e 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -244,6 +244,35 @@ describe(' collapsed API', () => { expect(screen.queryByRole('menuitem', { name: 'More' })).not.to.equal(null); }); + it('warns when a wrapper swallows the trigger ref', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Drops every prop and the ref, the way a hand-written helper easily does. + function SwallowingWrapper(props: { children: React.ReactElement }) { + return props.children; + } + + try { + render( + + + + } + > + Profile + , + ); + + expect( + error.mock.calls.some(([message]) => String(message).includes('did not receive a ref')), + ).to.equal(true); + } finally { + error.mockRestore(); + } + }); + it.skipIf(isJsdom())('overlaps the parent menu by default', async () => { // The popup animates, so geometry has to be read after the transition ends. async function settle(element: HTMLElement) { diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index b85c109685d0f5..9b5519ac1ba0d2 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -104,9 +104,25 @@ const Menu2Submenu = React.forwardRef(function Menu2Submenu( } } + // A wrapper that does not forward the ref also swallows the trigger + // behavior, and nothing else reports it. An unset ref proves the element + // never received what Base UI merged into it. + const triggerRef = React.useRef(null); + React.useEffect(() => { + if (process.env.NODE_ENV !== 'production' && trigger != null && triggerRef.current == null) { + console.error( + 'MUI: The `trigger` element of `Menu2Submenu` did not receive a ref. ' + + 'A component used as the trigger must forward its props and its ref to ' + + 'the element that it renders, the way Tooltip does. Without them the ' + + 'menu behavior does not reach the element.', + ); + } + }, [trigger]); + const triggerNode = trigger == null ? null : ( Date: Thu, 13 Aug 2026 18:02:37 +0300 Subject: [PATCH 59/62] Remove the component lifecycle and adoption plan from the RFC The RFC described the component name across major versions, the renames, the codemods, and the graduation checklist. Those answers need the plan for the next major version, and that plan does not exist yet. The positioning section now states the scope: the successor is new, the current Menu does not change, and the naming and the migration are a separate question. The component ships under the `Unstable_` prefix while the API settles. Also removes requirement 8, which asked for a path to become `Menu`, and the Grid lifecycle references that only supported the removed plan. Co-Authored-By: Claude Opus 5 --- docs/pages/experiments/menu2-rfc.md | 43 +++++++---------------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md index 48a0057fe1e059..d49329e2aa2e08 100644 --- a/docs/pages/experiments/menu2-rfc.md +++ b/docs/pages/experiments/menu2-rfc.md @@ -20,7 +20,7 @@ Material UI's `Menu` cannot do submenus. - **Weak community options.** `mui-nested-menu`, `material-ui-popup-state`, and many sandboxes have weak keyboard and ARIA support. Maintainers report this problem many times. - **Copy-paste code.** The Menubar docs page shows Base UI submenus as copy-paste code. Users then asked for a real component ([#48336](https://github.com/mui/material-ui/issues/48336)). Copy-paste code has no version, no tests, and no theme support. -We want submenus in `@mui/material` with Material visuals and full theme support. The current `Menu` must stay stable. The plan is to make the new component the default `Menu` in the next major version. +We want submenus in `@mui/material` with Material visuals and full theme support. The current `Menu` must stay stable. This RFC also sets the rules to build future Material UI components on Base UI: customization, style reuse, dependencies, tests, and tools. Menu is the first component. We intend the decisions here to apply to the other components. @@ -39,9 +39,8 @@ This RFC also sets the rules to build future Material UI components on Base UI 5. **Near-zero cost for existing users.** The current `Menu` continues to work. Apps that do not import the new component get no behavior change and no Base UI bundle cost. They pay a one-time cost, because the classic components now read the styles that the new components share. See the size numbers below. 6. **Keep the current API where the new foundation permits it.** Document the places where it does not. 7. **Add the other menu features that users request often, at the same time.** These features are checkbox items, radio items, groups, hover-open, and context menus. Then we do not need to change the API later. -8. **A clear path to become `Menu` in the next major version.** Supply a migration guide and codemods, so early adopters keep a way forward. -9. **Reuse a maintained library.** Do not build focus, dismissal, and positioning again. -10. **The component must look like any other Material UI component** in tooling, theming, imports, and tests. Users do not need to know about Base UI, and they do not install anything extra. +8. **Reuse a maintained library.** Do not build focus, dismissal, and positioning again. +9. **The component must look like any other Material UI component** in tooling, theming, imports, and tests. Users do not need to know about Base UI, and they do not install anything extra. ## What are our options? @@ -89,41 +88,21 @@ Our work is the style, the theme, and the API surface. ## Proposed solution -We propose a successor to `Menu` that uses Base UI. This successor follows the Grid lifecycle. A proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) shows that this works. We test the open questions in a companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)). +We propose a successor to `Menu` that uses Base UI. A proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) shows that this works. We test the open questions in a companion experiment ([#48823](https://github.com/mui/material-ui/pull/48823)). -### Positioning and lifecycle (decided) +### Positioning (decided) -The new component is a successor. It is not a rewrite of the current internals. It is also not a second namespace that stays forever. +The new component is a successor. It is not a rewrite of the current internals. The current `Menu` does not change. -| Phase | Component name | What happens | -| :-------------- | :--------------- | :----------------------------------------------------------------------------------------------- | -| Now (v9 minors) | `Unstable_Menu2` | Public incubation, a real release. The theme keys and the classes are `MuiMenu2*`. | -| Later in v9 | `Menu2` | Stable under the interim name. The current `Menu` does not change. The theme keys do not change. | -| Next major | `Menu` | `Menu2` becomes the canonical name. | -| Next major | `MenuLegacy` | We rename and deprecate the current `Menu`. We supply a codemod. | +This RFC does not cover the component name across major versions, or how applications move from the current `Menu` to the successor. Those questions need the plan for the next major version, and that plan does not exist yet. We answer them separately. -This plan follows Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, old one renamed `GridLegacy`, [#45363](https://github.com/mui/material-ui/pull/45363)). - -- **Renames.** Each rename breaks early adopters. But a codemod can do the rename, and we accepted this trade before. -- **The `2` suffix.** It makes a stable phase before the major release possible. A name without the suffix would collide with the `Menu` that we still release. -- **The `Unstable_` prefix.** Only the directories, the subpaths, and the exports use it. The internal names are `Menu2*` and the theme keys are `MuiMenu2*`. Our lint rules require this, and it matches Grid2. -- **The theme keys.** They do not change in the `Unstable_Menu2` -> `Menu2` step. Only the final promotion to `Menu` renames them. -- **The imports.** They follow our usual convention: flat names, one component for each subpath, and no short aliases such as `Root` or `Item`. +The component ships under the `Unstable_` prefix while the API settles. The imports follow our usual convention: flat names, one component for each subpath. ```jsx import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2Item from '@mui/material/Unstable_Menu2Item'; ``` -The subpaths use default exports. Therefore adopters can remove the `Unstable_` prefix in their own code. Their JSX then looks like the future API. We add the barrel exports at graduation. - -A checklist of four items controls graduation. It is not a judgment call. - -- The conformance suite passes, minus the documented skips. -- The theme registration is at parity. -- The `data-*` boundary is pinned. -- The design team approves. - ### Rules for Base UI-backed components (Menu is the first) The rule: a Base UI-backed component must look the same as any other Material UI component. Only the behavior below the surface is new. @@ -271,7 +250,7 @@ The proof of concept ([#48663](https://github.com/mui/material-ui/pull/48663)) c Done: -- **Names.** We renamed the components to the `Unstable_Menu2` lifecycle name, one component for each subpath. +- **Names.** The components use the `Unstable_Menu2` name, one component for each subpath. - **Docs tooling.** We removed the special cases. - **Styles.** The classic component and the successor share the same style modules. - **Composition.** Composed list primitives still work inside the items. `ListItemText inset` aligns with the icon column. `inset` is a `ListItemText` prop, not a menu item prop, so we implemented nothing. @@ -326,8 +305,7 @@ These are settled. The detail stays here, because the caveats matter. 1. Behavior benchmark: **done**. The results are above. 2. Design phase for the API shape. We try the design in the companion experiment, then answer each question against a real preview. 3. Release `Unstable_Menu2` in a v9 minor version, with conformance tests, API docs, and demos on the Menu page. -4. Make changes from the feedback. Then make `Menu2` stable when it passes the graduation checklist. -5. Next major version: promote `Menu2` to `Menu`, rename the old component to `MenuLegacy`, and release the migration guide and the codemods. +4. Make changes from the feedback, then remove the `Unstable_` prefix when the API settles. ### Appendix: full prop mapping @@ -441,7 +419,6 @@ Earlier attempts: Direction and precedent: - Maintainer statement (Dec 2024): [#11723 comment](https://github.com/mui/material-ui/issues/11723#issuecomment-2556390056) -- "Material UI will adopt (this new) Base UI component in its next major release." -- Grid lifecycle: [#45363](https://github.com/mui/material-ui/pull/45363) - Menubar docs page that uses Base UI: [react-menubar](https://mui.com/material-ui/react-menubar/) (from [#47616](https://github.com/mui/material-ui/pull/47616)) - [Base UI Menu](https://base-ui.com/react/components/menu) and [releases](https://base-ui.com/react/overview/releases) - Why the Base UI Menu has no `initialFocus`: [base-ui#2143](https://github.com/mui/base-ui/issues/2143) From e9eb6cd09eb93634c697c462d23a95d011652833 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 14 Aug 2026 12:30:12 +0300 Subject: [PATCH 60/62] Highlight the submenu trigger like a plain menu item Base UI gives the submenu trigger three states: disabled, highlighted, and open. We mapped only `open` to a class, so the highlighted state never reached the element. Two symptoms came from that one gap. The keyboard showed no highlight at all on a submenu trigger. The pointer showed only the weaker CSS `:hover` tint, and the trigger reached full strength only after the submenu opened, because `open` was the single state we styled. The intensity looked inverted. The class callback now maps all three states, and the list styles the highlighted and the disabled states next to the open state. A highlighted trigger uses the same colour as a highlighted plain item. A test navigates with the keyboard and compares the trigger's background with a plain item's, so the two cannot drift apart again. Co-Authored-By: Claude Opus 5 --- .../src/Unstable_Menu2/menu2SharedStyles.ts | 21 ++++++++++------ .../Menu2Collapsed.test.tsx | 25 +++++++++++++++++++ .../Unstable_Menu2Submenu/Menu2Submenu.tsx | 4 +++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts index 4e1bb9fe8fe140..ad2906852ea316 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -74,17 +74,22 @@ export const menu2PopupListStyles = memoTheme(({ theme }) => ({ ...(menuListStyles as CSSObject), // A submenu trigger is whatever element the caller passes, so its open state // is styled from the list that contains it, not from a component we render. - [`& .${menu2SubmenuTriggerClasses.open}`]: { + // The highlighted state matches a plain item, and an open trigger keeps it. + [`& .${menu2SubmenuTriggerClasses.highlighted}, & .${menu2SubmenuTriggerClasses.open}`]: { backgroundColor: (theme.vars || theme).palette.action.focus, }, - [`& .${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`]: { - backgroundColor: theme.alpha( - (theme.vars || theme).palette.primary.main, - `${(theme.vars || theme).palette.action.selectedOpacity} + ${ - (theme.vars || theme).palette.action.focusOpacity - }`, - ), + [`& .${menu2SubmenuTriggerClasses.disabled}`]: { + opacity: (theme.vars || theme).palette.action.disabledOpacity, }, + [`& .${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.highlighted}, & .${menu2SubmenuTriggerClasses.selected}.${menu2SubmenuTriggerClasses.open}`]: + { + backgroundColor: theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, + ), + }, })); /** diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx index 2703d471f26a6e..21299d355c6a62 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -273,6 +273,31 @@ describe(' collapsed API', () => { } }); + // The trigger showed no highlight for the keyboard, and only the weaker CSS + // `:hover` for the pointer, because the highlighted state was not mapped. + it.skipIf(isJsdom())('highlights the submenu trigger like a plain item', async () => { + const { user } = render( + Options}> + Plain + More}> + Nested + + , + ); + + const plain = await screen.findByRole('menuitem', { name: 'Plain' }); + const submenuTrigger = screen.getByRole('menuitem', { name: 'More' }); + + // Keyboard: the trigger takes the highlight the same way a plain item does. + await user.keyboard('{ArrowDown}'); + expect(plain).to.have.class(menu2ItemClasses.highlighted); + const plainHighlight = window.getComputedStyle(plain).backgroundColor; + + await user.keyboard('{ArrowDown}'); + expect(submenuTrigger).to.have.class(menu2SubmenuTriggerClasses.highlighted); + expect(window.getComputedStyle(submenuTrigger).backgroundColor).to.equal(plainHighlight); + }); + it.skipIf(isJsdom())('overlaps the parent menu by default', async () => { // The popup animates, so geometry has to be read after the transition ends. async function settle(element: HTMLElement) { diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx index 9b5519ac1ba0d2..355946e3952080 100644 --- a/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -132,6 +132,10 @@ const Menu2Submenu = React.forwardRef(function Menu2Submenu( className={(state) => clsx( menu2SubmenuTriggerClasses.root, + // Base UI highlights the trigger for the keyboard and the pointer. + // Without this class the trigger shows only the weaker CSS `:hover`. + state.highlighted && menu2SubmenuTriggerClasses.highlighted, + state.disabled && menu2SubmenuTriggerClasses.disabled, state.open && menu2SubmenuTriggerClasses.open, resolvedTriggerProps?.className, ) From da59b434f90762fe77a219af736be2435788f26e Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 14 Aug 2026 17:08:49 +0300 Subject: [PATCH 61/62] Give a hovered submenu trigger the full highlight Base UI sets the highlighted state on a submenu trigger only when the submenu opens. Before that the trigger fell back to the item `:hover` tint at 0.04, while a plain item showed the highlight at 0.12. The list now styles `.MuiMenu2SubmenuTrigger-root:hover` too. The selector is (0,3,0) and outranks the item's own `:hover` at (0,2,0), so the result does not depend on style insertion order. Co-Authored-By: Claude Opus 5 --- .../src/Unstable_Menu2/menu2SharedStyles.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts index ad2906852ea316..7a22a0bbc407eb 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -75,9 +75,13 @@ export const menu2PopupListStyles = memoTheme(({ theme }) => ({ // A submenu trigger is whatever element the caller passes, so its open state // is styled from the list that contains it, not from a component we render. // The highlighted state matches a plain item, and an open trigger keeps it. - [`& .${menu2SubmenuTriggerClasses.highlighted}, & .${menu2SubmenuTriggerClasses.open}`]: { - backgroundColor: (theme.vars || theme).palette.action.focus, - }, + // `:hover` is here too: Base UI highlights a submenu trigger only once its + // submenu opens, so during the open delay the trigger would otherwise show + // the weaker hover tint while its neighbours show the full highlight. + [`& .${menu2SubmenuTriggerClasses.root}:hover, & .${menu2SubmenuTriggerClasses.highlighted}, & .${menu2SubmenuTriggerClasses.open}`]: + { + backgroundColor: (theme.vars || theme).palette.action.focus, + }, [`& .${menu2SubmenuTriggerClasses.disabled}`]: { opacity: (theme.vars || theme).palette.action.disabledOpacity, }, From b25641481fa1ab191526428363ee187091bf7260 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 14 Aug 2026 18:08:48 +0300 Subject: [PATCH 62/62] Activate a Menu2 item once per key press The item root is styled(ButtonBase) rendered as a div, so Base UI and ButtonBase both treated it as a non-native button. Base UI activated the item on keydown, then ButtonBase synthesized a DOM click on keyup. One Space press activated the item twice. A checkbox item checked and unchecked, and Enter behaved the same way on every item. ButtonBase takes a new internal `suppressKeyboardActivation` prop, in the style of `suppressFocusVisible`. Every Menu2 item sets it, so Base UI stays the single owner of Enter and Space. `getMenu2RootRender` now removes the ButtonBase-only props for a custom root slot. `disableRipple` already leaked there and made React warn. The item root became styled(ButtonBase) in the ripple commit, which is where the double activation started. Co-Authored-By: Claude Opus 5 --- .../mui-material/src/ButtonBase/ButtonBase.js | 4 +++ .../src/ButtonBase/useButtonBase.ts | 15 +++++++- .../src/Unstable_Menu2/menu2Utils.ts | 24 +++++++++---- .../Menu2CheckboxItem.test.tsx | 34 ++++++++++++++++++- .../Menu2CheckboxItem.tsx | 21 ++++++++---- .../src/Unstable_Menu2Item/Menu2Item.test.tsx | 29 +++++++++++++++- .../src/Unstable_Menu2Item/Menu2Item.tsx | 21 ++++++++---- .../Unstable_Menu2LinkItem/Menu2LinkItem.tsx | 21 ++++++++---- .../Menu2RadioItem.test.tsx | 31 ++++++++++++++++- .../Menu2RadioItem.tsx | 21 ++++++++---- 10 files changed, 182 insertions(+), 39 deletions(-) diff --git a/packages/mui-material/src/ButtonBase/ButtonBase.js b/packages/mui-material/src/ButtonBase/ButtonBase.js index 7f43ece81d5cc4..5a5f13dc0e454f 100644 --- a/packages/mui-material/src/ButtonBase/ButtonBase.js +++ b/packages/mui-material/src/ButtonBase/ButtonBase.js @@ -93,6 +93,9 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { // escape hatch to suppress the focusVisible state and callback // used by anchored s to to suppress focus visible styling when opened with a pointer suppressFocusVisible = false, + // escape hatch for roots that already get Enter and Space activation from an outer + // layer, for example a Base UI part that renders into ButtonBase + suppressKeyboardActivation = false, // private prop to allow native vs non-native button props to be resolved before mount internalNativeButton: internalNativeButtonProp, /* eslint-enable react/prop-types */ @@ -168,6 +171,7 @@ const ButtonBase = React.forwardRef(function ButtonBase(inProps, ref) { type, hasFormAction, tabIndex, + suppressKeyboardActivation, onBeforeKeyDown: handleBeforeKeyDown, onBeforeKeyUp: handleBeforeKeyUp, }); diff --git a/packages/mui-material/src/ButtonBase/useButtonBase.ts b/packages/mui-material/src/ButtonBase/useButtonBase.ts index 6f4f26713e5096..04538322903c4e 100644 --- a/packages/mui-material/src/ButtonBase/useButtonBase.ts +++ b/packages/mui-material/src/ButtonBase/useButtonBase.ts @@ -55,6 +55,12 @@ export interface UseButtonBaseParameters { * @default false */ stopEventPropagation?: boolean | undefined; + /** + * When `true`, the hook does not synthesize Enter and Space clicks on non-native roots. + * Set it when an outer layer already activates the element from the keyboard. + * @default false + */ + suppressKeyboardActivation?: boolean | undefined; /** * An additional function that will run before the user's `onKeyDown`, e.g. * to trigger the ripple effect in ``. @@ -116,6 +122,7 @@ export default function useButtonBase( tabIndex = 0, focusableWhenDisabled: focusableWhenDisabledParam, stopEventPropagation = false, + suppressKeyboardActivation = false, onBeforeKeyDown, onBeforeKeyUp, } = parameters; @@ -278,7 +285,11 @@ export default function useButtonBase( onBeforeKeyDown?.(event); externalOnKeyDown?.(event); - if (event.target !== event.currentTarget || hasNativeKeyboardActivation()) { + if ( + suppressKeyboardActivation || + event.target !== event.currentTarget || + hasNativeKeyboardActivation() + ) { return; } @@ -302,6 +313,7 @@ export default function useButtonBase( externalOnKeyUp?.(event); if ( + !suppressKeyboardActivation && event.target === event.currentTarget && !hasNativeKeyboardActivation() && event.key === ' ' && @@ -328,6 +340,7 @@ export default function useButtonBase( onBeforeKeyDown, onBeforeKeyUp, stopEventPropagation, + suppressKeyboardActivation, ], ); diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts index abd2bcaa7cbfef..837262bc408209 100644 --- a/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts +++ b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts @@ -34,18 +34,28 @@ export function getMenu2RootRender( RootSlot: React.ElementType, ownerState: OwnerState, props?: Record, + defaultRootSlot?: React.ElementType, ) { + const rootProps = { ...(props ?? {}) }; + + // These props are internal to `ButtonBase`. Only the default root reaches it, + // so a custom slot would spread them onto the DOM and React would warn. + if (defaultRootSlot != null && RootSlot !== defaultRootSlot) { + delete rootProps.disableRipple; + delete rootProps.suppressKeyboardActivation; + } + if (isHostComponent(RootSlot)) { - const hostProps = { ...(props ?? {}) }; - delete hostProps.as; - delete hostProps.component; - delete hostProps.ownerState; - delete hostProps.sx; + delete rootProps.as; + delete rootProps.component; + delete rootProps.ownerState; + delete rootProps.suppressKeyboardActivation; + delete rootProps.sx; - return React.createElement(RootSlot, hostProps); + return React.createElement(RootSlot, rootProps); } - return React.createElement(RootSlot, appendOwnerState(RootSlot, props ?? {}, ownerState)); + return React.createElement(RootSlot, appendOwnerState(RootSlot, rootProps, ownerState)); } export function isMenu2RootNativeButton( diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx index c4a1b0afbe3d90..f8413d79448102 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; -import { createRenderer } from '@mui/internal-test-utils'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { act, createRenderer, screen } from '@mui/internal-test-utils'; import Menu2 from '@mui/material/Unstable_Menu2'; import Menu2CheckboxItem, { menu2CheckboxItemClasses as classes, @@ -26,4 +28,34 @@ describe('', () => { muiName: 'MuiMenu2CheckboxItem', testVariantProps: { checked: true }, })); + + // The item root is a ButtonBase. Base UI and ButtonBase both emulate keyboard + // activation on a non-native root, which used to toggle the item twice. + it('toggles once per activation, with the keyboard and with the pointer', async () => { + const onChange = spy(); + const { user } = render( + + + Ruler + + , + ); + + const item = screen.getByRole('menuitemcheckbox', { name: 'Ruler' }); + await act(async () => { + item.focus(); + }); + + await user.keyboard('[Space]'); + expect(onChange.callCount).to.equal(1); + expect(item).to.have.attribute('aria-checked', 'true'); + + await user.keyboard('[Enter]'); + expect(onChange.callCount).to.equal(2); + expect(item).to.have.attribute('aria-checked', 'false'); + + await user.click(item); + expect(onChange.callCount).to.equal(3); + expect(item).to.have.attribute('aria-checked', 'true'); + }); }); diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx index fbb4d118ea49dc..4769d092f50dd4 100644 --- a/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -201,14 +201,21 @@ const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( by default; the items keep their element. - component: component ?? 'div', - disableRipple, + render={getMenu2RootRender( + RootSlot, ownerState, - sx, - })} + { + ...resolveComponentProps(slotProps?.root, ownerState), + // ButtonBase renders a