diff --git a/docs/pages/experiments/menu2-playground.tsx b/docs/pages/experiments/menu2-playground.tsx new file mode 100644 index 00000000000000..6b1216b67d3816 --- /dev/null +++ b/docs/pages/experiments/menu2-playground.tsx @@ -0,0 +1,661 @@ +import * as React from 'react'; +import NextLink from 'next/link'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import ClassicMenu from '@mui/material/Menu'; +import ClassicMenuItem from '@mui/material/MenuItem'; +import Container from '@mui/material/Container'; +import CssBaseline from '@mui/material/CssBaseline'; +import Stack from '@mui/material/Stack'; +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, + 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. +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem from '@mui/material/Unstable_Menu2CheckboxItem'; +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 Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +type MenuProps = React.ComponentProps; +type PopupProps = MenuProps; +type PopupSide = NonNullable; +type PopupAlign = NonNullable; + +interface PlaygroundSettings { + // Root behavior + modal: boolean; + triggerOpenOnHover: boolean; + loopFocus: boolean; + highlightItemOnHover: boolean; + // Submenu behavior + submenusOpenOnHover: boolean; + submenuDelay: number; + submenuCloseDelay: number; + closeParentOnEsc: boolean; + // Positioning + side: PopupSide; + align: PopupAlign; + sideOffset: number; + alignOffset: number; + keepMounted: boolean; + // Appearance + elevation: number; + backdrop: 'none' | 'dimmed'; + animation: 'default' | 'off'; + dense: boolean; + dividers: boolean; + rtl: boolean; + highContrast: boolean; +} + +const defaultSettings: PlaygroundSettings = { + modal: true, + triggerOpenOnHover: false, + loopFocus: true, + highlightItemOnHover: true, + submenusOpenOnHover: true, + submenuDelay: 100, + submenuCloseDelay: 0, + closeParentOnEsc: false, + side: 'bottom', + align: 'start', + sideOffset: 8, + alignOffset: 0, + keepMounted: false, + elevation: 8, + backdrop: 'none', + animation: 'default', + dense: false, + dividers: false, + rtl: false, + highContrast: true, +}; + +const SIDES: PopupSide[] = ['bottom', 'top', 'left', 'right', 'inline-start', 'inline-end']; +const ALIGNS: PopupAlign[] = ['start', 'center', 'end']; +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. +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.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.backdrop], + ); +} + +function PlaygroundDemo({ + settings, + onLog, +}: { + settings: PlaygroundSettings; + onLog: (entry: string) => void; +}) { + 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 = { + openOnHover: settings.submenusOpenOnHover, + delay: settings.submenuDelay, + closeDelay: settings.submenuCloseDelay, + }; + // 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})`); + }; + + const handleOpenChangeComplete: MenuProps['onOpenChangeComplete'] = (nextOpen) => { + onLog(`onOpenChangeComplete -> ${nextOpen ? 'opened' : 'closed'}`); + }; + + const handleItemClick = (event: React.MouseEvent) => { + onLog(`item click: ${event.currentTarget.textContent}`); + }; + + return ( + 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} + > + + Actions + + New file + + + Duplicate + + + Archive (disabled) + + + + + + Share + + + } + slotProps={{ trigger: submenuTriggerProps }} + {...submenuPopupProps} + > + + Email + + + Copy link + + + Export as + + + } + slotProps={{ trigger: submenuTriggerProps }} + {...submenuPopupProps} + > + + + PDF document + + + EPUB publication + + + Markdown + + + + + + + View + + + } + slotProps={{ trigger: submenuTriggerProps }} + {...submenuPopupProps} + > + + Show ruler + + Show outline + + + + + Selected item (visual-only) + + + Menu documentation + + + ); +} + +const parityItems = [ + { label: 'Profile' }, + { label: 'My account', selected: true }, + { label: 'Settings' }, + { label: 'Read-only mode', disabled: true }, + { label: 'Logout' }, +] as const; + +function ClassicVersusSuccessorDemo({ settings }: { settings: PlaygroundSettings }) { + const [classicAnchorEl, setClassicAnchorEl] = React.useState(null); + const popupKnobProps = usePopupKnobProps(settings); + const itemProps = { dense: settings.dense, divider: settings.dividers }; + + return ( + +
+ + setClassicAnchorEl(null)} + elevation={settings.elevation} + > + {parityItems.map((item) => ( + setClassicAnchorEl(null)} + > + {item.label} + + ))} + +
+ Successor} + slotProps={{ + trigger: { + variant: 'outlined', + endIcon: , + }, + }} + {...popupKnobProps} + > + {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} + slotProps={{ + trigger: { + variant: 'outlined', + endIcon: , + }, + paper: { sx: { maxHeight: 320, overflow: 'auto' } }, + }} + sideOffset={4} + > + {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('triggerOpenOnHover', 'openOnHover (trigger)')} + {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 + + + + {renderCheckbox('dense', 'dense items')} + {renderCheckbox('dividers', 'item dividers')} + {renderCheckbox('rtl', 'RTL direction')} + {renderCheckbox('highContrast', 'enhanceHighContrast theme')} +
+
+ ); +} + +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 = (() => { + if (settings.rtl) { + return settings.highContrast ? rtlHighContrastTheme : rtlTheme; + } + return settings.highContrast ? highContrastTheme : theme; + })(); + + return ( + + + + + + + Menu2 playground + + + 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. + + + + +
+

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. +

+

+ 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. +

+ + + + + + + + + {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). Both + expose a top-level elevation prop; the successor forwards it to the Paper + slot. +

+ +
+ +
+

Classic-style controlled usage

+

+ No Menu2Trigger 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 +
+
+
+ ); +} diff --git a/docs/pages/experiments/menu2-recipes.tsx b/docs/pages/experiments/menu2-recipes.tsx new file mode 100644 index 00000000000000..5e8afc6c049f98 --- /dev/null +++ b/docs/pages/experiments/menu2-recipes.tsx @@ -0,0 +1,690 @@ +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'; +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, useTheme } from '@mui/material/styles'; +// 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 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 Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2Submenu from '@mui/material/Unstable_Menu2Submenu'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +interface MenuSettings { + modal: boolean; + disabled: boolean; + submenusOpenOnHover: boolean; +} + +const theme = createTheme({}); + +const defaultSettings: MenuSettings = { + modal: true, + disabled: false, + 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; + 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: { + popper: { + popperOptions: { + modifiers: [ + { + name: 'flip', + options: { + fallbackPlacements: ['left', 'right'], + }, + }, + ], + }, + }, + }, +} satisfies Partial; + +interface MenuTooltipChildProps { + onClickCapture?: React.MouseEventHandler; +} + +function MenuTooltip(props: { + title: string; + children: React.ReactElement; + tooltipProps?: Partial; +}) { + const { title, children, tooltipProps = horizontalTooltipProps } = 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 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; + + 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 Menu2WithPreviewCardsDemo({ 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); + } + }} + trigger={} + slotProps={{ + trigger: { + variant: 'contained', + endIcon: , + }, + }} + sideOffset={8} + > + + {rootPreviewCardItems[0].label} + + + Version history + + + } + slotProps={{ + trigger: { + openOnHover: submenusOpenOnHover, + }, + }} + > + {versionHistoryPreviewCardItems.map((item) => ( + + {item.label} + + ))} + + + {rootPreviewCardItems[1].label} + + + + ); +} + +function Menu2Demo({ settings }: { settings: MenuSettings }) { + const handleItemClick = React.useCallback((event: React.MouseEvent) => { + // eslint-disable-next-line no-console + console.log(`${event.currentTarget.textContent} clicked`); + }, []); + + return ( + File} + slotProps={{ + trigger: { + variant: 'contained', + endIcon: , + }, + }} + 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 } }} + > + + Document display + + 100% + Fit + Page width + + Custom zoom unavailable + + + + + + + + Show + Ruler + Document outline + Line numbers + Page breaks unavailable + + + + + + More tools + + + } + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} + > + Word count + Dictionary + Accessibility settings + + + + + Download + + + } + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} + > + Microsoft Word (.docx) + PDF document (.pdf) + Plain text (.txt) + + + + Add-ons unavailable + + + } + slotProps={{ trigger: { openOnHover: settings.submenusOpenOnHover } }} + > + Marketplace + + + ); +} + +function Menu2WithTooltipsDemo({ submenusOpenOnHover }: { submenusOpenOnHover: boolean }) { + const { direction } = useTheme(); + const submenuTriggerTooltipProps = React.useMemo>( + () => ({ + 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 ( + Tools} + slotProps={{ + trigger: { + variant: 'contained', + endIcon: , + }, + }} + sideOffset={8} + > + + New document + + + Open recent + + + Make a copy + + + Import from Drive + + + Share with people + + + + + + View options + + + + } + slotProps={{ trigger: { openOnHover: submenusOpenOnHover } }} + > + + Show + + Comments + + + Page breaks + + + + + + + Zoom + + + Fit + + + + Custom + + + + + + + ); +} + +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(); + + 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 ( + // 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 + 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 Menu2Experiment() { + const [settings, setSettings] = React.useState(defaultSettings); + + const handleCheckboxChange = (setting: keyof MenuSettings) => { + return (event: React.ChangeEvent) => { + setSettings((currentSettings) => ({ + ...currentSettings, + [setting]: event.target.checked, + })); + }; + }; + + return ( + + + + + + + Menu2 recipes + + + Integration recipes for Menu2. See also the{' '} + Menu2 RFC draft and the{' '} + Menu2 playground. + +
+ Demo controls + + + +
+
+ +

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.

+ +
+ Base UI Menu API +
+
+
+ ); +} 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/docs/pages/experiments/menu2-rfc.md b/docs/pages/experiments/menu2-rfc.md new file mode 100644 index 00000000000000..48a0057fe1e059 --- /dev/null +++ b/docs/pages/experiments/menu2-rfc.md @@ -0,0 +1,453 @@ +--- +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 + +

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` + +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. + +- **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. + +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 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 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? + +### Option A: Add submenus to the existing Menu + +We tried three times in eight years. The same problems blocked each attempt: + +| 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. **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 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 + +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 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) + +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 + +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. 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. 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 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`. + +```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. + +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: + + | 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]`. 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. The generator could not read types from `node_modules`, so the runtime PropTypes covered only the props that we declare locally. The infra now supports the extraction of inherited props ([mui-public#1709](https://github.com/mui/mui-public/pull/1709)). We get this capability when we sync with master, and then the PropTypes cover the inherited props too. +- **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. + +- **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 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 + +| 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 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 | + +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 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. + +- **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, 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. + +```jsx +Options} slotProps={{ paper: { elevation: 4 } }}> + Cut + Share}> + Email + Copy link + + +``` + +The popup part still exists, but it is internal. We export only its class hooks. Thus `styleOverrides` and `sx` do not change. + +Four results come from this work: + +- **`trigger` takes an element at both levels.** Base{NB}UI's `render` merges the trigger behavior into that element, so the caller keeps their own component. A string is not valid. The two levels match, and a `Tooltip` wraps the trigger at either level: + + ```jsx + View + }> + ``` + +- **A wrapper must forward the props and the ref to its child.** The `Tooltip` of Material{NB}UI does this. A wrapper that you write must do the same, or the trigger behavior does not reach the element. +- **A submenu trigger must not close the menu.** The caller usually passes a `Menu2Item`, which closes the menu on click. The submenu sets `closeOnClick` to false for the element that it renders. +- **We no longer infer `nativeButton`.** The caller declares it through `slotProps.trigger` when the element is not a native button. + +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: + +- **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. +- **Open state.** The list that contains a submenu trigger styles its open state, because the trigger is the caller's element. A test pins the order: a selected trigger keeps its selected blend when its submenu opens. +- **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 + +- **Unchanged:** item props (`dense`, `disableGutters`, `divider`, `selected`, `disabled`), visuals, theming, `keepMounted`, `container`. +- **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`: 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 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. The component connects the labels with `aria-labelledby`. +- A trigger that sets `aria-haspopup`, `aria-expanded`, and `aria-controls` for you. +- 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, 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: + +- **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: + +- **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. + +### 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. | +| Ripple | Release it by default | The item root becomes a styled `ButtonBase`, so the items get a ripple like the other Material UI components. `disableRipple` comes back and turns the ripple off. A `ButtonBase` inside an item stays impossible: it puts a focusable element inside a `menuitem`, and that highlights the item when the menu opens ([base-ui#2622](https://github.com/mui/base-ui/issues/2622)). Base UI does not permit that pattern. | +| `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`, `backdrop`, `paper`, and `list`. `MuiMenu2Submenu` has `root`, `paper`, and `list`. Neither has a `trigger` slot, because the caller supplies the trigger element and themes that component. 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. **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? +2. **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. + +3. **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. +4. **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? +5. **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. 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 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` | 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` | 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 1. | +| `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 | 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 (see the decisions above). | +| `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 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 (see the decisions above). | +| `PopoverClasses` | n/a | The new component does not use a Popover. | + +
+ +
+6. Item props + +| 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 the decisions above. | +| `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) 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: + +- [#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) + +Earlier attempts: + +- [#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): [#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) + +Community workarounds: + +- [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) diff --git a/packages-internal/core-docs/package.json b/packages-internal/core-docs/package.json index e618d6e6dcba1d..c41a4ecb386b26 100644 --- a/packages-internal/core-docs/package.json +++ b/packages-internal/core-docs/package.json @@ -53,7 +53,7 @@ "next": "16.2.12" }, "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 6d84f00aaf15f3..7b42c84539888e 100644 --- a/packages/mui-material/package.json +++ b/packages/mui-material/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@babel/runtime": "^7.29.7", + "@base-ui/react": "^1.6.0", "@mui/core-downloads-tracker": "workspace:^", "@mui/system": "workspace:^", "@mui/types": "workspace:^", 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 1fc1ecbba1a95b..f9eea903b6abd4 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/Unstable_Menu2/Menu2.spec.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx new file mode 100644 index 00000000000000..10b8e965e4e4f6 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -0,0 +1,188 @@ +import * as React from 'react'; +import { expectType } from '@mui/types'; +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 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 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'; + +function Menu2Composition() { + return ( + { + expectType(open); + eventDetails.cancel(); + eventDetails.preventUnmountOnClose(); + }} + trigger={} + anchor={null} + side="bottom" + align="start" + sideOffset={4} + collisionPadding={8} + keepMounted + finalFocus + slots={{ + portal: 'div', + positioner: 'div', + popup: 'div', + paper: 'div', + list: 'div', + }} + slotProps={{ + trigger: { openOnHover: true, delay: 100 }, + paper: { elevation: 4 }, + list: { 'data-testid': 'list' }, + }} + > + + 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 + + + + ); +} + +createTheme({ + components: { + MuiMenu2: { + defaultProps: { + modal: false, + align: 'start', + }, + // 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: {}, + backdrop: {}, + paper: {}, + list: {}, + }, + variants: [ + { + props: { align: 'start' }, + style: {}, + }, + ], + }, + MuiMenu2Submenu: { + defaultProps: { + defaultOpen: false, + }, + styleOverrides: { + root: {}, + paper: {}, + list: {}, + }, + }, + MuiMenu2Item: { + defaultProps: { + dense: true, + }, + styleOverrides: { + root: {}, + highlighted: {}, + }, + variants: [ + { + props: { selected: true }, + style: {}, + }, + ], + }, + + MuiMenu2RadioItem: { + variants: [ + { + props: { value: 'small' }, + style: {}, + }, + ], + }, + MuiMenu2LinkItem: { + variants: [ + { + props: { href: '/profile' }, + style: {}, + }, + ], + }, + }, +}); + +; + +; + +} +/>; + +, + }, + }} +/>; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx new file mode 100644 index 00000000000000..946613526fccc4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -0,0 +1,1077 @@ +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'; +import { paperClasses } from '@mui/material/Paper'; +import Tooltip from '@mui/material/Tooltip'; +import Menu2, { menu2PopupClasses, menu2TriggerClasses } from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem, { + menu2CheckboxItemClasses, +} from '@mui/material/Unstable_Menu2CheckboxItem'; +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 Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2Submenu, { menu2SubmenuTriggerClasses } from '@mui/material/Unstable_Menu2Submenu'; +import { createTheme, enhanceHighContrast, ThemeProvider } 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(menu2TriggerClasses.root); + + await user.click(trigger); + + const menu = await screen.findByRole('menu'); + expect(menu).to.have.class(menu2PopupClasses.root); + expect(screen.getByTestId('paper')).to.have.class(menu2PopupClasses.paper); + + 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(menu2ItemClasses.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( + 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 + + } + slotProps={{ trigger: { nativeButton: false } }} + > + + Native item + + + Native checkbox + + + + Native radio + + + Native submenu trigger} + slotProps={{ trigger: { nativeButton: true } }} + > + 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 } + > + Profile + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const paper = await screen.findByTestId('paper'); + expect(paper).not.to.have.attribute('classes'); + expect(paper).not.to.have.attribute('component'); + expect(paper).not.to.have.attribute('elevation'); + expect(paper).not.to.have.attribute('sx'); + + const list = screen.getByTestId('list'); + expect(list).not.to.have.attribute('classes'); + expect(list).not.to.have.attribute('component'); + expect(list).not.to.have.attribute('disablePadding'); + expect(list).not.to.have.attribute('disablepadding'); + 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.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())( + '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. + // 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); + } catch { + return []; + } + }) + .filter((rule) => (rule as CSSMediaRule).conditionText?.includes('forced-colors')) + .flatMap((rule) => Array.from((rule as CSSMediaRule).cssRules ?? [])) + .map((rule) => rule.cssText) + .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); + }, + ); + + 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( + 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); + expect(eventDetails.reason).to.equal('trigger-press'); + eventDetails.cancel(); + expect(eventDetails.isCanceled).to.equal(true); + }); + + const { user } = render( + Options}> + Profile + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + await waitFor(() => { + expect(handleOpenChange.callCount).to.equal(1); + }); + expect(screen.queryByRole('menu')).to.equal(null); + }); + + it('does not open when the root is disabled', async () => { + render( + Options}> + Profile + , + ); + + expect(screen.getByRole('button', { name: 'Options' })).to.have.attribute('disabled'); + expect(screen.queryByRole('menu')).to.equal(null); + }); + + it('supports defaultOpen', () => { + render( + Options}> + Profile + , + ); + + expect(screen.getByRole('menu')).not.to.equal(null); + }); + + it('supports keepMounted', () => { + render( + Options}> + Profile + , + ); + + expect(screen.getByText('Profile')).not.to.equal(null); + expect(screen.getByRole('button', { name: 'Options' })).to.have.attribute( + 'aria-expanded', + 'false', + ); + }); + + it('supports finalFocus', async () => { + const finalFocusRef = React.createRef(); + const { user } = render( + + + 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(menu2CheckboxItemClasses.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.skipIf(isJsdom())('gives the items a ripple, and disableRipple turns it off', async () => { + const { user } = render( + Options}> + Profile + + No ripple + + , + ); + + const withRipple = await screen.findByRole('menuitem', { name: 'Profile' }); + const withoutRipple = screen.getByRole('menuitem', { name: 'No ripple' }); + + // The items keep their element; ButtonBase renders a }> + + Show hidden files + + + + Small + + + Large + + + , + ); + + const checkboxIndicator = screen.getByTestId('checkbox-indicator'); + 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); + 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-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'); + 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-menu2-indicator-icon]', + ); + const uncheckedRadioMark = uncheckedRadioIndicator.querySelector( + '[data-mui-menu2-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.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); + } + }} + anchor={anchor ?? undefined} + positionMethod="fixed" + finalFocus={areaRef} + > + 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())('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( + 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( + 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; + } + + // 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); + }, + }); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + > + {child} + + ); + }); + + const { user } = render( + Options}> + + View options + + } + slotProps={{ trigger: { openOnHover: false } }} + > + 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/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx new file mode 100644 index 00000000000000..cf3b5045e58ead --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -0,0 +1,205 @@ +'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 './Menu2Popup'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menu2TriggerClasses } from './menu2Classes'; +import { SlotProps } from './menu2Utils'; + +export interface Menu2Slots extends NonNullable {} + +export interface Menu2SlotProps extends NonNullable { + trigger?: SlotProps, Menu2Props> | undefined; +} + +/** + * 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, + Omit { + /** + * The menu items. + */ + children?: React.ReactNode; + /** + * The element that opens the menu, for example a `Button`. + * + * 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.ReactElement | undefined; + /** + * The components used for each slot inside. + */ + slots?: Menu2Slots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2SlotProps | undefined; +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +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', + }); + + 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 popupSlots = slots; + 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 + // caller keeps whatever component they passed. + + clsx( + menu2TriggerClasses.root, + state.open && menu2TriggerClasses.open, + resolvedTriggerProps?.className, + ) + } + /> + ); + + return ( + + {triggerNode} + + {children} + + + ); +}); + +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`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The menu items. + */ + children: PropTypes.node, + /** + * 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]), + 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.shape({ + backdrop: PropTypes.elementType, + list: PropTypes.elementType, + paper: PropTypes.elementType, + popup: PropTypes.elementType, + portal: PropTypes.elementType, + positioner: PropTypes.elementType, + }), + /** + * The element that opens the menu, for example a `Button`. + * + * 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.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 new file mode 100644 index 00000000000000..fb27c19513a01d --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Benchmark.test.tsx @@ -0,0 +1,324 @@ +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 Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; + +/** + * 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)); + +// The successor animates its surface by default, so geometry has to be read +// 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', () => { + 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 highlights an item as soon as it opens', async () => { + const { user } = render(); + await user.click(openTrigger()); + await waitForOpen(); + // `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 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 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(); + }); + }); + + describe('current-value menus', () => { + it('classic highlights the selected item, which is what variant="selectedMenu" buys', async () => { + const { user } = render(); + await user.click(openTrigger()); + await waitForOpen(); + expect(screen.getByRole('menuitem', { name: 'Gamma' })).toHaveFocus(); + }); + + it('the successor highlights the first item, not the checked one', async () => { + const { user } = render( + Options}> + + 100% + 200% + + , + ); + openTrigger().focus(); + await user.keyboard('{ArrowDown}'); + await waitForOpen(); + + // Radio items are the accessible way to express "current value", but Base UI + // still starts navigation at the first item: there is no public API to open + // with the checked item highlighted. This is the capability that + // `variant="selectedMenu"` provided and that the successor cannot reproduce. + await waitFor(() => + expect(screen.getByRole('menuitemradio', { name: '100%' })).toHaveFocus(), + ); + expect(screen.getByRole('menuitemradio', { name: '200%' })).to.have.attribute( + 'aria-checked', + 'true', + ); + }); + }); + + 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('both close on Tab, but classic keeps focus on the trigger', async () => { + const { user: classicUser, unmount: unmountClassic } = render(); + const classicTrigger = openTrigger(); + await classicUser.click(classicTrigger); + await waitForOpen(); + await classicUser.tab(); + // 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( + + + + , + ); + 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(); + }); + }); + + 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('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 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)); + unmountClassic(); + + const { user: successorUser } = render( +
+ +
, + ); + const successorAnchor = openTrigger().getBoundingClientRect(); + await successorUser.click(openTrigger()); + 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/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx new file mode 100644 index 00000000000000..5859450164ecb3 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2Popup.tsx @@ -0,0 +1,434 @@ +'use client'; +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'; +import List from '../List'; +import { styled } from '../zero-styled'; +import { Theme } from '../styles'; +import { + Menu2PopupBase, + Menu2PopupPublicProps, + Menu2PopupSharedProps, + Menu2PopupSharedSlotProps, +} from './menu2PopupShared'; +import { + menu2PopupListStyles, + menu2PopupPaperStyles, + menu2PopupTransitionStyles, +} from './menu2SharedStyles'; +import { getMenu2PopupUtilityClass, Menu2PopupClasses } from './menu2Classes'; + +export interface Menu2PopupProps extends Omit< + Menu2PopupSharedProps, + 'classes' | 'defaultPositionerProps' | 'defaultSlots' | 'ownerState' | keyof Menu2PopupPublicProps +> { + /** + * The menu items. + */ + children?: React.ReactNode; + /** + * CSS class applied to the Base UI popup element. + */ + className?: Menu2PopupPublicProps['className'] | undefined; + /** + * Styles applied to the Base UI popup element. + */ + style?: Menu2PopupPublicProps['style'] | undefined; + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the trigger. + */ + anchor?: Menu2PopupPublicProps['anchor'] | undefined; + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod?: Menu2PopupPublicProps['positionMethod'] | undefined; + /** + * Which side of the anchor element to align the popup against. + * @default 'bottom' + */ + side?: Menu2PopupPublicProps['side'] | undefined; + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset?: Menu2PopupPublicProps['sideOffset'] | undefined; + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align?: Menu2PopupPublicProps['align'] | undefined; + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset?: Menu2PopupPublicProps['alignOffset'] | undefined; + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary?: Menu2PopupPublicProps['collisionBoundary'] | undefined; + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding?: Menu2PopupPublicProps['collisionPadding'] | undefined; + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding?: Menu2PopupPublicProps['arrowPadding'] | undefined; + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky?: Menu2PopupPublicProps['sticky'] | undefined; + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking?: Menu2PopupPublicProps['disableAnchorTracking'] | undefined; + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance?: Menu2PopupPublicProps['collisionAvoidance'] | undefined; + /** + * The container element to portal the popup into. + */ + container?: Menu2PopupPublicProps['container'] | undefined; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: Menu2PopupPublicProps['keepMounted'] | undefined; + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus?: Menu2PopupPublicProps['finalFocus'] | undefined; + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation?: Menu2PopupPublicProps['elevation'] | undefined; + /** + * 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?: Menu2PopupSlotProps | undefined; + /** + * The components used for each slot inside. + */ + slots?: Menu2PopupSlots | undefined; +} + +export interface Menu2PopupOwnerState extends Menu2PopupProps {} + +export interface Menu2PopupSlots { + /** + * The component used for the portal. + * @default BaseMenu.Portal + */ + portal?: React.ElementType | undefined; + /** + * The component used for the positioner. + * @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' + */ + popup?: React.ElementType | undefined; + /** + * The component used for the Material surface. + * @default Paper + */ + paper?: React.ElementType | undefined; + /** + * The component used for the presentational list wrapper. + * @default List + */ + list?: React.ElementType | undefined; +} + +export interface Menu2PopupSlotProps extends Menu2PopupSharedSlotProps {} + +const useUtilityClasses = (ownerState: Menu2PopupOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + backdrop: ['backdrop'], + paper: ['paper'], + list: ['list'], + }; + + return composeClasses(slots, getMenu2PopupUtilityClass, classes); +}; + +const Menu2PopupRoot = styled('div', { + name: 'MuiMenu2', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ outline: 0 }, menu2PopupTransitionStyles); + +const Menu2PopupBackdrop = styled(BaseMenu.Backdrop, { + name: 'MuiMenu2', + slot: 'Backdrop', + overridesResolver: (props, styles) => styles.backdrop, +})({ + 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: 'MuiMenu2', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})(menu2PopupPaperStyles); + +const Menu2PopupList = styled(List, { + name: 'MuiMenu2', + slot: 'List', + overridesResolver: (props, styles) => styles.list, +})(menu2PopupListStyles); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2Popup = React.forwardRef(function Menu2Popup( + inProps: Menu2PopupProps, + ref: React.ForwardedRef, +) { + // Internal: the collapsed component has already applied `MuiMenu2` defaults. + const props = inProps; + + const ownerState: Menu2PopupOwnerState = { + side: 'bottom', + align: 'start', + ...props, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +Menu2Popup.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, + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation: PropTypes.number, + /** + * 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({ + 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]), + }), + /** + * 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 Menu2Popup; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx new file mode 100644 index 00000000000000..7b3246294c8efb --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuPopup.tsx @@ -0,0 +1,415 @@ +'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 { + Menu2PopupBase, + Menu2PopupPublicProps, + Menu2PopupSharedProps, + Menu2PopupSharedSlotProps, +} from './menu2PopupShared'; +import { + menu2PopupListStyles, + menu2PopupPaperStyles, + menu2PopupTransitionStyles, +} from './menu2SharedStyles'; +import { getMenu2SubmenuPopupUtilityClass, Menu2SubmenuPopupClasses } from './menu2Classes'; + +export interface Menu2SubmenuPopupProps extends Omit< + Menu2PopupSharedProps, + 'classes' | 'defaultPositionerProps' | 'defaultSlots' | 'ownerState' | keyof Menu2PopupPublicProps +> { + /** + * The submenu items. + */ + children?: React.ReactNode; + /** + * CSS class applied to the Base UI popup element. + */ + className?: Menu2PopupPublicProps['className'] | undefined; + /** + * Styles applied to the Base UI popup element. + */ + style?: Menu2PopupPublicProps['style'] | undefined; + /** + * An element to position the popup against. + * + * By default, the popup is positioned against the submenu trigger. + */ + anchor?: Menu2PopupPublicProps['anchor'] | undefined; + /** + * Determines which CSS `position` property to use. + * @default 'absolute' + */ + positionMethod?: Menu2PopupPublicProps['positionMethod'] | undefined; + /** + * Which side of the anchor element to align the popup against. + * @default 'inline-end' + */ + side?: Menu2PopupPublicProps['side'] | undefined; + /** + * Distance between the anchor and the popup in pixels. + * @default 0 + */ + sideOffset?: Menu2PopupPublicProps['sideOffset'] | undefined; + /** + * How to align the popup relative to the specified side. + * @default 'start' + */ + align?: Menu2PopupPublicProps['align'] | undefined; + /** + * Additional offset along the alignment axis in pixels. + * @default 0 + */ + alignOffset?: Menu2PopupPublicProps['alignOffset'] | undefined; + /** + * An element or a rectangle that delimits the area that the popup is confined to. + * @default 'clipping-ancestors' + */ + collisionBoundary?: Menu2PopupPublicProps['collisionBoundary'] | undefined; + /** + * Additional space to maintain from the edge of the collision boundary. + * @default 5 + */ + collisionPadding?: Menu2PopupPublicProps['collisionPadding'] | undefined; + /** + * Minimum distance to maintain between the arrow and the edges of the popup. + * @default 5 + */ + arrowPadding?: Menu2PopupPublicProps['arrowPadding'] | undefined; + /** + * Whether to maintain the popup in the viewport after the anchor element was scrolled out of view. + * @default false + */ + sticky?: Menu2PopupPublicProps['sticky'] | undefined; + /** + * Whether to disable the popup from tracking layout shifts of its positioning anchor. + * @default false + */ + disableAnchorTracking?: Menu2PopupPublicProps['disableAnchorTracking'] | undefined; + /** + * Determines how to handle collisions when positioning the popup. + */ + collisionAvoidance?: Menu2PopupPublicProps['collisionAvoidance'] | undefined; + /** + * The container element to portal the popup into. + */ + container?: Menu2PopupPublicProps['container'] | undefined; + /** + * Whether to keep the portal mounted in the DOM while the popup is hidden. + * @default false + */ + keepMounted?: Menu2PopupPublicProps['keepMounted'] | undefined; + /** + * Determines the element to focus when the menu is closed. + */ + finalFocus?: Menu2PopupPublicProps['finalFocus'] | undefined; + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation?: Menu2PopupPublicProps['elevation'] | undefined; + /** + * 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?: Menu2SubmenuPopupSlotProps | undefined; + /** + * The components used for each slot inside. + */ + slots?: Menu2SubmenuPopupSlots | undefined; +} + +export interface Menu2SubmenuPopupOwnerState extends Menu2SubmenuPopupProps {} + +export interface Menu2SubmenuPopupSlots { + /** + * The component used for the portal. + * @default BaseMenu.Portal + */ + portal?: React.ElementType | undefined; + /** + * The component used for the positioner. + * @default BaseMenu.Positioner + */ + positioner?: React.ElementType | undefined; + /** + * The component rendered by the Base UI popup. + * @default 'div' + */ + popup?: React.ElementType | undefined; + /** + * The component used for the Material surface. + * @default Paper + */ + paper?: React.ElementType | undefined; + /** + * The component used for the presentational list wrapper. + * @default List + */ + list?: React.ElementType | undefined; +} + +export interface Menu2SubmenuPopupSlotProps extends Menu2PopupSharedSlotProps {} + +const useUtilityClasses = (ownerState: Menu2SubmenuPopupOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + paper: ['paper'], + list: ['list'], + }; + + return composeClasses(slots, getMenu2SubmenuPopupUtilityClass, classes); +}; + +const Menu2SubmenuPopupRoot = styled('div', { + name: 'MuiMenu2Submenu', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ outline: 0 }, menu2PopupTransitionStyles); + +const Menu2SubmenuPopupPaper = styled(Paper, { + name: 'MuiMenu2Submenu', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})(menu2PopupPaperStyles); + +const Menu2SubmenuPopupList = styled(List, { + name: 'MuiMenu2Submenu', + slot: 'List', + overridesResolver: (props, styles) => styles.list, +})(menu2PopupListStyles); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2SubmenuPopup = React.forwardRef(function Menu2SubmenuPopup( + inProps: Menu2SubmenuPopupProps, + ref: React.ForwardedRef, +) { + // Internal: `MuiMenu2Submenu` defaults are applied by Menu2Submenu. + const props = inProps; + + const ownerState: Menu2SubmenuPopupOwnerState = { + side: 'inline-end', + align: 'start', + ...props, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +Menu2SubmenuPopup.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, + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation: PropTypes.number, + /** + * 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({ + 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]), + }), + /** + * 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 Menu2SubmenuPopup; diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx new file mode 100644 index 00000000000000..6b3446905a967d --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2SubmenuRoot.tsx @@ -0,0 +1,40 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; + +/** + * Inherits the full Base UI `Menu.SubmenuRoot` prop surface (open/close + * 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 { + /** + * The content of the submenu. + */ + children?: React.ReactNode; +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +function Menu2SubmenuRoot(props: Menu2SubmenuRootProps): React.JSX.Element { + return ; +} + +Menu2SubmenuRoot.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, +} as any; + +export default Menu2SubmenuRoot; diff --git a/packages/mui-material/src/Unstable_Menu2/index.ts b/packages/mui-material/src/Unstable_Menu2/index.ts new file mode 100644 index 00000000000000..fea5dc2864a122 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/index.ts @@ -0,0 +1,16 @@ +export { default } 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/menu2Classes.ts b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts new file mode 100644 index 00000000000000..255f9dfc7079df --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts @@ -0,0 +1,286 @@ +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 backdrop element. */ + backdrop: 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', + 'backdrop', + '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'], +); + +/** + * 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' | 'backdrop' | 'paper' | 'list'; + +/** Theme `styleOverrides` slots for the collapsed `Menu2Submenu`. */ +export type Menu2SubmenuClassKey = 'root' | 'paper' | 'list'; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2ItemShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2ItemShared.tsx new file mode 100644 index 00000000000000..3192ace44d4d77 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2ItemShared.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 { + Menu2RootSlotProps, + Menu2RootSlots, + StateClassName, + mergeStateClassName, +} from './menu2Utils'; + +export interface Menu2ItemOwnerState { + checked?: boolean | undefined; + dense: boolean; + disabled: boolean; + divider: boolean; + disableGutters: boolean; + selected: boolean; +} + +export interface Menu2ItemVisualProps< + Classes, + Slots = Menu2RootSlots, + SlotProps = Menu2RootSlotProps, +> { + /** + * 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 Menu2ItemBaseProps { + /** + * 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 Menu2LinkItemBaseProps { + /** + * 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 Menu2SubmenuTriggerBaseProps { + /** + * 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 Menu2BaseItemState { + disabled?: boolean | undefined; + highlighted?: boolean | undefined; +} + +export function menu2ItemOverridesResolver( + props: { ownerState: Menu2ItemOwnerState }, + styles: Record, +) { + const { ownerState } = props; + + return [ + styles.root, + ownerState.dense && styles.dense, + ownerState.divider && styles.divider, + !ownerState.disableGutters && styles.gutters, + ] as CSSInterpolation; +} + +export function getMenu2ItemOwnerState( + props: Menu2ItemVisualProps & { + checked?: boolean | undefined; + disabled?: boolean | undefined; + }, +): Menu2ItemOwnerState { + 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 useMenu2ItemUtilityClasses( + ownerState: Menu2ItemOwnerState & { + classes?: Partial | undefined; + checked?: boolean | undefined; + open?: boolean | undefined; + }, + 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 getMenu2ItemClassName( + classes: Partial>, + ownerState: Menu2ItemOwnerState, + state: State, +) { + return clsx( + classes.root, + state.highlighted && classes.highlighted, + state.disabled && !ownerState.disabled && classes.disabled, + ); +} + +export function mergeMenu2ItemClassName( + className: StateClassName, + classes: Partial>, + ownerState: Menu2ItemOwnerState, +) { + return mergeStateClassName(className, (state) => + getMenu2ItemClassName(classes, ownerState, state), + ); +} diff --git a/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx new file mode 100644 index 00000000000000..4f72320df804cd --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -0,0 +1,345 @@ +'use client'; +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 { SlotProps } from './menu2Utils'; + +type ExternalSlotProps = Omit, 'className' | 'render' | 'style'> & { + className?: string | undefined; + render?: never | undefined; + 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 paperHostOmittedProps = [ + 'classes', + 'component', + 'elevation', + 'square', + 'sx', + 'variant', +] as const; +const listHostOmittedProps = [ + 'classes', + 'component', + 'dense', + 'disablePadding', + 'subheader', + 'sx', +] as const; + +export interface Menu2PopupSharedSlots { + /** + * The component used for the portal. + * @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 + */ + positioner?: React.ElementType | undefined; + /** + * The component rendered by the Base UI popup. + * @default 'div' + */ + popup?: React.ElementType | undefined; + /** + * The component used for the Material surface. + * @default Paper + */ + paper?: React.ElementType | undefined; + /** + * The component used for the presentational list wrapper. + * @default List + */ + list?: React.ElementType | undefined; +} + +export interface Menu2PopupSharedSlotProps { + portal?: SlotProps, OwnerState> | undefined; + backdrop?: SlotProps, OwnerState> | undefined; + positioner?: SlotProps, OwnerState> | undefined; + popup?: SlotProps, OwnerState> | undefined; + paper?: SlotProps, OwnerState> | undefined; + list?: SlotProps, OwnerState> | undefined; +} + +type Menu2PositionerProps = BaseMenu.Positioner.Props; +type Menu2PortalProps = BaseMenu.Portal.Props; + +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']; + +/** + * 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. + */ + 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; + /** + * Which side of the anchor element to align the popup against. + * @default 'bottom' + */ + side?: Menu2PopupSide | 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; + /** + * The elevation of the menu surface. + * @default 8 + */ + elevation?: number | undefined; +} + +export interface Menu2PopupSharedProps + extends + Omit, + Menu2PopupPublicProps { + classes?: Partial> | undefined; + ownerState: OwnerState; + slots?: Menu2PopupSharedSlots | undefined; + slotProps?: Menu2PopupSharedSlotProps | undefined; + defaultSlots: { + popup: React.ElementType; + paper: React.ElementType; + list: React.ElementType; + backdrop?: React.ElementType | undefined; + }; + defaultPositionerProps?: Partial | undefined; + sx?: SxProps | undefined; +} + +export const Menu2PopupBase = React.forwardRef(function Menu2PopupBase( + props: Menu2PopupSharedProps, + 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, + elevation, + style, + ...other + } = props; + + // The portal and positioner are context providers, not just elements: the + // positioner needs the portal's context and the popup needs the positioner's. + // Swapping either for a plain element breaks the tree, so the Base parts are + // always rendered and a slot only changes what they render, through `render`. + const PortalSlot = slots?.portal; + // Opt-in: rendering a backdrop unconditionally would hand non-modal menus a + // full-screen layer, and modal menus already get Base UI's inert backdrop. + const BackdropSlot = slots?.backdrop ?? (slotProps?.backdrop ? defaultSlots.backdrop : undefined); + const PositionerSlot = slots?.positioner; + const PopupSlot = slots?.popup ?? defaultSlots.popup; + const PaperSlot = slots?.paper ?? defaultSlots.paper; + const ListSlot = slots?.list ?? defaultSlots.list; + + const resolvedPortalProps = resolveComponentProps(slotProps?.portal, ownerState); + const resolvedBackdropProps = resolveComponentProps(slotProps?.backdrop, ownerState); + const resolvedPositionerProps = resolveComponentProps(slotProps?.positioner, ownerState); + const resolvedPopupProps = resolveComponentProps(slotProps?.popup, ownerState); + const resolvedPaperProps = resolveComponentProps(slotProps?.paper, ownerState); + const resolvedListProps = resolveComponentProps(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 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`. + const mergedPaperProps = useSlotProps({ + elementType: PaperSlot, + externalSlotProps: resolvedPaperProps, + ownerState, + additionalProps: { elevation: elevation ?? 8 }, + className: classes?.paper, + }); + const mergedListProps = useSlotProps({ + elementType: ListSlot, + externalSlotProps: resolvedListProps, + ownerState, + additionalProps: { component: 'div', disablePadding: false }, + className: classes?.list, + }); + + const paperSlotProps = getSlotProps( + PaperSlot, + { ...mergedPaperProps, sx: mergeSx(sx, resolvedPaperProps?.sx) }, + paperHostOmittedProps, + ); + const listSlotProps = getSlotProps(ListSlot, mergedListProps, listHostOmittedProps); + + return ( + + {BackdropSlot ? ( + + ) : null} + + + + {children} + + + + + ); +}) as ( + props: Menu2PopupSharedProps & React.RefAttributes, +) => React.JSX.Element; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts new file mode 100644 index 00000000000000..4e1bb9fe8fe140 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -0,0 +1,150 @@ +import { CSSInterpolation, CSSObject } from '@mui/system'; +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; + disabled: string; + dense: string; + divider: string; + gutters: string; + selected: string; + open?: string | undefined; +} + +export function getMenu2ItemStyles( + theme: Theme, + classes: SharedMenu2ItemClasses, +): CSSInterpolation { + const selectedFocusBackgroundColor = theme.alpha( + (theme.vars || theme).palette.primary.main, + `${(theme.vars || theme).palette.action.selectedOpacity} + ${ + (theme.vars || theme).palette.action.focusOpacity + }`, + ); + + return { + WebkitTapHighlightColor: 'transparent', + backgroundColor: 'transparent', + border: 0, + margin: 0, + borderRadius: 0, + color: 'inherit', + cursor: 'pointer', + userSelect: 'none', + verticalAlign: 'middle', + MozAppearance: 'none', + WebkitAppearance: 'none', + outline: 0, + '&::-moz-focus-inner': { + borderStyle: 'none', + }, + ...getMenuItemRootStyles(theme, classes, { + focusVisibleClass: classes.highlighted, + disabledPointerEvents: true, + }), + ...(classes.open && { + [`&.${classes.open}`]: { + backgroundColor: (theme.vars || theme).palette.action.focus, + }, + [`&.${classes.selected}.${classes.open}`]: { + backgroundColor: selectedFocusBackgroundColor, + }, + }), + }; +} + +export const menu2PopupPaperStyles: CSSInterpolation = { + // 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 + // 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 = 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 + * `Grow` transition the legacy Menu uses (same scale ramp, same theme + * durations, and the transform running at two thirds of the opacity duration). + * + * It has to live on the popup element: Base UI waits for animations on the + * popup itself before unmounting, so a transition on any descendant would be + * cut off on exit. `--transform-origin` is set by the positioner, so the menu + * grows out of the edge it is anchored to. + */ +export const menu2PopupTransitionStyles = memoTheme(({ theme }) => ({ + 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', + justifyContent: 'center', + minWidth: 36, + color: (theme.vars || theme).palette.action.active, + '& [data-mui-menu2-indicator-icon]': { + display: 'inline-block', + flexShrink: 0, + width: '1.25rem', + height: '1.25rem', + fill: 'currentColor', + }, + '& [data-mui-menu2-checkbox-checkmark]': { + fill: (theme.vars || theme).palette.background.paper, + }, + '&[data-unchecked] [data-mui-menu2-indicator-mark]': { + visibility: 'hidden', + }, +})); diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts new file mode 100644 index 00000000000000..abd2bcaa7cbfef --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts @@ -0,0 +1,65 @@ +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 interface Menu2RootSlots { + root?: React.ElementType | undefined; +} + +export interface Menu2RootSlotProps { + root?: SlotProps, OwnerState>; +} + +export function getMenu2RootRender( + 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 isMenu2RootNativeButton( + 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/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx new file mode 100644 index 00000000000000..c4a1b0afbe3d90 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import 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_Menu2CheckboxItem/Menu2CheckboxItem.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx new file mode 100644 index 00000000000000..fbb4d118ea49dc --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -0,0 +1,345 @@ +'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 ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import ButtonBase from '../ButtonBase'; +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, + getMenu2ItemOwnerState, + Menu2ItemBaseProps, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + menu2ItemOverridesResolver, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; +import { + getMenu2CheckboxItemUtilityClass, + menu2CheckboxItemClasses, + Menu2CheckboxItemClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2CheckboxItemSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType | undefined; + /** + * The component that renders the check indicator. + * @default Menu2CheckboxItemIndicator + */ + indicator?: React.ElementType | undefined; +} + +export interface Menu2CheckboxItemSlotProps extends Menu2RootSlotProps { + indicator?: + | SlotProps & Record, Menu2ItemOwnerState> + | undefined; +} + +export interface Menu2CheckboxItemProps + extends + Omit< + BaseMenu.CheckboxItem.Props, + 'className' | 'nativeButton' | 'onChange' | 'onCheckedChange' | 'render' | 'style' + >, + Menu2ItemBaseProps, + Menu2ItemVisualProps< + Menu2CheckboxItemClasses, + Menu2CheckboxItemSlots, + Menu2CheckboxItemSlotProps + > { + /** + * 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; + /** + * If `true`, the ripple effect is disabled. + * @default false + */ + disableRipple?: boolean | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2CheckboxItemRoot = styled(ButtonBase, { + name: 'MuiMenu2CheckboxItem', + slot: 'Root', + overridesResolver: menu2ItemOverridesResolver, +})<{ ownerState: Menu2ItemOwnerState }>( + memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2CheckboxItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2CheckboxItem = React.forwardRef(function Menu2CheckboxItem( + inProps: Menu2CheckboxItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2CheckboxItem', + }); + + const { + children, + checked, + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + disableRipple = false, + divider = false, + nativeButton: nativeButtonProp, + onChange, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenu2ItemOwnerState({ + checked, + dense, + disabled, + disableGutters, + divider, + selected, + }), + classes: classesProp, + }; + const classes = useMenu2ItemUtilityClasses( + ownerState, + getMenu2CheckboxItemUtilityClass, + ); + 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 ?? Menu2CheckboxItemRoot; + const IndicatorSlot = slots?.indicator ?? Menu2CheckboxItemIndicator; + const resolvedIndicatorProps = resolveComponentProps(slotProps?.indicator, ownerState); + + return ( + + by default; the items keep their element. + component: component ?? 'div', + disableRipple, + ownerState, + sx, + })} + className={(state) => + clsx( + className, + getMenu2ItemClassName(classes, ownerState, state), + state.checked && classes.checked, + ) + } + checked={checked} + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} + onCheckedChange={handleCheckedChange} + style={style} + {...other} + > + {/* Reserved by default: an unmounted indicator would shift the label. */} + + {children} + + + ); +}); + +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`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * 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`, the ripple effect is disabled. + * @default false + */ + disableRipple: 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({ + 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, + }), + /** + * 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 Menu2CheckboxItem; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.ts new file mode 100644 index 00000000000000..dff5b9c4fec88a --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.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_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx new file mode 100644 index 00000000000000..4db2d49ba25ac8 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.test.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +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'; +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(); + + 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_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx new file mode 100644 index 00000000000000..e727cf0ee95033 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx @@ -0,0 +1,213 @@ +'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 { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menu2IndicatorStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2CheckboxItemIndicatorUtilityClass, + Menu2CheckboxItemIndicatorClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2CheckboxItemIndicatorSlots { + /** + * The component that renders the root. + * @default 'span' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2CheckboxItemIndicatorSlotProps extends Menu2RootSlotProps {} + +export interface Menu2CheckboxItemIndicatorProps 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?: Menu2CheckboxItemIndicatorSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2CheckboxItemIndicatorSlotProps | 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: Menu2CheckboxItemIndicatorProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + checked: ['checked'], + disabled: ['disabled'], + highlighted: ['highlighted'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenu2CheckboxItemIndicatorUtilityClass, classes), + }; +}; + +const Menu2CheckboxItemIndicatorRoot = styled('span', { + name: 'MuiMenu2CheckboxItemIndicator', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})(menu2IndicatorStyles) as any; + +function DefaultCheckboxIndicatorIcon() { + return ( + + ); +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2CheckboxItemIndicator = React.forwardRef(function Menu2CheckboxItemIndicator( + inProps: Menu2CheckboxItemIndicatorProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2CheckboxItemIndicator', + }); + + 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 ?? } + + ); +}); + +Menu2CheckboxItemIndicator.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 Menu2CheckboxItemIndicator; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.ts new file mode 100644 index 00000000000000..415da71ca40fa2 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2CheckboxItemIndicator'; +export * from './Menu2CheckboxItemIndicator'; +export { + menu2CheckboxItemIndicatorClasses, + getMenu2CheckboxItemIndicatorUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2CheckboxItemIndicatorClasses, + Menu2CheckboxItemIndicatorClassKey, +} from '../Unstable_Menu2/menu2Classes'; 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..004661448f869e --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.test.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +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, () => ({ + 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_Menu2Group/Menu2Group.tsx b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx new file mode 100644 index 00000000000000..b583e2bd4e7b9e --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx @@ -0,0 +1,169 @@ +'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 { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { getMenu2GroupUtilityClass, Menu2GroupClasses } from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2GroupSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2GroupSlotProps extends Menu2RootSlotProps {} + +export interface Menu2GroupProps 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?: Menu2GroupSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2GroupSlotProps | 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: Menu2GroupProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getMenu2GroupUtilityClass, classes); +}; + +const Menu2GroupRoot = styled('div', { + name: 'MuiMenu2Group', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2Group = React.forwardRef(function Menu2Group( + inProps: Menu2GroupProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2Group', + }); + + const { + className, + classes: classesProp, + component, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +Menu2Group.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 Menu2Group; diff --git a/packages/mui-material/src/Unstable_Menu2Group/index.ts b/packages/mui-material/src/Unstable_Menu2Group/index.ts new file mode 100644 index 00000000000000..7b636c29e449aa --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/index.ts @@ -0,0 +1,4 @@ +export { default } from './Menu2Group'; +export * from './Menu2Group'; +export { menu2GroupClasses, getMenu2GroupUtilityClass } from '../Unstable_Menu2/menu2Classes'; +export type { Menu2GroupClasses, Menu2GroupClassKey } from '../Unstable_Menu2/menu2Classes'; 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..0062ad5128fda5 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +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'; +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_Menu2GroupLabel/Menu2GroupLabel.tsx b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx new file mode 100644 index 00000000000000..1127d343ae1e1e --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx @@ -0,0 +1,174 @@ +'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 ListSubheader from '../ListSubheader'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2GroupLabelUtilityClass, + Menu2GroupLabelClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2GroupLabelSlots { + /** + * The component that renders the root. + * @default ListSubheader + */ + root?: React.ElementType | undefined; +} + +export interface Menu2GroupLabelSlotProps extends Menu2RootSlotProps {} + +export interface Menu2GroupLabelProps 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?: Menu2GroupLabelSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2GroupLabelSlotProps | 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: Menu2GroupLabelProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getMenu2GroupLabelUtilityClass, classes); +}; + +const Menu2GroupLabelRoot = styled(ListSubheader, { + name: 'MuiMenu2GroupLabel', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2GroupLabel = React.forwardRef(function Menu2GroupLabel( + inProps: Menu2GroupLabelProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2GroupLabel', + }); + + const { + className, + classes: classesProp, + component, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +Menu2GroupLabel.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 Menu2GroupLabel; diff --git a/packages/mui-material/src/Unstable_Menu2GroupLabel/index.ts b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.ts new file mode 100644 index 00000000000000..8a600248959ee1 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2GroupLabel'; +export * from './Menu2GroupLabel'; +export { + menu2GroupLabelClasses, + getMenu2GroupLabelUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2GroupLabelClasses, + Menu2GroupLabelClassKey, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx new file mode 100644 index 00000000000000..2a013c63ff6b80 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +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, () => ({ + 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). + const item = document.querySelector('[role="menuitem"]')!; + return { ...other, container: { firstChild: item } as unknown as HTMLElement }; + }, + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'span', + muiName: 'MuiMenu2Item', + testVariantProps: { dense: true }, + })); +}); diff --git a/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx new file mode 100644 index 00000000000000..3ec12cf3222457 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx @@ -0,0 +1,252 @@ +'use client'; +import * as React from 'react'; +import resolveComponentProps from '@mui/utils/resolveComponentProps'; +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 ButtonBase from '../ButtonBase'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2ItemOwnerState, + Menu2ItemBaseProps, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + menu2ItemOverridesResolver, + mergeMenu2ItemClassName, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; +import { + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, +} from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2ItemUtilityClass, + menu2ItemClasses, + Menu2ItemClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2ItemSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2ItemSlotProps extends Menu2RootSlotProps {} + +export interface Menu2ItemProps + extends + Omit, + Menu2ItemBaseProps, + Menu2ItemVisualProps { + /** + * 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; + /** + * If `true`, the ripple effect is disabled. + * @default false + */ + disableRipple?: boolean | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2ItemRoot = styled(ButtonBase, { + name: 'MuiMenu2Item', + slot: 'Root', + overridesResolver: menu2ItemOverridesResolver, +})<{ ownerState: Menu2ItemOwnerState }>( + memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2ItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2Item = React.forwardRef(function Menu2Item( + inProps: Menu2ItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2Item', + }); + + 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, + getMenu2ItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const RootSlot = slots?.root ?? Menu2ItemRoot; + + return ( + + by default; the items keep their element. + component: component ?? 'div', + disableRipple, + ownerState, + sx, + })} + className={mergeMenu2ItemClassName(className, classes, ownerState)} + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} + style={style} + {...other} + /> + + ); +}); + +Menu2Item.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`, the ripple effect is disabled. + * @default false + */ + disableRipple: 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 Menu2Item; diff --git a/packages/mui-material/src/Unstable_Menu2Item/index.ts b/packages/mui-material/src/Unstable_Menu2Item/index.ts new file mode 100644 index 00000000000000..e25f41dc5ae0ea --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/index.ts @@ -0,0 +1,4 @@ +export { default } from './Menu2Item'; +export * from './Menu2Item'; +export { menu2ItemClasses, getMenu2ItemUtilityClass } from '../Unstable_Menu2/menu2Classes'; +export type { Menu2ItemClasses, Menu2ItemClassKey } from '../Unstable_Menu2/menu2Classes'; 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..5f4d8e390ddfa8 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.test.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import 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_Menu2LinkItem/Menu2LinkItem.tsx b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx new file mode 100644 index 00000000000000..685c30c50f8766 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx @@ -0,0 +1,241 @@ +'use client'; +import * as React from 'react'; +import resolveComponentProps from '@mui/utils/resolveComponentProps'; +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 ButtonBase from '../ButtonBase'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2ItemOwnerState, + Menu2LinkItemBaseProps, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + menu2ItemOverridesResolver, + mergeMenu2ItemClassName, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; +import { + getMenu2LinkItemUtilityClass, + menu2LinkItemClasses, + Menu2LinkItemClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2LinkItemSlots { + /** + * The component that renders the root. + * @default 'a' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2LinkItemSlotProps extends Menu2RootSlotProps {} + +export interface Menu2LinkItemProps + extends + Omit, + Menu2LinkItemBaseProps, + Menu2ItemVisualProps { + /** + * 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; + /** + * If `true`, the ripple effect is disabled. + * @default false + */ + disableRipple?: boolean | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2LinkItemRoot = styled(ButtonBase, { + name: 'MuiMenu2LinkItem', + slot: 'Root', + overridesResolver: menu2ItemOverridesResolver, +})<{ ownerState: Menu2ItemOwnerState }>( + memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2LinkItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2LinkItem = React.forwardRef(function Menu2LinkItem( + inProps: Menu2LinkItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2LinkItem', + }); + + const { + className, + classes: classesProp, + component, + dense = false, + disableGutters = false, + disableRipple = false, + divider = false, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenu2ItemOwnerState({ + dense, + disabled: false, + disableGutters, + divider, + selected, + }), + classes: classesProp, + }; + const classes = useMenu2ItemUtilityClasses( + ownerState, + getMenu2LinkItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + + return ( + + by default; the items keep their element. + component: component ?? 'a', + disableRipple, + ownerState, + sx, + })} + className={mergeMenu2ItemClassName(className, classes, ownerState)} + style={style} + {...other} + /> + + ); +}); + +Menu2LinkItem.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`, the ripple effect is disabled. + * @default false + */ + disableRipple: 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 Menu2LinkItem; diff --git a/packages/mui-material/src/Unstable_Menu2LinkItem/index.ts b/packages/mui-material/src/Unstable_Menu2LinkItem/index.ts new file mode 100644 index 00000000000000..261948c35562e2 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/index.ts @@ -0,0 +1,4 @@ +export { default } from './Menu2LinkItem'; +export * from './Menu2LinkItem'; +export { menu2LinkItemClasses, getMenu2LinkItemUtilityClass } from '../Unstable_Menu2/menu2Classes'; +export type { Menu2LinkItemClasses, Menu2LinkItemClassKey } from '../Unstable_Menu2/menu2Classes'; 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..2d00a7ce30b5a4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import 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_Menu2RadioGroup/Menu2RadioGroup.tsx b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx new file mode 100644 index 00000000000000..23bbd942ca5258 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx @@ -0,0 +1,226 @@ +'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 { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2RadioGroupUtilityClass, + Menu2RadioGroupClasses, +} from '../Unstable_Menu2/menu2Classes'; + +interface Menu2RadioGroupOwnerState extends Menu2RadioGroupProps {} + +export interface Menu2RadioGroupSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2RadioGroupSlotProps extends Menu2RootSlotProps {} + +export interface Menu2RadioGroupProps 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?: Menu2RadioGroupSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2RadioGroupSlotProps | 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: Menu2RadioGroupOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + disabled: ['disabled'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenu2RadioGroupUtilityClass, classes), + }; +}; + +const Menu2RadioGroupRoot = styled('div', { + name: 'MuiMenu2RadioGroup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2RadioGroup = React.forwardRef(function Menu2RadioGroup( + inProps: Menu2RadioGroupProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2RadioGroup', + }); + + 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} + /> + ); +}); + +Menu2RadioGroup.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 Menu2RadioGroup; diff --git a/packages/mui-material/src/Unstable_Menu2RadioGroup/index.ts b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.ts new file mode 100644 index 00000000000000..f6c449efe07eff --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2RadioGroup'; +export * from './Menu2RadioGroup'; +export { + menu2RadioGroupClasses, + getMenu2RadioGroupUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2RadioGroupClasses, + Menu2RadioGroupClassKey, +} from '../Unstable_Menu2/menu2Classes'; 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..a34286e05397d8 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.test.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +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'; +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_Menu2RadioItem/Menu2RadioItem.tsx b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx new file mode 100644 index 00000000000000..1e626f4ea3a339 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx @@ -0,0 +1,289 @@ +'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 ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import ButtonBase from '../ButtonBase'; +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, + getMenu2ItemOwnerState, + Menu2ItemBaseProps, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + menu2ItemOverridesResolver, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; +import { + getMenu2RadioItemUtilityClass, + menu2RadioItemClasses, + Menu2RadioItemClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2RadioItemSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType | undefined; + /** + * The component that renders the check indicator. + * @default Menu2RadioItemIndicator + */ + indicator?: React.ElementType | undefined; +} + +export interface Menu2RadioItemSlotProps extends Menu2RootSlotProps { + indicator?: + | SlotProps & Record, Menu2ItemOwnerState> + | undefined; +} + +export interface Menu2RadioItemProps + extends + Omit, + Menu2ItemBaseProps, + Menu2ItemVisualProps { + /** + * 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; + /** + * If `true`, the ripple effect is disabled. + * @default false + */ + disableRipple?: boolean | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2RadioItemRoot = styled(ButtonBase, { + name: 'MuiMenu2RadioItem', + slot: 'Root', + overridesResolver: menu2ItemOverridesResolver, +})<{ ownerState: Menu2ItemOwnerState }>( + memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2RadioItemClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2RadioItem = React.forwardRef(function Menu2RadioItem( + inProps: Menu2RadioItemProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2RadioItem', + }); + + const { + children, + 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, + getMenu2RadioItemUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const RootSlot = slots?.root ?? Menu2RadioItemRoot; + const IndicatorSlot = slots?.indicator ?? Menu2RadioItemIndicator; + const resolvedIndicatorProps = resolveComponentProps(slotProps?.indicator, ownerState); + + return ( + + by default; the items keep their element. + component: component ?? 'div', + disableRipple, + ownerState, + sx, + })} + className={(state) => + clsx( + className, + getMenu2ItemClassName(classes, ownerState, state), + state.checked && classes.checked, + ) + } + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} + style={style} + {...other} + > + {/* Reserved by default: an unmounted indicator would shift the label. */} + + {children} + + + ); +}); + +Menu2RadioItem.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`, the ripple effect is disabled. + * @default false + */ + disableRipple: 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({ + 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, + }), + /** + * 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 Menu2RadioItem; diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/index.ts b/packages/mui-material/src/Unstable_Menu2RadioItem/index.ts new file mode 100644 index 00000000000000..d1edd2e9763db6 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/index.ts @@ -0,0 +1,7 @@ +export { default } from './Menu2RadioItem'; +export * from './Menu2RadioItem'; +export { + menu2RadioItemClasses, + getMenu2RadioItemUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { Menu2RadioItemClasses, Menu2RadioItemClassKey } from '../Unstable_Menu2/menu2Classes'; 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..c6fae27b15d5d6 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +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'; +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(); + + 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_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx new file mode 100644 index 00000000000000..809b4c180bb1e6 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx @@ -0,0 +1,212 @@ +'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 { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menu2IndicatorStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2RadioItemIndicatorUtilityClass, + Menu2RadioItemIndicatorClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2RadioItemIndicatorSlots { + /** + * The component that renders the root. + * @default 'span' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2RadioItemIndicatorSlotProps extends Menu2RootSlotProps {} + +export interface Menu2RadioItemIndicatorProps 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?: Menu2RadioItemIndicatorSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2RadioItemIndicatorSlotProps | 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: Menu2RadioItemIndicatorProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + checked: ['checked'], + disabled: ['disabled'], + highlighted: ['highlighted'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenu2RadioItemIndicatorUtilityClass, classes), + }; +}; + +const Menu2RadioItemIndicatorRoot = styled('span', { + name: 'MuiMenu2RadioItemIndicator', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})(menu2IndicatorStyles) as any; + +function DefaultRadioIndicatorIcon() { + return ( + + ); +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2RadioItemIndicator = React.forwardRef(function Menu2RadioItemIndicator( + inProps: Menu2RadioItemIndicatorProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2RadioItemIndicator', + }); + + 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 ?? } + + ); +}); + +Menu2RadioItemIndicator.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 Menu2RadioItemIndicator; diff --git a/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.ts b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.ts new file mode 100644 index 00000000000000..8b93c269ea0f22 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2RadioItemIndicator'; +export * from './Menu2RadioItemIndicator'; +export { + menu2RadioItemIndicatorClasses, + getMenu2RadioItemIndicatorUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2RadioItemIndicatorClasses, + Menu2RadioItemIndicatorClassKey, +} from '../Unstable_Menu2/menu2Classes'; 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..5e5a7451d2df01 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import 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_Menu2Separator/Menu2Separator.tsx b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx new file mode 100644 index 00000000000000..ec7302755966dd --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx @@ -0,0 +1,192 @@ +'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 { 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 memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2RootRender, Menu2RootSlotProps } from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2SeparatorUtilityClass, + Menu2SeparatorClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2SeparatorSlots { + /** + * The component that renders the root. + * @default Divider + */ + root?: React.ElementType | undefined; +} + +export interface Menu2SeparatorSlotProps extends Menu2RootSlotProps {} + +export interface Menu2SeparatorProps 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?: Menu2SeparatorSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2SeparatorSlotProps | 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: Menu2SeparatorProps) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + }; + + return composeClasses(slots, getMenu2SeparatorUtilityClass, classes); +}; + +const Menu2SeparatorRoot = styled(Divider, { + name: 'MuiMenu2Separator', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})( + // 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; + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2Separator = React.forwardRef(function Menu2Separator( + inProps: Menu2SeparatorProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2Separator', + }); + + const { + className, + classes: classesProp, + component, + orientation = 'horizontal', + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + classes: classesProp, + orientation, + }; + const classes = useUtilityClasses(ownerState); + + return ( + + ); +}); + +Menu2Separator.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 Menu2Separator; diff --git a/packages/mui-material/src/Unstable_Menu2Separator/index.ts b/packages/mui-material/src/Unstable_Menu2Separator/index.ts new file mode 100644 index 00000000000000..2d70c501e79c8f --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/index.ts @@ -0,0 +1,7 @@ +export { default } from './Menu2Separator'; +export * from './Menu2Separator'; +export { + menu2SeparatorClasses, + getMenu2SeparatorUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { Menu2SeparatorClasses, Menu2SeparatorClassKey } 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 new file mode 100644 index 00000000000000..547fd90c7e7d4a --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Collapsed.test.tsx @@ -0,0 +1,385 @@ +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'; +import Menu2, { menu2PopupClasses, menu2TriggerClasses } from '@mui/material/Unstable_Menu2'; +import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; +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. +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); + }); + + // 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( + Options} + slots={{ portal: 'div', positioner: 'div' }} + slotProps={{ + portal: { 'data-testid': 'portal' }, + positioner: { 'data-testid': 'positioner' }, + }} + > + 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( + Options}> + More}> + 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); + }); + + // 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: { + paper: { paddingTop: '9px' }, + list: { paddingBottom: '7px' }, + }, + }, + MuiMenu2Submenu: { + styleOverrides: { + paper: { paddingTop: '11px' }, + }, + }, + }, + }); + const { user } = render( + + Options} + slotProps={{ paper: { 'data-testid': 'paper' } }} + > + More} + slotProps={{ paper: { 'data-testid': 'submenu-paper' } }} + > + Nested + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + + 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' }); + + 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', + ); + }); + + // Geometry only: jsdom has no layout, so this runs in the browser project. + // The open state is styled from the list, and the selected state from the item. + // Both selectors are (0,2,0), so the winner depends on style insertion order. + // A selected trigger that is open must keep the selected blend, not the plain + // neutral open colour. + it.skipIf(isJsdom())('keeps the selected blend on a trigger whose submenu is open', async () => { + const { user } = render( + Options}> + 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('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(); + } + }); + + // 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) { + 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( + Options} + slotProps={{ paper: { 'data-testid': 'paper' } }} + > + More} + slotProps={{ paper: { 'data-testid': 'submenu-paper' } }} + > + 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); + }); + + // `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( + Options}> + More}> + Nested + + , + ); + + 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); + 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 () => { + const { user } = render( + Options}> + 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 + View}> + 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..b85c109685d0f5 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/Menu2Submenu.tsx @@ -0,0 +1,200 @@ +'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 { useDefaultProps } from '../DefaultPropsProvider'; +import { SlotProps } from '../Unstable_Menu2/menu2Utils'; +import { menu2SubmenuTriggerClasses } from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2SubmenuSlots extends NonNullable {} + +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 element that opens the submenu, for example a `Menu2Item`. + * + * 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.ReactElement | undefined; + /** + * 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/) + */ +const Menu2Submenu = React.forwardRef(function Menu2Submenu( + props: Menu2SubmenuProps, + ref: React.ForwardedRef, +) { + 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 popupSlots = slots; + 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 : ( + )} + {...resolvedTriggerProps} + className={(state) => + clsx( + menu2SubmenuTriggerClasses.root, + state.open && menu2SubmenuTriggerClasses.open, + resolvedTriggerProps?.className, + ) + } + /> + ); + + 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.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.shape({ + list: PropTypes.elementType, + paper: PropTypes.elementType, + popup: PropTypes.elementType, + portal: PropTypes.elementType, + positioner: PropTypes.elementType, + }), + /** + * The element that opens the submenu, for example a `Menu2Item`. + * + * 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.element, +} as any; + +export default Menu2Submenu; diff --git a/packages/mui-material/src/Unstable_Menu2Submenu/index.ts b/packages/mui-material/src/Unstable_Menu2Submenu/index.ts new file mode 100644 index 00000000000000..ec8e07d87122f0 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Submenu/index.ts @@ -0,0 +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/styles/components.ts b/packages/mui-material/src/styles/components.ts index 3404eaee7c94f0..fe805adb508a5c 100644 --- a/packages/mui-material/src/styles/components.ts +++ b/packages/mui-material/src/styles/components.ts @@ -477,6 +477,90 @@ export interface Components { variants?: ComponentsVariants['MuiMenuList'] | undefined; } | undefined; + MuiMenu2?: + | { + defaultProps?: ComponentsProps['MuiMenu2'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2'] | undefined; + variants?: ComponentsVariants['MuiMenu2'] | undefined; + } + | undefined; + MuiMenu2CheckboxItem?: + | { + defaultProps?: ComponentsProps['MuiMenu2CheckboxItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2CheckboxItem'] | undefined; + variants?: ComponentsVariants['MuiMenu2CheckboxItem'] | undefined; + } + | undefined; + MuiMenu2CheckboxItemIndicator?: + | { + defaultProps?: ComponentsProps['MuiMenu2CheckboxItemIndicator'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2CheckboxItemIndicator'] | undefined; + variants?: ComponentsVariants['MuiMenu2CheckboxItemIndicator'] | undefined; + } + | undefined; + MuiMenu2Group?: + | { + defaultProps?: ComponentsProps['MuiMenu2Group'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Group'] | undefined; + variants?: ComponentsVariants['MuiMenu2Group'] | undefined; + } + | undefined; + MuiMenu2GroupLabel?: + | { + defaultProps?: ComponentsProps['MuiMenu2GroupLabel'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2GroupLabel'] | undefined; + variants?: ComponentsVariants['MuiMenu2GroupLabel'] | undefined; + } + | undefined; + MuiMenu2Item?: + | { + defaultProps?: ComponentsProps['MuiMenu2Item'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Item'] | undefined; + variants?: ComponentsVariants['MuiMenu2Item'] | undefined; + } + | undefined; + MuiMenu2LinkItem?: + | { + defaultProps?: ComponentsProps['MuiMenu2LinkItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2LinkItem'] | undefined; + variants?: ComponentsVariants['MuiMenu2LinkItem'] | undefined; + } + | undefined; + MuiMenu2RadioGroup?: + | { + defaultProps?: ComponentsProps['MuiMenu2RadioGroup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2RadioGroup'] | undefined; + variants?: ComponentsVariants['MuiMenu2RadioGroup'] | undefined; + } + | undefined; + MuiMenu2RadioItem?: + | { + defaultProps?: ComponentsProps['MuiMenu2RadioItem'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2RadioItem'] | undefined; + variants?: ComponentsVariants['MuiMenu2RadioItem'] | undefined; + } + | undefined; + MuiMenu2RadioItemIndicator?: + | { + defaultProps?: ComponentsProps['MuiMenu2RadioItemIndicator'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2RadioItemIndicator'] | undefined; + variants?: ComponentsVariants['MuiMenu2RadioItemIndicator'] | undefined; + } + | undefined; + MuiMenu2Separator?: + | { + defaultProps?: ComponentsProps['MuiMenu2Separator'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Separator'] | undefined; + variants?: ComponentsVariants['MuiMenu2Separator'] | undefined; + } + | undefined; + MuiMenu2Submenu?: + | { + defaultProps?: ComponentsProps['MuiMenu2Submenu'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Submenu'] | undefined; + variants?: ComponentsVariants['MuiMenu2Submenu'] | undefined; + } + | undefined; MuiMobileStepper?: | { defaultProps?: ComponentsProps['MuiMobileStepper'] | undefined; diff --git a/packages/mui-material/src/styles/enhanceHighContrast.test.ts b/packages/mui-material/src/styles/enhanceHighContrast.test.ts index a88bf0938b35e4..4a5ecbcc47efb9 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,199 @@ describe('enhanceHighContrast', () => { }); }); + describe('Menu2 item overrides', () => { + const itemCases: Array< + [ + component: string, + classes: { disabled: string; highlighted: string; selected: string }, + slot: string, + ] + > = [ + ['MuiMenu2Item', menu2ItemClasses, 'root'], + ['MuiMenu2LinkItem', menu2LinkItemClasses, 'root'], + ['MuiMenu2CheckboxItem', menu2CheckboxItemClasses, 'root'], + ['MuiMenu2RadioItem', menu2RadioItemClasses, 'root'], + ]; + + 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, 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.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, slot) => { + const theme = enhanceHighContrast(createTheme(), { + disabled: 'ButtonText', + activeText: 'Canvas', + activeBackground: 'ButtonBorder', + }); + const rootOverrides = (theme.components as any)[component].styleOverrides[ + slot + ] 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.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, slot) => { + const theme = enhanceHighContrast(createTheme()); + 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( + 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('MuiMenu2Submenu marks the trigger open state from the list', () => { + const theme = enhanceHighContrast(createTheme()); + const listOverrides = theme.components?.MuiMenu2Submenu?.styleOverrides + ?.list as Array; + const hcmOverride = listOverrides[listOverrides.length - 1] as Record; + + 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 +1081,13 @@ describe('enhanceHighContrast', () => { ['MuiLinearProgress', 'bar2'], ['MuiListItemButton', 'root'], ['MuiMenuItem', 'root'], + ['MuiMenu2Item', 'root'], + ['MuiMenu2LinkItem', 'root'], + ['MuiMenu2CheckboxItem', 'root'], + ['MuiMenu2RadioItem', 'root'], + ['MuiMenu2Submenu', 'list'], + ['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..a35ef1f5ab428a 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,56 @@ 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.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, + }, + }, + // 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}`, + }, + }, + }; +} + /** * Enhances a theme with styles for Windows High Contrast Mode (forced-colors). * @@ -396,6 +454,140 @@ 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), + // 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, + }, + }, + }, + ], + }, + }, + MuiMenu2RadioItem: { + ...c?.MuiMenu2RadioItem, + styleOverrides: { + ...c?.MuiMenu2RadioItem?.styleOverrides, + root: [ + c?.MuiMenu2RadioItem?.styleOverrides?.root, + menu2ItemOverrides(menu2RadioItemClasses, hcTokens), + ], + }, + }, + // 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, + list: [ + c?.MuiMenu2Submenu?.styleOverrides?.list, + { + [`& .${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, + }, + }, + }, + ], + }, + }, + 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: { diff --git a/packages/mui-material/src/styles/overrides.ts b/packages/mui-material/src/styles/overrides.ts index fa9e23fd15ab1a..a2da6ad83bf63a 100644 --- a/packages/mui-material/src/styles/overrides.ts +++ b/packages/mui-material/src/styles/overrides.ts @@ -67,6 +67,17 @@ 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 { Menu2RadioGroupClassKey } from '../Unstable_Menu2RadioGroup'; +import { Menu2RadioItemClassKey } from '../Unstable_Menu2RadioItem'; +import { Menu2RadioItemIndicatorClassKey } from '../Unstable_Menu2RadioItemIndicator'; +import { Menu2SeparatorClassKey } from '../Unstable_Menu2Separator'; import { MobileStepperClassKey } from '../MobileStepper'; import { ModalClassKey } from '../Modal'; import { NativeSelectClassKey } from '../NativeSelect'; @@ -211,6 +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; + MuiMenu2RadioGroup: Menu2RadioGroupClassKey; + MuiMenu2RadioItem: Menu2RadioItemClassKey; + MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorClassKey; + MuiMenu2Separator: Menu2SeparatorClassKey; 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..ccb28e4c90f542 100644 --- a/packages/mui-material/src/styles/props.ts +++ b/packages/mui-material/src/styles/props.ts @@ -64,6 +64,18 @@ import { ListProps } from '../List'; import { ListSubheaderProps } from '../ListSubheader'; import { MenuItemProps } from '../MenuItem'; import { MenuListProps } from '../MenuList'; +import { Menu2Props } from '../Unstable_Menu2'; +import { Menu2CheckboxItemProps } from '../Unstable_Menu2CheckboxItem'; +import { Menu2CheckboxItemIndicatorProps } from '../Unstable_Menu2CheckboxItemIndicator'; +import { Menu2GroupProps } from '../Unstable_Menu2Group'; +import { Menu2GroupLabelProps } from '../Unstable_Menu2GroupLabel'; +import { Menu2ItemProps } from '../Unstable_Menu2Item'; +import { Menu2LinkItemProps } from '../Unstable_Menu2LinkItem'; +import { Menu2RadioGroupProps } from '../Unstable_Menu2RadioGroup'; +import { Menu2RadioItemProps } from '../Unstable_Menu2RadioItem'; +import { Menu2RadioItemIndicatorProps } from '../Unstable_Menu2RadioItemIndicator'; +import { Menu2SeparatorProps } from '../Unstable_Menu2Separator'; +import { Menu2SubmenuProps } from '../Unstable_Menu2Submenu'; import { MenuProps } from '../Menu'; import { MobileStepperProps } from '../MobileStepper'; import { ModalProps } from '../Modal'; @@ -189,6 +201,18 @@ export interface ComponentsPropsList { MuiMenu: MenuProps; MuiMenuItem: MenuItemProps; MuiMenuList: MenuListProps; + MuiMenu2: Menu2Props; + MuiMenu2CheckboxItem: Menu2CheckboxItemProps; + MuiMenu2CheckboxItemIndicator: Menu2CheckboxItemIndicatorProps; + MuiMenu2Group: Menu2GroupProps; + MuiMenu2GroupLabel: Menu2GroupLabelProps; + MuiMenu2Item: Menu2ItemProps; + MuiMenu2LinkItem: Menu2LinkItemProps; + MuiMenu2RadioGroup: Menu2RadioGroupProps; + MuiMenu2RadioItem: Menu2RadioItemProps; + MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorProps; + MuiMenu2Separator: Menu2SeparatorProps; + MuiMenu2Submenu: Menu2SubmenuProps; MuiMobileStepper: MobileStepperProps; MuiModal: ModalProps; MuiNativeSelect: NativeSelectProps; 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 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a046d163095630..fc379efa5bc5f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,7 +697,7 @@ importers: specifier: ^7.29.7 version: 7.29.7 '@base-ui/react': - specifier: ^1 + specifier: ^1.5.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@docsearch/react': specifier: catalog:docs @@ -1114,6 +1114,9 @@ importers: '@babel/runtime': specifier: ^7.29.7 version: 7.29.7 + '@base-ui/react': + specifier: ^1.6.0 + version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@emotion/react': specifier: ^11.5.0 version: 11.14.0(@types/react@19.2.17)(react@19.2.8)(supports-color@10.2.2)