diff --git a/docs/pages/experiments/menu-preview.tsx b/docs/pages/experiments/menu-preview.tsx new file mode 100644 index 00000000000000..2690587f232e10 --- /dev/null +++ b/docs/pages/experiments/menu-preview.tsx @@ -0,0 +1,688 @@ +import * as React from 'react'; +import Container from '@mui/material/Container'; +import CssBaseline from '@mui/material/CssBaseline'; +import Popover from '@mui/material/Popover'; +import Stack from '@mui/material/Stack'; +import Tooltip, { type TooltipProps } from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +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 Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +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); + } + }} + > + }> + Help cards + + + + {rootPreviewCardItems[0].label} + + + + Version history + + + + {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 + + + New document + Open… + Template gallery + Recent documents + Docs help center + Make a copy + + + Rename document + + + Offline editing unavailable + + + + + + View options + + + + + Document display + + + + 100% + + + + Fit + + + + Page width + + + + Custom zoom unavailable + + + + + + + + Show + + + Ruler + + + + Document outline + + + + Line numbers + + + + Page breaks unavailable + + + + + + + + More tools + + + + Word count + Dictionary + Accessibility settings + + + + + + + + Download + + + + Microsoft Word (.docx) + PDF document (.pdf) + Plain text (.txt) + + + + + + Add-ons unavailable + + + + Marketplace + + + + + ); +} + +function 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 + + + + New document + + + Open recent + + + Make a copy + + + Import from Drive + + + Share with people + + + + + + + View options + + + + + + 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 ( + + + + + + + Menu Preview + +
+ 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/menu-rfc.tsx b/docs/pages/experiments/menu-rfc.tsx new file mode 100644 index 00000000000000..98c78990fd1931 --- /dev/null +++ b/docs/pages/experiments/menu-rfc.tsx @@ -0,0 +1,624 @@ +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, 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 Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; + +type MenuProps = React.ComponentProps; +type PopupProps = React.ComponentProps; +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 / RFC open questions + elevation: number; + animation: 'none' | 'grow'; + dense: boolean; + dividers: boolean; + rtl: 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, + animation: 'none', + dense: false, + dividers: false, + rtl: false, +}; + +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' }); + +// RFC open question "default open/close animation": CSS approximation of the +// classic Grow transition, driven by Base UI's data-starting/ending-style. +const growPopupSx: SxProps = { + transformOrigin: 'var(--transform-origin)', + transition: + 'opacity 225ms cubic-bezier(0.4, 0, 0.2, 1), transform 225ms cubic-bezier(0.4, 0, 0.2, 1)', + '&[data-starting-style], &[data-ending-style]': { + opacity: 0, + transform: 'scale(0.8, 0.6)', + }, + '&[data-ending-style]': { + transitionDuration: '195ms, 195ms', + }, +}; + +function usePopupKnobProps(settings: PlaygroundSettings) { + return React.useMemo( + () => ({ + // Top-level convenience prop (forwards to the Paper slot). + elevation: settings.elevation, + ...(settings.animation === 'grow' ? { slotProps: { popup: { sx: growPopupSx } } } : null), + }), + [settings.elevation, settings.animation], + ); +} + +function PlaygroundDemo({ + settings, + onLog, +}: { + settings: PlaygroundSettings; + onLog: (entry: string) => void; +}) { + const popupKnobProps = usePopupKnobProps(settings); + const itemProps = { dense: settings.dense, divider: settings.dividers }; + const submenuTriggerProps = { + ...itemProps, + openOnHover: settings.submenusOpenOnHover, + delay: settings.submenuDelay, + closeDelay: settings.submenuCloseDelay, + }; + const submenuPopupProps = { sideOffset: 8, ...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 + + + + Actions + + New file + + + Duplicate + + + Archive (disabled) + + + + + + + Share + + + + + Email + + + Copy link + + + + Export as + + + + + + + PDF document + + + + EPUB publication + + + + Markdown + + + + + + + + + + View + + + + + + Show ruler + + + + Show outline + + + + + + + Selected item (visual-only) + + + Menu documentation + + + + ); +} + +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 + + + {parityItems.map((item) => ( + + {item.label} + + ))} + + +
+ ); +} + +function ControlledAnchorDemo() { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + + const handleOpenChange: MenuProps['onOpenChange'] = (nextOpen) => { + if (!nextOpen) { + setAnchorEl(null); + } + }; + + return ( +
+ + + + setAnchorEl(null)}>Profile + setAnchorEl(null)}>My account + setAnchorEl(null)}>Logout + + +
+ ); +} + +const typeaheadEntries = [ + 'Argentina', + 'Australia', + 'Austria', + 'Belgium', + 'Brazil', + 'Canada', + 'Chile', + 'Colombia', + 'Czechia', + 'Denmark', + 'Estonia', + 'Finland', + 'France', + 'Germany', + 'Greece', + 'Hungary', + 'Iceland', + 'India', + 'Ireland', + 'Italy', + 'Japan', + 'Lithuania', + 'Mexico', + 'Netherlands', + 'New Zealand', + 'Norway', + 'Poland', + 'Portugal', + 'Spain', + 'Sweden', + 'Switzerland', + 'United Kingdom', +]; + +function TypeaheadScrollDemo() { + return ( + + }> + Country + + + {typeaheadEntries.map((entry) => ( + + {entry} + + ))} + + + ); +} + +function SettingsPanel({ + settings, + onChange, +}: { + settings: PlaygroundSettings; + onChange: React.Dispatch>; +}) { + const setSetting = ( + key: Key, + value: PlaygroundSettings[Key], + ) => { + onChange((currentSettings) => ({ ...currentSettings, [key]: value })); + }; + + const renderCheckbox = (key: keyof PlaygroundSettings, label: string) => ( + + ); + + const renderNumber = (key: keyof PlaygroundSettings, label: string, step = 50) => ( + + ); + + return ( + + Playground knobs +
+ Root behavior + {renderCheckbox('modal', 'modal')} + {renderCheckbox('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 (RFC open questions) + + + {renderCheckbox('dense', 'dense items')} + {renderCheckbox('dividers', 'item dividers')} + {renderCheckbox('rtl', 'RTL direction')} +
+
+ ); +} + +export default function MenuRfcExperiment() { + const [settings, setSettings] = React.useState(defaultSettings); + const [log, setLog] = React.useState([]); + + const pushLog = React.useCallback((entry: string) => { + setLog((currentLog) => [...currentLog.slice(-11), entry]); + }, []); + + const playgroundTheme = settings.rtl ? rtlTheme : theme; + + return ( + + + + + + + Menu RFC playground + + + Companion experiment for the Menu successor RFC. Every knob maps to a prop or an RFC + open question. See also the{' '} + Menu2 experiment for Tooltip, + PreviewCard, and ContextMenu recipes. + + + + +
+

Kitchen sink

+

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

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

Classic vs successor

+

+ The same item set rendered by the classic Menu and the successor, for visual parity + checks (dense, dividers, selected, disabled, elevation knobs apply to both). 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/packages-internal/core-docs/package.json b/packages-internal/core-docs/package.json index 953e9aecacbb2f..c79e2af6870670 100644 --- a/packages-internal/core-docs/package.json +++ b/packages-internal/core-docs/package.json @@ -53,7 +53,7 @@ "next": "15.5.20" }, "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 c705653f56b636..29b1474bd1d779 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..012e77f1fd923b --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.spec.tsx @@ -0,0 +1,203 @@ +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 Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger from '@mui/material/Unstable_Menu2Trigger'; +import { createTheme } from '@mui/material/styles'; +// @ts-expect-error Menu2 is intentionally not exported from the root barrel for this POC. +import { Menu2 as RootBarrelMenu2 } from '@mui/material'; + +function Menu2Composition() { + return ( + { + expectType(open); + eventDetails.cancel(); + eventDetails.preventUnmountOnClose(); + }} + > + + Options + + + + Menu2Group + + Menu2Item + + Profile + { + expectType(event); + expectType(checked); + eventDetails.cancel(); + }} + > + + Checkbox + + { + expectType(event); + expectType(value); + eventDetails.cancel(); + }} + > + + + One + + + + { + expectType(open); + eventDetails.cancel(); + }} + > + + More + + + Nested + + + + + + ); +} + +createTheme({ + components: { + MuiMenu2: { + defaultProps: { + modal: false, + }, + }, + MuiMenu2SubmenuRoot: { + defaultProps: { + defaultOpen: false, + }, + }, + MuiMenu2Item: { + defaultProps: { + dense: true, + }, + styleOverrides: { + root: {}, + highlighted: {}, + }, + variants: [ + { + props: { selected: true }, + style: {}, + }, + ], + }, + MuiMenu2Popup: { + defaultProps: { + align: 'start', + }, + styleOverrides: { + root: {}, + paper: {}, + list: {}, + }, + variants: [ + { + props: { align: 'start' }, + style: {}, + }, + ], + }, + MuiMenu2RadioItem: { + variants: [ + { + props: { value: 'small' }, + style: {}, + }, + ], + }, + MuiMenu2LinkItem: { + variants: [ + { + props: { href: '/profile' }, + style: {}, + }, + ], + }, + }, +}); + +; + +; + +} +> + Options +; + + + Options +; + +, + }, + }} +/>; 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..9cff470240995f --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.test.tsx @@ -0,0 +1,1115 @@ +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 { listClasses } from '@mui/material/List'; +import { paperClasses } from '@mui/material/Paper'; +import Tooltip from '@mui/material/Tooltip'; +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2CheckboxItem, { + menu2CheckboxItemClasses, +} from '@mui/material/Unstable_Menu2CheckboxItem'; +import Menu2CheckboxItemIndicator from '@mui/material/Unstable_Menu2CheckboxItemIndicator'; +import Menu2Group from '@mui/material/Unstable_Menu2Group'; +import Menu2GroupLabel from '@mui/material/Unstable_Menu2GroupLabel'; +import Menu2Item, { menu2ItemClasses } from '@mui/material/Unstable_Menu2Item'; +import Menu2LinkItem from '@mui/material/Unstable_Menu2LinkItem'; +import Menu2Popup, { menu2PopupClasses } from '@mui/material/Unstable_Menu2Popup'; +import Menu2RadioGroup from '@mui/material/Unstable_Menu2RadioGroup'; +import Menu2RadioItem from '@mui/material/Unstable_Menu2RadioItem'; +import Menu2RadioItemIndicator from '@mui/material/Unstable_Menu2RadioItemIndicator'; +import Menu2Separator from '@mui/material/Unstable_Menu2Separator'; +import Menu2SubmenuPopup from '@mui/material/Unstable_Menu2SubmenuPopup'; +import Menu2SubmenuRoot from '@mui/material/Unstable_Menu2SubmenuRoot'; +import Menu2SubmenuTrigger from '@mui/material/Unstable_Menu2SubmenuTrigger'; +import Menu2Trigger, { menu2TriggerClasses } from '@mui/material/Unstable_Menu2Trigger'; +import { ThemeProvider, createTheme } from '@mui/material/styles'; + +describe('', () => { + 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); + }); + + it('does not render the trigger as a link when href is passed by a JS caller', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger.tagName).to.equal('BUTTON'); + expect(trigger).not.to.have.attribute('href'); + + await user.click(trigger); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it('supports component props, slotProps, classes, styleOverrides, and variants', async () => { + const theme = createTheme({ + components: { + MuiMenu2Trigger: { + defaultProps: { + variant: 'outlined', + }, + }, + MuiMenu2Popup: { + styleOverrides: { + paper: { + minWidth: 128, + }, + }, + variants: [ + { + props: { align: 'start' }, + style: { + '--Menu2Popup-variant': '"applied"', + }, + }, + ], + }, + MuiMenu2Item: { + variants: [ + { + props: { selected: true }, + style: { + fontWeight: 700, + }, + }, + { + props: { disabled: true }, + style: { + '--Menu2Item-disabledVariant': '"applied"', + }, + }, + ], + }, + MuiMenu2CheckboxItem: { + variants: [ + { + props: { checked: true }, + style: { + '--Menu2CheckboxItem-checkedVariant': '"applied"', + }, + }, + ], + }, + MuiMenu2RadioItem: { + variants: [ + { + props: { value: 'small' }, + style: { + '--Menu2RadioItem-valueVariant': '"applied"', + }, + }, + ], + }, + MuiMenu2LinkItem: { + variants: [ + { + props: { href: '/profile' }, + style: { + '--Menu2LinkItem-hrefVariant': '"applied"', + }, + }, + ], + }, + }, + }); + + const { user } = render( + + + Options + + + Profile + + Disabled profile + Checked profile + + Small + + Link profile + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(screen.getByRole('button', { name: 'Options' })).to.have.class('custom-trigger'); + expect(await screen.findByTestId('list')).to.have.class('custom-list'); + expect( + window.getComputedStyle(screen.getByRole('menu')).getPropertyValue('--Menu2Popup-variant'), + ).to.equal('"applied"'); + expect(await screen.findByRole('menuitem', { name: 'Profile' })).to.have.class('custom-item'); + expect(screen.getByRole('menuitem', { name: 'Profile' })).to.have.class( + menu2ItemClasses.selected, + ); + expect(screen.getByRole('menuitem', { name: 'Disabled profile' })).to.have.class( + menu2ItemClasses.disabled, + ); + expect( + window + .getComputedStyle(screen.getByRole('menuitem', { name: 'Disabled profile' })) + .getPropertyValue('--Menu2Item-disabledVariant'), + ).to.equal('"applied"'); + expect(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })).to.have.class( + menu2CheckboxItemClasses.checked, + ); + expect( + window + .getComputedStyle(screen.getByRole('menuitemcheckbox', { name: 'Checked profile' })) + .getPropertyValue('--Menu2CheckboxItem-checkedVariant'), + ).to.equal('"applied"'); + expect( + window + .getComputedStyle(screen.getByRole('menuitemradio', { name: 'Small' })) + .getPropertyValue('--Menu2RadioItem-valueVariant'), + ).to.equal('"applied"'); + expect( + window + .getComputedStyle(screen.getByRole('menuitem', { name: 'Link profile' })) + .getPropertyValue('--Menu2LinkItem-hrefVariant'), + ).to.equal('"applied"'); + }); + + it('composes popup class names', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const menu = await screen.findByRole('menu'); + expect(menu).to.have.class('popup-open'); + expect(menu).to.have.class('popup-side-bottom'); + expect(menu).to.have.class(menu2PopupClasses.root); + }); + + it('does not pass ownerState to host popup slots', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByTestId('popup')).not.to.have.attribute('ownerState'); + }); + + it('derives native button behavior from host root slots', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { user } = render( + + Options + + + Native item + + + Native checkbox + + + + Native radio + + + + + Native submenu trigger + + + Nested + + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + expect(trigger.tagName).to.equal('DIV'); + + trigger.focus(); + await user.keyboard('[Enter]'); + + expect(await screen.findByRole('menuitem', { name: 'Native item' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect(screen.getByRole('menuitemcheckbox', { name: 'Native checkbox' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect(screen.getByRole('menuitemradio', { name: 'Native radio' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect(screen.getByRole('menuitem', { name: 'Native submenu trigger' })).to.have.property( + 'tagName', + 'BUTTON', + ); + expect( + error.mock.calls.some(([message]) => String(message).includes('nativeButton')), + ).to.equal(false); + } finally { + error.mockRestore(); + } + }); + + it('allows nativeButton to override root slot inference for custom slots', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const CustomDivRoot = React.forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef<'div'> & { ownerState?: unknown } + >(function CustomDivRoot({ ownerState: _ownerState, ...props }, ref) { + return
; + }); + const CustomButtonRoot = React.forwardRef< + HTMLButtonElement, + React.ComponentPropsWithoutRef<'button'> & { ownerState?: unknown } + >(function CustomButtonRoot({ ownerState: _ownerState, ...props }, ref) { + return + + Options + + Profile + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + await user.click(await screen.findByRole('menuitem', { name: 'Profile' })); + + await waitFor(() => { + expect(document.activeElement).to.equal(finalFocusRef.current); + }); + }); + + it('returns focus to the trigger on Escape and closes on outside press', async () => { + const { user } = render( + + + + Options + + Profile + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Options' }); + await user.click(trigger); + await screen.findByRole('menu'); + + await user.keyboard('[Escape]'); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + expect(document.activeElement).to.equal(trigger); + + await user.click(trigger); + await screen.findByRole('menu'); + await user.click(screen.getByRole('button', { name: 'Outside' })); + + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + }); + + it('supports touch trigger interactions', async () => { + const { user } = render( + + Options + + Profile + + , + ); + + await user.pointer({ + keys: '[TouchA]', + target: screen.getByRole('button', { name: 'Options' }), + }); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it('supports modal backdrop behavior', async () => { + const { user } = render( + + + Modal menu + + Profile + + + + Non-modal menu + + Settings + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Modal menu' })); + await screen.findByRole('menu'); + expect(screen.getByTestId('modal-positioner').previousElementSibling).to.have.attribute( + 'role', + 'presentation', + ); + + await user.keyboard('[Escape]'); + await waitFor(() => { + expect(screen.queryByRole('menu')).to.equal(null); + }); + + await user.click(screen.getByRole('button', { name: 'Non-modal menu' })); + await screen.findByRole('menu'); + expect(screen.getByTestId('non-modal-positioner').previousElementSibling).to.equal(null); + }); + + it('opens in an RTL tree', async () => { + const { user } = render( +
+ + Options + + Profile + + +
, + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + expect(await screen.findByRole('menu')).not.to.equal(null); + }); + + it.skipIf(isJsdom())('applies Base UI positioning attributes in the browser', async () => { + const { user } = render( +
+ + Options + + Profile + + +
, + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const positioner = await screen.findByTestId('positioner'); + expect(positioner).to.have.attribute('data-side', 'bottom'); + expect(positioner).to.have.attribute('data-align', 'start'); + expect(positioner.style.transform).not.to.equal(''); + }); + + it('supports checkbox and radio item state', async () => { + const handleCheckboxChange = spy((event: Event, checked: boolean, eventDetails: any) => { + expect(event).to.be.instanceOf(Event); + expect(checked).to.equal(true); + expect(eventDetails.reason).to.equal('item-press'); + }); + const handleRadioChange = spy((event: Event, value: string, eventDetails: any) => { + expect(event).to.be.instanceOf(Event); + expect(value).to.equal('large'); + expect(eventDetails.reason).to.equal('item-press'); + }); + + const { user } = render( + + Options + + + + Show hidden files + + + + + Small + + + + Large + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const checkbox = await screen.findByRole('menuitemcheckbox', { name: /show hidden files/i }); + expect(checkbox).to.have.attribute('aria-checked', 'false'); + + await user.click(checkbox); + + expect(checkbox).to.have.attribute('aria-checked', 'true'); + expect(checkbox).to.have.class(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('keeps mounted unchecked indicator marks hidden', () => { + render( + + Options + + + + Show hidden files + + + + + Small + + + + Large + + + + , + ); + + const checkboxIndicator = screen.getByTestId('checkbox-indicator'); + const checkboxIcon = checkboxIndicator.querySelector('[data-mui-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); + } + }} + > + + Copy + + +
+ ); + } + + const { user } = render( + + + Other menu + + Other item + + + + , + ); + + // Seed Base UI's internal previously-focused record with an unrelated + // trigger by opening and closing that menu first. + const otherTrigger = screen.getByRole('button', { name: 'Other menu' }); + await user.click(otherTrigger); + await screen.findByRole('menuitem', { name: 'Other item' }); + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(otherTrigger).toHaveFocus(); + }); + + // A detached menu has no trigger; without finalFocus, closing it restores + // focus to that stale record instead of the invoked surface. + const area = screen.getByTestId('context-area'); + fireEvent.contextMenu(area, { clientX: 100, clientY: 100 }); + await screen.findByRole('menuitem', { name: 'Copy' }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(area).toHaveFocus(); + }); + }, + ); + + it.skipIf(isJsdom())('keeps separator spacing stable while a submenu is open', async () => { + const { user } = render( + + 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; + } + + function ClickClosingTooltip(props: { + title: string; + children: React.ReactElement; + }) { + const { title, children } = props; + const [open, setOpen] = React.useState(false); + + const child = React.cloneElement(children, { + onClickCapture: (event: React.MouseEvent) => { + setOpen(false); + children.props.onClickCapture?.(event); + }, + }); + + return ( + setOpen(true)} + onClose={() => setOpen(false)} + > + {child} + + ); + } + + const { user } = render( + + Options + + + + View options + + + Comments + + + + , + ); + + const submenuTrigger = screen.getByRole('menuitem', { name: 'View options' }); + + await user.hover(submenuTrigger); + expect(await screen.findByRole('tooltip')).to.have.text('Open view settings'); + + await user.click(submenuTrigger); + + expect(await screen.findByRole('menuitem', { name: 'Comments' })).not.to.equal(null); + await waitFor(() => { + expect(screen.queryByRole('tooltip')).to.equal(null); + }); + }); + + it('supports Material UI Tooltip on disabled items through a non-disabled wrapper', async () => { + const { user } = render( + + Options + + + + Import from Drive + + + + , + ); + + expect(screen.getByRole('menuitem', { name: 'Import from Drive' })).to.have.attribute( + 'aria-disabled', + 'true', + ); + await expectTooltipOnHover( + user, + screen.getByTestId('disabled-item-tooltip-target'), + 'Unavailable while offline', + ); + }); + + it('does not warn when a submenu trigger is disabled', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const { user } = render( + + Options + + + Add-ons unavailable + + Marketplace + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Options' })); + + const submenuTrigger = await screen.findByRole('menuitem', { + name: 'Add-ons unavailable', + }); + expect(submenuTrigger).to.have.attribute('aria-disabled', 'true'); + expect( + warn.mock.calls.some(([message]) => + String(message).includes('A disabled element was detected on '), + ), + ).to.equal(false); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/mui-material/src/Unstable_Menu2/Menu2.tsx b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx new file mode 100644 index 00000000000000..40501b5bfeedba --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/Menu2.tsx @@ -0,0 +1,46 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { useDefaultProps } from '../DefaultPropsProvider'; + +/** + * Inherits the full Base UI `Menu.Root` prop surface (open/close control, + * modality, `actionsRef`, keyboard behavior); hover-open props live on the + * trigger parts. `Omit` (a mapped type) is used instead of bare `extends` so + * the proptypes generator resolves the inherited members. + */ +export interface Menu2Props extends Omit { + /** + * The content of the menu. + */ + children?: React.ReactNode; +} + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +function Menu2(props: Menu2Props): React.JSX.Element { + const themedProps = useDefaultProps({ + props, + name: 'MuiMenu2', + }); + + return ; +} + +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 content of the menu. + */ + children: PropTypes.node, +} as any; + +export default Menu2; diff --git a/packages/mui-material/src/Unstable_Menu2/RFC.md b/packages/mui-material/src/Unstable_Menu2/RFC.md new file mode 100644 index 00000000000000..4d8258ea7c8e51 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/RFC.md @@ -0,0 +1,344 @@ +# RFC draft: Menu successor with submenu support + +Living draft tracked in this PR until the RFC is posted publicly; review comments welcome on this file. + +Suggested issue title: `[RFC] Menu: Base UI-based successor with submenu support` + +Structured for `.github/ISSUE_TEMPLATE/3.rfc.yml` -- paste each section below into the matching form field. + +--- + +## What's the problem? + +Material UI's `Menu` cannot express submenus (nested menus): + +- It is one of the oldest and most-requested features: https://github.com/mui/material-ui/issues/11723 has been open since 2018 with 120+ reactions (plus duplicates such as https://github.com/mui/material-ui/issues/8152). +- Material UI v0.x supported nested menus (https://github.com/mui/material-ui/pull/2148); the capability was lost in the v1 rewrite and never recovered. +- Community workarounds (`material-ui-nested-menu-item`, `mui-nested-menu`, `material-ui-popup-state` recipes, many sandboxes) are consistently incomplete on keyboard navigation and ARIA, which maintainers have called out repeatedly in the issue threads. +- The Menubar docs page already ships Base UI-composed submenus as copy-paste code, and users immediately asked for a maintained, in-package component (https://github.com/mui/material-ui/issues/48336). Copy-paste code is not versioned, tested, or theme-integrated. + +Desired outcome: first-class, accessible submenu support in `@mui/material` -- with Material visuals and full theming -- without destabilizing the existing `Menu`, and on a path to becoming the default `Menu` in the next major. + +Beyond the Menu itself, this RFC pilots the standards for how future Material UI components are built on top of Base UI (customization contract, styling reuse, dependency shape, testing, tooling). Menu is the reference implementation; the cross-cutting decisions below are meant to apply to every Base UI-backed component that follows. + +## What are the requirements? + +1. Correct WAI-ARIA menu pattern behavior across nesting levels: trigger semantics (`aria-haspopup`/`aria-expanded`), RTL-aware ArrowRight/ArrowLeft submenu navigation, Escape close ordering, focus restored to the parent trigger item on close, typeahead scoped per level, arbitrary nesting depth. +2. Production-grade pointer UX: safe-polygon hover intent ("safe triangle") and hover-open with configurable delays -- explicitly the bar that past attempts failed to clear. +3. Collision-aware positioning with automatic anchor tracking: submenus flip at viewport edges instead of clipping. +4. Pixel parity with the existing `Menu`/`MenuItem` visuals, and full theming integration: `sx`, `classes`, `component`, `slots`/`slotProps`, theme `defaultProps`/`styleOverrides`/`variants`. +5. Zero cost and zero risk for existing users: the classic `Menu` keeps working unchanged, and apps that do not import the new component pay no bundle or behavior cost. +6. API continuity with the classic `Menu` where the underlying model allows (item-level props, `container`, `keepMounted`), with deliberate and documented divergence where it does not (open/close control, positioning, transitions). +7. Cover the adjacent long-requested menu capabilities in the same API so it does not need reshaping later: checkbox/radio items, groups, hover-open menus, context-menu (cursor) positioning. +8. A credible graduation path: the component becomes `Menu` in the next major with a migration guide and codemods where feasible, so preview adopters are not stranded. +9. Sustainable maintenance: reuse a maintained primitive rather than re-implementing focus, dismissal, and positioning machinery in this repo. +10. Cross-cutting indistinguishability: the component must look indistinguishable from a normal Material UI component in tooling, theming, imports, and tests. The only new thing is the Base UI behavior substrate underneath. Users should not need to know Base UI is involved, nor install anything extra. + +## What are our options? + +### Option A: Add submenus to the existing Menu stack + +This has been attempted three times over eight years; each attempt got further than the last and hit the same walls: + +- https://github.com/mui/material-ui/pull/14700 (2019, +267 lines): recursive Menu-in-Menu. Closed with "It's something we will want to solve at the core level. I'm pretty sure we need to change the menu implementation and to expose new objects to make it happen." +- https://github.com/mui/material-ui/pull/20591 (2020-2022, +1333 lines, ~22 months of review): `subMenu` prop on `MenuItem` implemented inside core via `cloneElement`. Stalled on a compound of blockers: a double-digit relative gzip increase to the core bundle, hard UX requirements (safe-triangle hover intent, collision-aware placement -- blocked on Popover internals), test-infrastructure churn, and the risk of destabilizing a core component right before v5. The closing review rejected the `cloneElement` approach and concluded the path forward was to rebuild on headless menu primitives. +- https://github.com/mui/material-ui/pull/37570 (2023-2024): docs-demo-only approach. Closed after an accessibility review found fundamental gaps (Escape handling, `aria-expanded`, screen reader announcements, close ordering), with the explicit direction: "I think it would make more sense to focus on bringing this to Base UI." + +The failures are structural, not incidental. Every open `Menu` is a full `Modal` (`Menu -> Popover -> Modal -> FocusTrap/Backdrop/ModalManager`), and nesting two of them fights the stack in at least six places: + +1. Dismissal model: each menu renders an invisible full-screen backdrop that captures clicks. A submenu's backdrop stacks above the parent's paper, making parent items non-interactive and closing the child on any parent click. Fixing this means replacing backdrop dismissal with a coordinated click-away model across the whole menu tree. +2. `ModalManager` sets `aria-hidden="true"` on all body children except the top-most modal -- opening a submenu removes the parent menu from the accessibility tree. +3. Keyboard: the roving tabindex handler treats ArrowRight/ArrowLeft as no-ops for vertical lists, and `MenuList`/`MenuItem` expose no hook point for "open submenu on ArrowRight" or trigger semantics. +4. Focus: each modal has its own focus trap and per-trap restore target; closing a submenu must restore focus to the parent trigger item, which the per-trap model does not coordinate. +5. Positioning: `Popover` has no collision flipping -- a right-opening submenu near the viewport edge clips instead of flipping to the other side. +6. Each `MenuList` owns an isolated keyboard/typeahead registry; nested lists share no active-item model. + +Meeting requirements 1-3 this way means touching `Menu`, `MenuList`, `MenuItem`, `Popover`, `Modal`, `ModalManager`, and `FocusTrap`, and replacing two load-bearing models (backdrop dismissal, per-modal focus trapping) shared with `Dialog` and every `Popover` consumer. That is a re-implementation of exactly the machinery Base UI's `Menu` already ships, with disproportionate regression risk, as throwaway work ahead of the next major. Rejected. + +### Option B: Keep it as copy-paste docs composition (like the Menubar page) + +Rejected as the end state: unversioned and untested code with no theming contract cannot be the first-class answer to an 8-year-old feature request, and users have already asked for the packaged component (https://github.com/mui/material-ui/issues/48336). + +### Option C: Wait for the next major rewrite + +Rejected: demand has waited since 2018, and shipping a public unstable component now battle-tests the API so the next major's `Menu` lands already validated instead of freshly designed. + +### Option D: Successor component built on Base UI, shipped as public unstable (proposed) + +Base UI's `Menu` (`@base-ui/react`, stable 1.x since early 2026, maintained by the same organization) covers requirements 1-3 out of the box, verified against its source and test suites: safe-polygon hover intent on submenu triggers (`openOnHover` default `true`, `delay` 100ms, `safePolygon` close handler), RTL-aware submenu keyboard navigation (parametrized ltr/rtl open/close key tests), Escape closing the innermost submenu by default (`closeParentOnEsc`, default `false`), focus returned to the parent trigger item on close (asserted in tests), per-level typeahead, and collision avoidance defaulting to flip with automatic anchor tracking. Material UI's job reduces to styling, theming, and API surface -- detailed below. + +## Proposed solution + +Introduce a Base UI-based successor to `Menu`, positioned as "Menu v2" and following the Grid lifecycle precedent. A proof of concept validates feasibility: https://github.com/mui/material-ui/pull/48663 (live demo: https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/), and a companion playground exercises the open questions: https://github.com/mui/material-ui/pull/48823. + +### Positioning and lifecycle (decided) + +The new component is a successor, not an in-place reimplementation of the legacy internals and not a permanently parallel namespace: + +| Phase | Component name | What happens | +| --------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Now (v9 minors) | `Unstable_Menu2` | Public incubation -- a real release, not docs-only. Theme keys and classes are `MuiMenu2*` (see the naming note below). | +| Later in v9 | `Menu2` | Stabilized under the interim name; users adopt. Legacy `Menu` untouched; theme keys unchanged. | +| Next major | `Menu` | `Menu2` promoted to the canonical name. | +| Next major | `MenuLegacy` | Old `Menu` renamed, deprecated; codemod provided. | + +Precedent: Grid (`Unstable_Grid2` -> `Grid2` -> `Grid`, with the old component renamed `GridLegacy`, see https://github.com/mui/material-ui/pull/45363). The rename at each step is a breaking change for early adopters, but it is codemoddable and Material UI has accepted this trade before. The `2`-suffixed interim name is what makes a stable pre-major phase possible at all: an unsuffixed name would collide with the still-shipping legacy `Menu` until the major. + +Naming note: only directories, subpaths, and export names carry the `Unstable_` prefix. Internal identifiers use clean `Menu2*` names and theme keys/classes use `MuiMenu2*` -- both enforced by the repo's naming-convention and name-matches-component lint rules, and both matching the Grid2 precedent (`Unstable_Grid2` used `MuiGrid2` keys). A side benefit: theme keys and classes written against the unstable component survive the `Unstable_Menu2` -> `Menu2` stabilization unchanged; only the final `Menu2` -> `Menu` promotion renames them (codemod). + +Import ergonomics follow the existing convention -- flat-named components, one component per subpath, no Base UI-style short aliases (`Root`/`Item`/`Trigger`): + +```jsx +import Menu2 from '@mui/material/Unstable_Menu2'; +import Menu2Item from '@mui/material/Unstable_Menu2Item'; +``` + +Since the subpaths use default exports, adopters can locally drop the `Unstable_` prefix, as above -- the JSX then already reads like the future stable API. Root barrel exports are added at graduation (the experiment is deliberately subpath-only). + +Graduation is gated by a fixed checklist, not judgment calls: the legacy `describeConformance` suite passing minus a documented skip list, theme-registration parity, the pinned `data-*` conformance boundary (see dependency riders below), and design/API sign-off. + +### Standards for Base UI-backed components (Menu is the pilot) + +Meta-principle: a Base UI-backed component must be indistinguishable from a normal Material UI component in every cross-cutting concern. Decided: + +- Customization contract: `slots`/`slotProps` is the user-facing mechanism, as in every existing component. Base UI's `render` prop is used internally by Material UI to inject its styled elements (for example the trigger rendering a Material `Button`), and remains a last-resort escape hatch -- not the documented contract. +- Slot plumbing: reuse the `@mui/utils` slot utilities (`useSlotProps`/`mergeSlotProps`/`appendOwnerState`); extend them in place if genuine Base UI `render`-bridging gaps appear. No bespoke per-component bridge layers. +- Styling reuse with the legacy component: share the actual styled element by passing it through Base UI's `render` internally (the model used by the Menubar docs components), falling back to a shared style function where element injection is impractical. The experiment already implements the fallback: classic `Menu`/`MenuItem` and the new parts consume the same extracted style modules, so there is a single source of visual truth. The remaining work is upgrading to element-level sharing where it fits; free-floating copies are not acceptable. Caution learned from two shipped regressions: extracted classic styles can embed DOM-context assumptions that silently change meaning under the Base UI structure -- a `maxHeight: calc(100% - 96px)` whose `100%` meant the viewport inside the classic full-screen Modal but resolved against the content-sized popup, and an `[item] + divider` adjacency margin that broke when Base UI mounted inline focus-guard nodes next to an open submenu trigger. Shared style functions need a per-consumer audit for positional values and structural selectors, and parts should own their own spacing instead of relying on sibling combinators. +- Material presentational props are preserved (`dense`, `disableGutters`, `divider`, `inset`, `selected`): Material UI owns presentation, Base UI owns behavior. The line is styling-vs-functionality, not old-vs-new; individual long-tail props can still be dropped case-by-case. +- Dependency shape: `@base-ui/react` becomes a direct dependency of `@mui/material` (caret range), like `@popperjs/core` -- an implementation detail users never install or import directly. Two riders: (a) Base UI version bumps are deliberate, reviewed events, never auto-merged; (b) conformance tests pin the `data-*` attribute surface we consume, so an upstream rename or removal fails CI instead of silently regressing styles. +- Docs and API tooling: existing infrastructure is a fixed constraint; the component conforms to it. If a component cannot be documented without tooling changes, that is a signal about the component. +- Theme registration: standard `defaultProps`/`styleOverrides`/`variants` registration per part, under standard keys (`MuiMenu2*`, see the naming note above) -- no special rule for Base UI parts. + +Proposed, awaiting team reaction: + +- Styling state source of truth (hybrid): `Mui-*` classes + `ownerState` remain the public contract that `styleOverrides`/`variants`/`sx` are written against; internal styles may read Base UI `data-*` attributes for positional or transient state (precedent: Tooltip's `[data-popper-placement]` selectors). Rule of thumb: state users theme or that appears in the documented API gets a class; purely positional/transient internal state stays `data-*`-only. The experiment already exercises the internal side of this rule: the popup surface consumes the positioner-provided `--available-height` variable for its collision-aware max-height. +- Prop surface typing: `extends` the Base UI prop types with `Omit` for curated or renamed props -- inheritance by default (no drift as Base UI evolves), curation as an explicit, documented list. Consequence: callback signatures follow Base UI by default, for example `onOpenChange(open, eventDetails)` rather than the legacy event-first `onClose(event, reason)`. Validated in the experiment (renderless roots, flattened popup): the pattern type-checks, the spec's negative assertions hold, and the flattened container inherits its hoisted positioner/portal surface via `Pick`. One confirmed limitation: the proptypes generator does not expand members declared in `node_modules`, so runtime PropTypes on inherited props degrade to the locally declared ones (types still carry the full contract; `remove-proptypes` strips them in production anyway). Teaching the generator to expand external heritage is a shared-infra follow-up; until then the trade is inherited types + reduced dev-mode runtime validation. +- Testing: reuse the existing harnesses on two fronts -- `describeConformance` for the Material UI contract (ref, className, `sx`, theme `styleOverrides`), and the legacy Menu behavior suite (keyboard navigation, open/close, focus) rerun against the successor. Every skip is annotated with why it is incompatible under the Base UI model. Parity is proven by the same tests passing, not by new bespoke tests. + +### API shape (pending a dedicated design phase) + +The agreed rules for the shape, replacing a global flat-vs-compound choice with a per-part split: + +- Keep the public API as close to the legacy `Menu` as the substrate allows; users should not need to know Base UI is involved. +- Structural/plumbing parts (Portal, Positioner, Popup, Paper, List) are bundled into a flat container component and exposed via `slots`/`slotProps` -- they exist for wiring, not day-to-day composition. +- Customization-heavy parts stay standalone components (`MenuItem`-like parts, submenu triggers, checkbox/radio items) -- they need per-instance children and props and cannot be buried in a container. + +Hard precondition before the design is finalized: a behavior benchmark diffing the Material UI Menu against the Base UI Menu from the user's perspective (open/close semantics, focus behavior, keyboard model, dismissal, positioning defaults). The benchmark gates the design -- how close to drop-in the successor API can be -- not the positioning, which is decided above. + +Illustrative sketch only (the container boundaries and the submenu shape are exactly what the design phase must settle; the experiment's fully compound API is the reference input at the other end of the spectrum): + +```jsx + + Cut + {/* strawman: submenu as a nested container owned by a standalone trigger item */} + + + Email + Copy link + + + +``` + +Behavior notes worth stating explicitly regardless of final shape: + +- Submenus open on hover by default in Base UI (`openOnHover` default `true`, `delay` 100ms, safe-polygon close). This is new behavior the classic Menu never had; it matches native OS menus, and it is configurable. +- Escape closes the innermost submenu and returns focus to its trigger item (APG behavior); closing the whole tree is opt-in. +- While a submenu is open, inline focus-guard nodes sit next to its trigger inside the parent popup; see open question 7 for the styling-contract implications. +- The menu surface constrains itself to `min(calc(100vh - 96px), var(--available-height))` and scrolls internally -- the classic viewport-only clamp replaced by a collision-aware one. + +### Compatibility posture + +Continuity where it matters, honesty where it does not: + +- Kept as-is: item-level presentational props (`dense`, `disableGutters`, `divider`, `inset`, `selected` -- visual-only, as today), `disabled`, visual design, theming entry points, `keepMounted`, portal `container`. +- Changed deliberately (Base UI model replaces the old one): open/close control (`open`/`defaultOpen` + `onOpenChange(open, eventDetails)` instead of controlled-only `open` + `onClose(event, reason)`), positioning (`anchor`/`side`/`align`/offsets instead of `anchorEl`/`anchorOrigin`/`transformOrigin`), transitions (CSS `data-starting-style`/`data-ending-style` + `onOpenChangeComplete` instead of `TransitionComponent`/`Grow`). +- Dropped intentionally: + - `disableAutoFocus`, `disableEnforceFocus`, `disableRestoreFocus`, `disableEscapeKeyDown`: escape hatches that degrade accessibility; `modal` and `finalFocus` cover the legitimate cases. + - `variant="selectedMenu"`, `autoFocus`, `disableAutoFocusItem`: listbox-style selection behavior on `role="menu"`; initial focus is handled internally per the WAI-ARIA menu pattern. + - `anchorOrigin`/`transformOrigin`/`anchorReference`/`anchorPosition`, `PopoverClasses`, `transitionDuration`, `slots.transition`, `action.updatePosition`: superseded by Base UI's Floating-UI-based positioning with automatic anchor tracking and collision handling. + - `disablePortal`: Base UI popups are always portalled. + +A full old-to-new prop mapping is in the collapsible appendix at the end. + +### New capabilities (vs classic Menu) + +- Submenus with correct keyboard, hover-intent, and ARIA behavior. +- Checkbox and radio items (`role="menuitemcheckbox"`/`menuitemradio"` with `aria-checked` and indicators). +- Groups with automatically associated labels (`role="group"` + `aria-labelledby`). +- Trigger wiring for `aria-haspopup`/`aria-expanded`/`aria-controls` out of the box. +- Typeahead with per-item `label` override. +- Hover-open with configurable delays; reason-rich, cancelable `onOpenChange`. + +### Reference implementation and known deltas + +The PoC (https://github.com/mui/material-ui/pull/48663) proves feasibility end-to-end: submenus, checkbox/radio items, groups, pixel parity with classic `MenuItem`, full theme registration, and a comprehensive test suite -- at +77 B gzip on the `@mui/material` barrel (Base UI code is only paid when importing the component). The companion experiment (https://github.com/mui/material-ui/pull/48823) iterates on it toward the standards above. + +Resolved in the experiment branch: + +- `MenuPreview` naming -> `Unstable_Menu2` lifecycle naming, per-part subpaths, Base UI-style short aliases dropped. +- Docs tooling special-casing -> removed; the experiment is exercised via a non-public playground page instead of generated API docs. +- Style sharing: classic and successor consume the same extracted style modules (single source of visual truth). +- Prop surfaces on the renderless roots and the flattened popup inherit Base UI types via `Omit`/`Pick` (the item parts already followed the pattern); the roots gain `actionsRef` and future Base UI props for free (hover-open with delays lives on the trigger parts, already exposed). +- Top-level `elevation` convenience prop exists on the popup (default 8, forwards to the Paper slot). + +Remaining: + +- Fully compound API -> per-part flat/standalone split per the design phase. +- Style sharing is at the shared-style-function level (option 1) -> upgrade to shared styled elements via internal `render` where practical. +- Bespoke slot-bridging layer -> `@mui/utils` slot utilities. +- Bespoke test suite -> `describeConformance` + the legacy Menu behavior suite rerun with annotated skips (piloted on the item and popup; remaining parts and the legacy rerun pending). +- `inset` item prop not yet implemented (decided as preserved). +- No default open/close animation and no ripple -> open questions below. + +### Open questions + +1. Ripple. Items are Base UI divs, so there is no `TouchRipple`. Either add `TouchRipple` to the item root slot for Material fidelity (restoring `disableRipple` and friends) or formally drop ripple on menu items. No accessibility stake; this is a design-identity decision. +2. Default open/close animation. Classic Menu animates with `Grow` by default; the experiment ships none by default but demonstrates a CSS approximation of `Grow` via `data-starting-style`/`data-ending-style` behind a toggle. Proposal: ship a default CSS transition so the component does not feel like a visual regression, overridable via plain CSS. +3. Convenience `elevation` prop on the container: implemented in the experiment (default 8, forwards to the Paper slot) -- confirm we keep it. +4. Backdrop. Base UI has `Menu.Backdrop`; the experiment does not surface it. Expose a `backdrop` slot now or wait for demand? +5. Imperative actions. Under the typing standard, Base UI's `actionsRef` (`close()`, `unmount()`) is inherited by default -- the question is whether to curate it away, not whether to add it. +6. Context menu (right-click / cursor positioning). Classic `anchorPosition` use cases are covered by a virtual-element `anchor` recipe in the experiment. Base UI also has a dedicated `ContextMenu` component. Recipe now, dedicated component later? The recipe surfaced a focus-restore hole that sharpens this question: a detached menu has no trigger to restore focus to, and on close Base UI falls back to an internal previously-focused-element record -- which can be a stale, unrelated menu trigger from an earlier interaction on the same page. The recipe must pass `finalFocus` (the invoked surface, per the APG context-menu pattern) to behave correctly; a wrapped `ContextMenu` component would remove that footgun entirely, since its trigger is the right-click surface itself. +7. Sibling-structure styling contract around submenu triggers. While a submenu is open, Base UI keeps inline focus-guard and portal-anchor nodes next to the trigger inside the parent popup -- tab order into the portalled submenu depends on them. Any consumer CSS built on sibling combinators around a trigger (`+`/`~`, `:last-child`-style assumptions) silently breaks the moment a submenu opens: this is the first place a Base UI implementation detail reaches the consumer styling contract, and it is invisible until runtime (the experiment shipped exactly this bug -- the separator following a trigger lost its adjacency-based margins). The built-in parts now avoid it by owning their own spacing. Options: (a) accept and document the constraint as part of the styling contract ("do not style through sibling combinators around triggers"; the guard nodes are identifiable via `data-base-ui-focus-guard` for consumers who must), (b) raise upstream whether guard placement could avoid interleaving trigger siblings (for example positioning the guards at the popup boundary), (c) both. Proposal: (c) -- document now, pursue upstream hardening. +8. Bundle-size governance. Base UI adds real weight per component family; do we add a size-snapshot gate per Base UI-backed component? +9. Residual accessibility obligation. Base UI owns the interaction a11y; what does Material UI still verify on top (an axe pass in conformance, screen-reader smoke tests, contrast of the styled surfaces)? +10. Behavior defaults divergence: do we ratify Base UI's defaults where they differ from the classic Menu (hover-open submenus, non-modal scroll behavior), or re-tune them for continuity? To be fed by the behavior benchmark. +11. SSR, `'use client'` boundaries, and ref typing are expected to follow the existing patterns with no divergence -- to confirm during implementation, not expected to be contentious. + +### Rollout plan + +1. Behavior benchmark (hard precondition): diff Material UI Menu vs Base UI Menu from the user's perspective; publish the results in this RFC to set how close to drop-in the API can be. Include the structure-sensitive cases the experiment surfaced: parent-menu layout stability while submenus open and close, and height-constrained menus near the viewport edge. +2. Design phase for the API shape under the rules above (container boundaries, submenu shape), validated in the companion experiment -- each open question resolved against a deploy preview rather than in the abstract. +3. Land `Unstable_Menu2` in a v9 minor: conformance + legacy behavior suites with annotated skips, API reference docs, and a docs section on the Menu page (submenu, checkbox/radio, context-menu demos). +4. Iterate on feedback; stabilize as `Menu2` once the graduation checklist passes (conformance minus documented skips, theme-registration parity, pinned `data-*` boundary, design sign-off). +5. Next major: promote `Menu2` to `Menu`, rename legacy to `MenuLegacy` (deprecated), ship the migration guide and codemods for the renames and the mechanical parts of the mapping below (Grid precedent). + +### Appendix: full prop mapping (classic Menu -> successor) + +
+1. Open / close and control + +| Classic Menu | New equivalent | Notes | +| ---------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `open` (required, controlled-only) | `open` + `defaultOpen` | uncontrolled becomes possible | +| `onClose(event, reason)` | `onOpenChange(open, eventDetails)` | signature inherits Base UI types per the typing standard; reasons include `escape-key`, `outside-press`, `focus-out`, `trigger-press`, `item-press`; supports `cancel()` and exposes the native event | +| n/a | `onOpenChangeComplete(open)` | replaces `onTransitionExited`-style hooks | + +
+ +
+2. Positioning + +| Classic Menu / Popover | New equivalent | Notes | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------- | +| `anchorEl` | `anchor` | also accepts refs and virtual elements | +| `anchorOrigin` + `transformOrigin` | `side` + `align` + `sideOffset` + `alignOffset` | finer control | +| `anchorReference="anchorPosition"` + `anchorPosition` | `anchor={virtualElement}` | see open question 6 | +| `marginThreshold` (default 16) | `collisionPadding` (default 5) | equivalent concept | +| `anchorReference="none"` | omit `anchor`, position via CSS | equivalent | +| `action.updatePosition()` | automatic anchor tracking | `disableAnchorTracking` to opt out | +| -- | `collisionBoundary`, `sticky`, `collisionAvoidance`, `positionMethod`, `arrowPadding` | new capabilities | + +
+ +
+3. Focus and modality + +| Classic Menu | New equivalent | Notes | +| ----------------------------------------- | --------------------- | ------------------------------------------------------------- | +| `autoFocus`, `disableAutoFocusItem` | internal | per WAI-ARIA menu pattern | +| `variant` (`menu`/`selectedMenu`) | dropped | selection state is invalid on menu items | +| `disableAutoFocus`, `disableEnforceFocus` | dropped | `modal` prop covers modality | +| `disableRestoreFocus` | `finalFocus` | explicit focus target on close | +| `disableEscapeKeyDown` | dropped | contradicts the menu pattern; use `onKeyDown` if truly needed | +| `disableScrollLock` | `modal` | non-modal menus do not lock scroll | +| `hideBackdrop` | partially via `modal` | see open question 4 | +| `disablePortal` | dropped | always portalled | +| `keepMounted`, `container` | same | identical semantics | + +
+ +
+4. Transitions + +| Classic Menu | New equivalent | +| ------------------------------------------------------------------- | --------------------------------------------------- | +| `TransitionComponent` / `slots.transition` (default `Grow`) | CSS via `data-starting-style` / `data-ending-style` | +| `transitionDuration` | CSS `transition-duration` on the popup | +| `onTransitionEnter` / `onTransitionExited` / `closeAfterTransition` | `onOpenChangeComplete` + `keepMounted` | +| default `Grow` animation | open question 2 (proposal: default CSS transition) | + +
+ +
+5. Styling / slots + +| Classic Menu | New equivalent | Notes | +| ------------------------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------- | +| `slots`: `root`, `paper`, `list`, `transition`, `backdrop` | `portal`, `positioner`, `popup`, `paper`, `list` | no transition slot (CSS-based) | +| `elevation` (default 8) | `elevation` (default 8, forwards to the Paper slot) | implemented; see open question 3 | +| paper `maxHeight: calc(100% - 96px)` (viewport clamp via the Modal) | `min(calc(100vh - 96px), var(--available-height))` + internal scroll | collision-aware | +| `slots.backdrop` + `BackdropProps` | not surfaced | see open question 4 | +| `PopoverClasses` | n/a | no Popover underneath | + +
+ +
+6. Item-level props + +| Classic MenuItem / MenuList | New equivalent | Notes | +| ------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dense`, `disableGutters`, `divider`, `inset` | same | preserved (decided): Material UI owns presentation; `inset` pending in the experiment | +| `` between items | `Separator` part | owns its 8px margins (stable while submenus are open); classic spacing relied on item-adjacency selectors | +| `selected` | same (visual-only, as today) | preserved (decided); checkbox/radio items cover real selection semantics. Note: classic `MenuItem` now derives `aria-checked` from `selected` for `menuitemcheckbox`/`menuitemradio` roles (https://github.com/mui/material-ui/pull/48651) -- the successor's dedicated checkbox/radio items own this instead | +| `disabled` | same | `aria-disabled`, item stays focusable | +| `href` / `LinkComponent` | link item variant | real `` | +| `autoFocus` (item) | dropped | initial focus is internal | +| ripple props | none currently | see open question 1 | +| `focusVisibleClassName`, `onFocusVisible`, `action.focusVisible()` | `highlighted` state class / data attributes | style via CSS | +| `MenuList.disableListWrap` | `loopFocus` (default true) | inverse | +| `MenuList.autoFocus`/`autoFocusItem`/`variant` | dropped | internal / legacy | +| `MenuList.disablePadding`, `subheader` | `slotProps.list`, group + group label parts | groups get proper ARIA association | + +
+ +## Resources and benchmarks + +Proof of concept and experiment: + +- PoC PR: https://github.com/mui/material-ui/pull/48663 (live demo: https://deploy-preview-48663--material-ui.netlify.app/experiments/menu-preview/) +- RFC companion playground (use case demos + knobs for the open questions): https://github.com/mui/material-ui/pull/48823 +- Measured bundle impact of the PoC on `@mui/material`: +160 B parsed / +77 B gzip (~0.03%) -- the style-extraction refactor only; Base UI code is paid only when importing the new component. + +Demand: + +- https://github.com/mui/material-ui/issues/11723 (canonical request, open since 2018, 120+ reactions) +- https://github.com/mui/material-ui/issues/8152 (closed as duplicate) +- https://github.com/mui/material-ui/issues/48336 (packaged Menubar/submenu component request) +- https://github.com/mui/material-ui/issues/45790 (nested menu docs demo request) + +Prior attempts on the legacy stack: + +- https://github.com/mui/material-ui/pull/14700 (2019, closed) +- https://github.com/mui/material-ui/pull/20591 (2020-2022, closed) +- https://github.com/mui/material-ui/pull/37570 (2023-2024, closed) +- v0.x nested menu support: https://github.com/mui/material-ui/pull/2148, https://github.com/mui/material-ui/pull/3265 + +Direction and precedent: + +- Maintainer statement (Dec 2024): https://github.com/mui/material-ui/issues/11723#issuecomment-2556390056 -- "Material UI will adopt (this new) Base UI component in its next major release." +- Grid lifecycle precedent (legacy rename): https://github.com/mui/material-ui/pull/45363 +- Menubar docs page composed from Base UI with submenus: https://mui.com/material-ui/react-menubar/ (added in https://github.com/mui/material-ui/pull/47616) +- Base UI Menu (submenu anatomy, keyboard model): https://base-ui.com/react/components/menu +- Base UI releases (1.x stable): https://base-ui.com/react/overview/releases + +Community workarounds discussed in the threads: + +- https://github.com/azmenak/material-ui-nested-menu-item (and its successor `mui-nested-menu`) +- https://jcoreio.github.io/material-ui-popup-state/ +- https://www.npmjs.com/package/better-mui-menu diff --git a/packages/mui-material/src/Unstable_Menu2/index.d.ts b/packages/mui-material/src/Unstable_Menu2/index.d.ts new file mode 100644 index 00000000000000..23dd0c131828d4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/index.d.ts @@ -0,0 +1,3 @@ +export { default } from './Menu2'; +export { default as Unstable_Menu2 } from './Menu2'; +export * from './Menu2'; diff --git a/packages/mui-material/src/Unstable_Menu2/index.js b/packages/mui-material/src/Unstable_Menu2/index.js new file mode 100644 index 00000000000000..23dd0c131828d4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2'; +export { default as Unstable_Menu2 } from './Menu2'; +export * from './Menu2'; diff --git a/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts new file mode 100644 index 00000000000000..8c2ee021bb0e00 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2Classes.ts @@ -0,0 +1,273 @@ +import generateUtilityClass from '@mui/utils/generateUtilityClass'; +import generateUtilityClasses from '@mui/utils/generateUtilityClasses'; + +export interface Menu2TriggerClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if the menu is open. */ + open: string; +} + +export type Menu2TriggerClassKey = keyof Menu2TriggerClasses; + +export function getMenu2TriggerUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Trigger', slot); +} + +export const menu2TriggerClasses: Menu2TriggerClasses = generateUtilityClasses('MuiMenu2Trigger', [ + 'root', + 'disabled', + 'open', +]); + +export interface Menu2PopupClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the Material Paper element. */ + paper: string; + /** Styles applied to the Material List element. */ + list: string; +} + +export type Menu2PopupClassKey = keyof Menu2PopupClasses; + +export function getMenu2PopupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Popup', slot); +} + +export const menu2PopupClasses: Menu2PopupClasses = generateUtilityClasses('MuiMenu2Popup', [ + 'root', + 'paper', + 'list', +]); + +export interface Menu2SubmenuPopupClasses { + /** Styles applied to the root element. */ + root: string; + /** Styles applied to the Material Paper element. */ + paper: string; + /** Styles applied to the Material List element. */ + list: string; +} + +export type Menu2SubmenuPopupClassKey = keyof Menu2SubmenuPopupClasses; + +export function getMenu2SubmenuPopupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2SubmenuPopup', slot); +} + +export const menu2SubmenuPopupClasses: Menu2SubmenuPopupClasses = generateUtilityClasses( + 'MuiMenu2SubmenuPopup', + ['root', 'paper', 'list'], +); + +export interface Menu2ItemClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** Styles applied to the root element if `dense={true}`. */ + dense: string; + /** Styles applied to the root element if `divider={true}`. */ + divider: string; + /** Styles applied to the root element unless `disableGutters={true}`. */ + gutters: string; + /** State class applied to the root element if `selected={true}`. */ + selected: string; +} + +export type Menu2ItemClassKey = keyof Menu2ItemClasses; + +export function getMenu2ItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Item', slot); +} + +export const menu2ItemClasses: Menu2ItemClasses = generateUtilityClasses('MuiMenu2Item', [ + 'root', + 'highlighted', + 'disabled', + 'dense', + 'divider', + 'gutters', + 'selected', +]); + +export interface Menu2LinkItemClasses extends Menu2ItemClasses {} + +export type Menu2LinkItemClassKey = keyof Menu2LinkItemClasses; + +export function getMenu2LinkItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2LinkItem', slot); +} + +export const menu2LinkItemClasses: Menu2LinkItemClasses = generateUtilityClasses( + 'MuiMenu2LinkItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected'], +); + +export interface Menu2CheckboxItemClasses extends Menu2ItemClasses { + /** State class applied to the root element if `checked={true}`. */ + checked: string; +} + +export type Menu2CheckboxItemClassKey = keyof Menu2CheckboxItemClasses; + +export function getMenu2CheckboxItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2CheckboxItem', slot); +} + +export const menu2CheckboxItemClasses: Menu2CheckboxItemClasses = generateUtilityClasses( + 'MuiMenu2CheckboxItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'checked'], +); + +export interface Menu2CheckboxItemIndicatorClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; +} + +export type Menu2CheckboxItemIndicatorClassKey = keyof Menu2CheckboxItemIndicatorClasses; + +export function getMenu2CheckboxItemIndicatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2CheckboxItemIndicator', slot); +} + +export const menu2CheckboxItemIndicatorClasses: Menu2CheckboxItemIndicatorClasses = + generateUtilityClasses('MuiMenu2CheckboxItemIndicator', [ + 'root', + 'checked', + 'disabled', + 'highlighted', + ]); + +export interface Menu2RadioGroupClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; +} + +export type Menu2RadioGroupClassKey = keyof Menu2RadioGroupClasses; + +export function getMenu2RadioGroupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2RadioGroup', slot); +} + +export const menu2RadioGroupClasses: Menu2RadioGroupClasses = generateUtilityClasses( + 'MuiMenu2RadioGroup', + ['root', 'disabled'], +); + +export interface Menu2RadioItemClasses extends Menu2ItemClasses { + /** State class applied to the root element if `checked={true}`. */ + checked: string; +} + +export type Menu2RadioItemClassKey = keyof Menu2RadioItemClasses; + +export function getMenu2RadioItemUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2RadioItem', slot); +} + +export const menu2RadioItemClasses: Menu2RadioItemClasses = generateUtilityClasses( + 'MuiMenu2RadioItem', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'checked'], +); + +export interface Menu2RadioItemIndicatorClasses { + /** Styles applied to the root element. */ + root: string; + /** State class applied to the root element if `checked={true}`. */ + checked: string; + /** State class applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if highlighted. */ + highlighted: string; +} + +export type Menu2RadioItemIndicatorClassKey = keyof Menu2RadioItemIndicatorClasses; + +export function getMenu2RadioItemIndicatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2RadioItemIndicator', slot); +} + +export const menu2RadioItemIndicatorClasses: Menu2RadioItemIndicatorClasses = + generateUtilityClasses('MuiMenu2RadioItemIndicator', [ + 'root', + 'checked', + 'disabled', + 'highlighted', + ]); + +export interface Menu2GroupClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type Menu2GroupClassKey = keyof Menu2GroupClasses; + +export function getMenu2GroupUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Group', slot); +} + +export const menu2GroupClasses: Menu2GroupClasses = generateUtilityClasses('MuiMenu2Group', [ + 'root', +]); + +export interface Menu2GroupLabelClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type Menu2GroupLabelClassKey = keyof Menu2GroupLabelClasses; + +export function getMenu2GroupLabelUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2GroupLabel', slot); +} + +export const menu2GroupLabelClasses: Menu2GroupLabelClasses = generateUtilityClasses( + 'MuiMenu2GroupLabel', + ['root'], +); + +export interface Menu2SeparatorClasses { + /** Styles applied to the root element. */ + root: string; +} + +export type Menu2SeparatorClassKey = keyof Menu2SeparatorClasses; + +export function getMenu2SeparatorUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2Separator', slot); +} + +export const menu2SeparatorClasses: Menu2SeparatorClasses = generateUtilityClasses( + 'MuiMenu2Separator', + ['root'], +); + +export interface Menu2SubmenuTriggerClasses extends Menu2ItemClasses { + /** State class applied to the root element if the submenu is open. */ + open: string; +} + +export type Menu2SubmenuTriggerClassKey = keyof Menu2SubmenuTriggerClasses; + +export function getMenu2SubmenuTriggerUtilityClass(slot: string): string { + return generateUtilityClass('MuiMenu2SubmenuTrigger', slot); +} + +export const menu2SubmenuTriggerClasses: Menu2SubmenuTriggerClasses = generateUtilityClasses( + 'MuiMenu2SubmenuTrigger', + ['root', 'highlighted', 'disabled', 'dense', 'divider', 'gutters', 'selected', 'open'], +); diff --git a/packages/mui-material/src/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..e1b5794fb39315 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2PopupShared.tsx @@ -0,0 +1,334 @@ +'use client'; +import * as React from 'react'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import appendOwnerState from '@mui/utils/appendOwnerState'; +import isHostComponent from '@mui/utils/isHostComponent'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { PaperProps } from '../Paper'; +import { ListProps } from '../List'; +import { resolveSlotProps, SlotProps } from './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 portalHostOmittedProps = ['container', 'keepMounted'] as const; +const positionerHostOmittedProps = [ + 'align', + 'alignOffset', + 'anchor', + 'arrowPadding', + 'collisionAvoidance', + 'collisionBoundary', + 'collisionPadding', + 'disableAnchorTracking', + 'positionMethod', + 'render', + 'side', + 'sideOffset', + 'sticky', +] as const; +const paperHostOmittedProps = [ + 'classes', + 'component', + 'elevation', + 'square', + 'sx', + 'variant', +] as const; +const listHostOmittedProps = [ + 'classes', + 'component', + 'dense', + 'disablePadding', + 'subheader', + 'sx', +] as const; + +export interface Menu2PopupSharedSlots { + /** + * 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 Menu2PopupSharedSlotProps { + portal?: 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; + }; + 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; + + const PortalSlot = slots?.portal ?? BaseMenu.Portal; + const PositionerSlot = slots?.positioner ?? BaseMenu.Positioner; + const PopupSlot = slots?.popup ?? defaultSlots.popup; + const PaperSlot = slots?.paper ?? defaultSlots.paper; + const ListSlot = slots?.list ?? defaultSlots.list; + + const resolvedPortalProps = resolveSlotProps(slotProps?.portal, ownerState); + const resolvedPositionerProps = resolveSlotProps(slotProps?.positioner, ownerState); + const resolvedPopupProps = resolveSlotProps(slotProps?.popup, ownerState); + const resolvedPaperProps = resolveSlotProps(slotProps?.paper, ownerState); + const resolvedListProps = resolveSlotProps(slotProps?.list, ownerState); + const { className: resolvedPopupClassName, ...resolvedPopupOtherProps } = + resolvedPopupProps ?? {}; + const positionerProps = { + ...defaultPositionerProps, + }; + + setDefinedProp(positionerProps, 'anchor', anchor); + setDefinedProp(positionerProps, 'positionMethod', positionMethod); + setDefinedProp(positionerProps, 'side', side); + setDefinedProp(positionerProps, 'sideOffset', sideOffset); + setDefinedProp(positionerProps, 'align', align); + setDefinedProp(positionerProps, 'alignOffset', alignOffset); + setDefinedProp(positionerProps, 'collisionBoundary', collisionBoundary); + setDefinedProp(positionerProps, 'collisionPadding', collisionPadding); + setDefinedProp(positionerProps, 'arrowPadding', arrowPadding); + setDefinedProp(positionerProps, 'sticky', sticky); + setDefinedProp(positionerProps, 'disableAnchorTracking', disableAnchorTracking); + setDefinedProp(positionerProps, 'collisionAvoidance', collisionAvoidance); + + const popupClassName = clsx(classes?.root, className, resolvedPopupClassName); + const popupRender = ; + const portalSlotProps = getSlotProps( + PortalSlot, + { + container, + keepMounted, + ...resolvedPortalProps, + }, + portalHostOmittedProps, + ); + const positionerSlotProps = getSlotProps( + PositionerSlot, + { + ...positionerProps, + ...resolvedPositionerProps, + }, + positionerHostOmittedProps, + ); + const paperSlotProps = getSlotProps( + PaperSlot, + { + elevation: elevation ?? 8, + ...resolvedPaperProps, + className: clsx(classes?.paper, resolvedPaperProps?.className), + sx: mergeSx(sx, resolvedPaperProps?.sx), + }, + paperHostOmittedProps, + ); + const listSlotProps = getSlotProps( + ListSlot, + { + component: 'div', + disablePadding: false, + ...resolvedListProps, + className: clsx(classes?.list, resolvedListProps?.className), + }, + listHostOmittedProps, + ); + + return ( + + + + + {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..cd44eac5ad0121 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2SharedStyles.ts @@ -0,0 +1,93 @@ +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'; + +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: CSSInterpolation = menuListStyles; + +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..72843f75b39976 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2/menu2Utils.ts @@ -0,0 +1,74 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import appendOwnerState from '@mui/utils/appendOwnerState'; +import isHostComponent from '@mui/utils/isHostComponent'; + +export type StateClassName = string | ((state: State) => string | undefined) | undefined; + +export function resolveStateClassName( + className: StateClassName, + state: State, +): string | undefined { + return typeof className === 'function' ? className(state) : className; +} + +export function mergeStateClassName( + className: StateClassName, + getClassName: (state: State) => string | undefined, +) { + return (state: State) => clsx(getClassName(state), resolveStateClassName(className, state)); +} + +export type SlotProps = + SlotPropsValue | ((ownerState: OwnerState) => SlotPropsValue) | undefined; + +export function resolveSlotProps( + slotProps: SlotProps, + ownerState: OwnerState, +): SlotPropsValue | undefined { + return typeof slotProps === 'function' + ? (slotProps as (ownerState: OwnerState) => SlotPropsValue)(ownerState) + : slotProps; +} + +export interface 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.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx new file mode 100644 index 00000000000000..43face4999bb44 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/Menu2CheckboxItem.tsx @@ -0,0 +1,309 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, + resolveSlotProps, +} 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; +} + +export interface Menu2CheckboxItemSlotProps extends Menu2RootSlotProps {} + +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; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2CheckboxItemRoot = styled('div', { + 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 { + checked, + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + onChange, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...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; + + return ( + + + clsx( + className, + getMenu2ItemClassName(classes, ownerState, state), + state.checked && classes.checked, + ) + } + checked={checked} + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} + onCheckedChange={handleCheckedChange} + style={style} + {...other} + /> + + ); +}); + +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`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * Event handler called when the checkbox item is ticked or unticked. + */ + onChange: PropTypes.func, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default Menu2CheckboxItem; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts new file mode 100644 index 00000000000000..dff5b9c4fec88a --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2CheckboxItem'; +export * from './Menu2CheckboxItem'; +export { + menu2CheckboxItemClasses, + getMenu2CheckboxItemUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2CheckboxItemClasses, + Menu2CheckboxItemClassKey, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js new file mode 100644 index 00000000000000..fc33e94bd00adf --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItem/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2CheckboxItem'; +export * from './Menu2CheckboxItem'; +export { + menu2CheckboxItemClasses, + getMenu2CheckboxItemUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx new file mode 100644 index 00000000000000..f631a21aa669b0 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/Menu2CheckboxItemIndicator.tsx @@ -0,0 +1,216 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menu2IndicatorStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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.d.ts b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.d.ts new file mode 100644 index 00000000000000..415da71ca40fa2 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.d.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_Menu2CheckboxItemIndicator/index.js b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js new file mode 100644 index 00000000000000..5cdec46f207d32 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2CheckboxItemIndicator/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2CheckboxItemIndicator'; +export * from './Menu2CheckboxItemIndicator'; +export { + menu2CheckboxItemIndicatorClasses, + getMenu2CheckboxItemIndicatorUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; 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..06ede66d21f8b0 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/Menu2Group.tsx @@ -0,0 +1,172 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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.d.ts b/packages/mui-material/src/Unstable_Menu2Group/index.d.ts new file mode 100644 index 00000000000000..7b636c29e449aa --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/index.d.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_Menu2Group/index.js b/packages/mui-material/src/Unstable_Menu2Group/index.js new file mode 100644 index 00000000000000..f9932fa19a4cbb --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Group/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2Group'; +export * from './Menu2Group'; +export { menu2GroupClasses, getMenu2GroupUtilityClass } from '../Unstable_Menu2/menu2Classes'; 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..61dad9a3b376b7 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/Menu2GroupLabel.tsx @@ -0,0 +1,177 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import ListSubheader from '../ListSubheader'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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.d.ts b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.d.ts new file mode 100644 index 00000000000000..8a600248959ee1 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.d.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_Menu2GroupLabel/index.js b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.js new file mode 100644 index 00000000000000..d76f379d195950 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2GroupLabel/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2GroupLabel'; +export * from './Menu2GroupLabel'; +export { + menu2GroupLabelClasses, + getMenu2GroupLabelUtilityClass, +} 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..54c0596540014e --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Item, { menu2ItemClasses as classes } from '@mui/material/Unstable_Menu2Item'; +import Unstable_Menu2Popup from '@mui/material/Unstable_Menu2Popup'; +import describeConformance from '../../test/describeConformance'; + +describe('', () => { + 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..909ed67f27a9bb --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/Menu2Item.tsx @@ -0,0 +1,238 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2ItemOwnerState, + Menu2ItemBaseProps, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + menu2ItemOverridesResolver, + mergeMenu2ItemClassName, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; +import { + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, + resolveSlotProps, +} 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; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2ItemRoot = styled('div', { + 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, + 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 ( + + + + ); +}); + +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`, 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.d.ts b/packages/mui-material/src/Unstable_Menu2Item/index.d.ts new file mode 100644 index 00000000000000..e25f41dc5ae0ea --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/index.d.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_Menu2Item/index.js b/packages/mui-material/src/Unstable_Menu2Item/index.js new file mode 100644 index 00000000000000..8113d84f952368 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Item/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2Item'; +export * from './Menu2Item'; +export { menu2ItemClasses, getMenu2ItemUtilityClass } from '../Unstable_Menu2/menu2Classes'; 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..949b35c64c3def --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/Menu2LinkItem.tsx @@ -0,0 +1,230 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2LinkItemRoot = styled('a', { + 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, + 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 ( + + + + ); +}); + +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`, 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.d.ts b/packages/mui-material/src/Unstable_Menu2LinkItem/index.d.ts new file mode 100644 index 00000000000000..261948c35562e2 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/index.d.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_Menu2LinkItem/index.js b/packages/mui-material/src/Unstable_Menu2LinkItem/index.js new file mode 100644 index 00000000000000..2b6de8f99c77b0 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2LinkItem/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2LinkItem'; +export * from './Menu2LinkItem'; +export { menu2LinkItemClasses, getMenu2LinkItemUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx new file mode 100644 index 00000000000000..121098a0961898 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { createRenderer } from '@mui/internal-test-utils'; +import Unstable_Menu2 from '@mui/material/Unstable_Menu2'; +import Unstable_Menu2Item from '@mui/material/Unstable_Menu2Item'; +import Unstable_Menu2Popup, { + menu2PopupClasses as classes, +} from '@mui/material/Unstable_Menu2Popup'; +import describeConformance from '../../test/describeConformance'; + +describe('', () => { + const { render } = createRenderer(); + + describeConformance( + document.body}> + Item + , + () => ({ + classes, + render: (node) => { + const { container, ...other } = render( + + {node} + , + ); + // The popup renders in a portal surrounded by Base UI focus-guard + // spans, so no real parent has it as firstChild; hand the harness a + // container satisfying its firstChild contract. + const popup = document.querySelector('[role="menu"]')!; + return { ...other, container: { firstChild: popup } as unknown as HTMLElement }; + }, + // The popup root is the Base UI Popup element; swapping the host goes + // through slots.popup rather than the component prop. + skip: ['componentProp'], + refInstanceof: window.HTMLDivElement, + muiName: 'MuiMenu2Popup', + testVariantProps: { align: 'center' }, + slots: { + paper: { + expectedClassName: classes.paper, + }, + list: { + expectedClassName: classes.list, + }, + }, + }), + ); +}); diff --git a/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx new file mode 100644 index 00000000000000..231213e0b52040 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Popup/Menu2Popup.tsx @@ -0,0 +1,410 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import composeClasses from '@mui/utils/composeClasses'; +import HTMLElementType from '@mui/utils/HTMLElementType'; +import { SxProps } from '@mui/system'; +import Paper from '../Paper'; +import List from '../List'; +import { styled } from '../zero-styled'; +import { Theme } from '../styles'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + Menu2PopupBase, + Menu2PopupPublicProps, + Menu2PopupSharedProps, + Menu2PopupSharedSlotProps, +} from '../Unstable_Menu2/menu2PopupShared'; +import { menu2PopupListStyles, menu2PopupPaperStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { getMenu2PopupUtilityClass, Menu2PopupClasses } from '../Unstable_Menu2/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 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'], + paper: ['paper'], + list: ['list'], + }; + + return composeClasses(slots, getMenu2PopupUtilityClass, classes); +}; + +const Menu2PopupRoot = styled('div', { + name: 'MuiMenu2Popup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ + outline: 0, +}); + +const Menu2PopupPaper = styled(Paper, { + name: 'MuiMenu2Popup', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})(menu2PopupPaperStyles); + +const Menu2PopupList = styled(List, { + name: 'MuiMenu2Popup', + 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, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2Popup', + }); + + 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({ + 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_Menu2Popup/index.d.ts b/packages/mui-material/src/Unstable_Menu2Popup/index.d.ts new file mode 100644 index 00000000000000..fd40324bf82c76 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Popup/index.d.ts @@ -0,0 +1,4 @@ +export { default } from './Menu2Popup'; +export * from './Menu2Popup'; +export { menu2PopupClasses, getMenu2PopupUtilityClass } from '../Unstable_Menu2/menu2Classes'; +export type { Menu2PopupClasses, Menu2PopupClassKey } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Popup/index.js b/packages/mui-material/src/Unstable_Menu2Popup/index.js new file mode 100644 index 00000000000000..f3e4e588fc00a7 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Popup/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2Popup'; +export * from './Menu2Popup'; +export { menu2PopupClasses, getMenu2PopupUtilityClass } from '../Unstable_Menu2/menu2Classes'; 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..4ee275d0309328 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/Menu2RadioGroup.tsx @@ -0,0 +1,229 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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.d.ts b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.d.ts new file mode 100644 index 00000000000000..f6c449efe07eff --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.d.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_Menu2RadioGroup/index.js b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.js new file mode 100644 index 00000000000000..e340c0c98a0045 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioGroup/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2RadioGroup'; +export * from './Menu2RadioGroup'; +export { + menu2RadioGroupClasses, + getMenu2RadioGroupUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; 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..7b3c4b40c89664 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/Menu2RadioItem.tsx @@ -0,0 +1,253 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, + resolveSlotProps, +} 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; +} + +export interface Menu2RadioItemSlotProps extends Menu2RootSlotProps {} + +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; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2RadioItemRoot = styled('div', { + 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 { + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...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; + + return ( + + + clsx( + className, + getMenu2ItemClassName(classes, ownerState, state), + state.checked && classes.checked, + ) + } + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} + style={style} + {...other} + /> + + ); +}); + +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`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), + /** + * Value of the radio item. + */ + value: PropTypes.any.isRequired, +} as any; + +export default Menu2RadioItem; diff --git a/packages/mui-material/src/Unstable_Menu2RadioItem/index.d.ts b/packages/mui-material/src/Unstable_Menu2RadioItem/index.d.ts new file mode 100644 index 00000000000000..d1edd2e9763db6 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/index.d.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_Menu2RadioItem/index.js b/packages/mui-material/src/Unstable_Menu2RadioItem/index.js new file mode 100644 index 00000000000000..fb72894a580328 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItem/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2RadioItem'; +export * from './Menu2RadioItem'; +export { + menu2RadioItemClasses, + getMenu2RadioItemUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; 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..febe9761856ff3 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/Menu2RadioItemIndicator.tsx @@ -0,0 +1,215 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { menu2IndicatorStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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.d.ts b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.d.ts new file mode 100644 index 00000000000000..8b93c269ea0f22 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.d.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_Menu2RadioItemIndicator/index.js b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js new file mode 100644 index 00000000000000..8e8bc0e1ef77ff --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2RadioItemIndicator/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2RadioItemIndicator'; +export * from './Menu2RadioItemIndicator'; +export { + menu2RadioItemIndicatorClasses, + getMenu2RadioItemIndicatorUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; 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..6aa3cea1506be4 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/Menu2Separator.tsx @@ -0,0 +1,195 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Separator as BaseSeparator } from '@base-ui/react/separator'; +import { SxProps } from '@mui/system'; +import Divider from '../Divider'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenu2RootRender, + Menu2RootSlotProps, + resolveSlotProps, +} 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.d.ts b/packages/mui-material/src/Unstable_Menu2Separator/index.d.ts new file mode 100644 index 00000000000000..2d70c501e79c8f --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/index.d.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_Menu2Separator/index.js b/packages/mui-material/src/Unstable_Menu2Separator/index.js new file mode 100644 index 00000000000000..a331f4d0ee54be --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Separator/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2Separator'; +export * from './Menu2Separator'; +export { + menu2SeparatorClasses, + getMenu2SeparatorUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx new file mode 100644 index 00000000000000..eaabffb3f8b019 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/Menu2SubmenuPopup.tsx @@ -0,0 +1,413 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import composeClasses from '@mui/utils/composeClasses'; +import HTMLElementType from '@mui/utils/HTMLElementType'; +import { SxProps } from '@mui/system'; +import Paper from '../Paper'; +import List from '../List'; +import { styled } from '../zero-styled'; +import { Theme } from '../styles'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + Menu2PopupBase, + Menu2PopupPublicProps, + Menu2PopupSharedProps, + Menu2PopupSharedSlotProps, +} from '../Unstable_Menu2/menu2PopupShared'; +import { menu2PopupListStyles, menu2PopupPaperStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2SubmenuPopupUtilityClass, + Menu2SubmenuPopupClasses, +} from '../Unstable_Menu2/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: 'MuiMenu2SubmenuPopup', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({ + outline: 0, +}); + +const Menu2SubmenuPopupPaper = styled(Paper, { + name: 'MuiMenu2SubmenuPopup', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})(menu2PopupPaperStyles); + +const Menu2SubmenuPopupList = styled(List, { + name: 'MuiMenu2SubmenuPopup', + 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, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2SubmenuPopup', + }); + + 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({ + 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_Menu2SubmenuPopup/index.d.ts b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts new file mode 100644 index 00000000000000..9a231c449df2f2 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2SubmenuPopup'; +export * from './Menu2SubmenuPopup'; +export { + menu2SubmenuPopupClasses, + getMenu2SubmenuPopupUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2SubmenuPopupClasses, + Menu2SubmenuPopupClassKey, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js new file mode 100644 index 00000000000000..3469ac08b3ed0f --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuPopup/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2SubmenuPopup'; +export * from './Menu2SubmenuPopup'; +export { + menu2SubmenuPopupClasses, + getMenu2SubmenuPopupUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx new file mode 100644 index 00000000000000..a918fc7ca0890c --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/Menu2SubmenuRoot.tsx @@ -0,0 +1,46 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { useDefaultProps } from '../DefaultPropsProvider'; + +/** + * 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 { + const themedProps = useDefaultProps({ + props, + name: 'MuiMenu2SubmenuRoot', + }); + + 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_Menu2SubmenuRoot/index.d.ts b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts new file mode 100644 index 00000000000000..5821f3d3212344 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.d.ts @@ -0,0 +1,2 @@ +export { default } from './Menu2SubmenuRoot'; +export * from './Menu2SubmenuRoot'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js new file mode 100644 index 00000000000000..5821f3d3212344 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuRoot/index.js @@ -0,0 +1,2 @@ +export { default } from './Menu2SubmenuRoot'; +export * from './Menu2SubmenuRoot'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx new file mode 100644 index 00000000000000..d2118f3cc6ed31 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/Menu2SubmenuTrigger.tsx @@ -0,0 +1,275 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import ListContext from '../List/ListContext'; +import { styled } from '../zero-styled'; +import memoTheme from '../utils/memoTheme'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { getMenu2ItemStyles } from '../Unstable_Menu2/menu2SharedStyles'; +import { + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, + resolveSlotProps, +} from '../Unstable_Menu2/menu2Utils'; +import { + getMenu2ItemClassName, + getMenu2ItemOwnerState, + Menu2ItemOwnerState, + Menu2ItemVisualProps, + Menu2SubmenuTriggerBaseProps, + menu2ItemOverridesResolver, + useMenu2ItemUtilityClasses, +} from '../Unstable_Menu2/menu2ItemShared'; +import { + getMenu2SubmenuTriggerUtilityClass, + menu2SubmenuTriggerClasses, + Menu2SubmenuTriggerClasses, +} from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2SubmenuTriggerSlots { + /** + * The component that renders the root. + * @default 'div' + */ + root?: React.ElementType | undefined; +} + +export interface Menu2SubmenuTriggerSlotProps extends Menu2RootSlotProps {} + +export interface Menu2SubmenuTriggerProps + extends + Omit, + Menu2SubmenuTriggerBaseProps, + Menu2ItemVisualProps< + Menu2SubmenuTriggerClasses, + Menu2SubmenuTriggerSlots, + Menu2SubmenuTriggerSlotProps + > { + /** + * The content of the component. + */ + children?: React.ReactNode; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label?: string | undefined; + /** + * How long to wait before the submenu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay?: number | undefined; + /** + * How long to wait before closing the submenu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay?: number | undefined; + /** + * Whether the submenu should also open when the trigger is hovered. + */ + openOnHover?: boolean | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; +} + +const Menu2SubmenuTriggerRoot = styled('div', { + name: 'MuiMenu2SubmenuTrigger', + slot: 'Root', + overridesResolver: menu2ItemOverridesResolver, +})<{ ownerState: Menu2ItemOwnerState }>( + memoTheme(({ theme }) => getMenu2ItemStyles(theme, menu2SubmenuTriggerClasses)), +); + +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2SubmenuTrigger = React.forwardRef(function Menu2SubmenuTrigger( + inProps: Menu2SubmenuTriggerProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2SubmenuTrigger', + }); + + const { + className, + classes: classesProp, + component, + dense = false, + disabled = false, + disableGutters = false, + divider = false, + nativeButton: nativeButtonProp, + selected = false, + slotProps, + slots, + sx, + style, + ...other + } = props; + const ownerState = { + ...props, + ...getMenu2ItemOwnerState({ dense, disabled, disableGutters, divider, selected }), + classes: classesProp, + }; + const classes = useMenu2ItemUtilityClasses( + ownerState, + getMenu2SubmenuTriggerUtilityClass, + ); + const childContext = React.useMemo( + () => ({ + dense, + disableGutters, + }), + [dense, disableGutters], + ); + const RootSlot = slots?.root ?? Menu2SubmenuTriggerRoot; + + return ( + + + clsx( + className, + getMenu2ItemClassName(classes, ownerState, state), + state.open && classes.open, + ) + } + disabled={disabled} + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component)} + style={style} + {...other} + /> + + ); +}); + +Menu2SubmenuTrigger.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * The content of the component. + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * How long to wait before closing the submenu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay: PropTypes.number, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * How long to wait before the submenu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay: PropTypes.number, + /** + * If `true`, compact vertical padding designed for keyboard and mouse input is used. + * @default false + */ + dense: PropTypes.bool, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * If `true`, the left and right padding is removed. + * @default false + */ + disableGutters: PropTypes.bool, + /** + * If `true`, a 1px light border is added to the bottom of the menu item. + * @default false + */ + divider: PropTypes.bool, + /** + * Overrides the text label to use when the item is matched during keyboard text navigation. + */ + label: PropTypes.string, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * Whether the submenu should also open when the trigger is hovered. + */ + openOnHover: PropTypes.bool, + /** + * If `true`, the component is selected. + * @default false + */ + selected: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default Menu2SubmenuTrigger; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts new file mode 100644 index 00000000000000..f80db63dd82338 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.d.ts @@ -0,0 +1,10 @@ +export { default } from './Menu2SubmenuTrigger'; +export * from './Menu2SubmenuTrigger'; +export { + menu2SubmenuTriggerClasses, + getMenu2SubmenuTriggerUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; +export type { + Menu2SubmenuTriggerClasses, + Menu2SubmenuTriggerClassKey, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js new file mode 100644 index 00000000000000..e624188f7f5cc2 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2SubmenuTrigger/index.js @@ -0,0 +1,6 @@ +export { default } from './Menu2SubmenuTrigger'; +export * from './Menu2SubmenuTrigger'; +export { + menu2SubmenuTriggerClasses, + getMenu2SubmenuTriggerUtilityClass, +} from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.tsx b/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.tsx new file mode 100644 index 00000000000000..a88cbde9d54ef5 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Trigger/Menu2Trigger.tsx @@ -0,0 +1,268 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import clsx from 'clsx'; +import composeClasses from '@mui/utils/composeClasses'; +import { Menu as BaseMenu } from '@base-ui/react/menu'; +import { SxProps } from '@mui/system'; +import Button, { ButtonProps } from '../Button'; +import { Theme } from '../styles'; +import { styled } from '../zero-styled'; +import { useDefaultProps } from '../DefaultPropsProvider'; +import { + getMenu2RootRender, + isMenu2RootNativeButton, + Menu2RootSlotProps, + resolveSlotProps, +} from '../Unstable_Menu2/menu2Utils'; +import { getMenu2TriggerUtilityClass, Menu2TriggerClasses } from '../Unstable_Menu2/menu2Classes'; + +export interface Menu2TriggerSlots { + /** + * The component that renders the root. + * @default Button + */ + root?: React.ElementType | undefined; +} + +export interface Menu2TriggerProps + extends + Omit< + BaseMenu.Trigger.Props, + 'className' | 'handle' | 'nativeButton' | 'payload' | 'render' | 'style' + >, + Omit< + ButtonProps, + keyof BaseMenu.Trigger.Props | 'classes' | 'component' | 'disabled' | 'href' | 'style' + > { + /** + * The component used for the root node. + */ + component?: React.ElementType | undefined; + /** + * Override or extend the styles applied to the component. + */ + classes?: Partial | undefined; + /** + * CSS class applied to the element. + */ + className?: string | undefined; + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled?: boolean | undefined; + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton?: boolean | undefined; + /** + * How long to wait before the menu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay?: number | undefined; + /** + * How long to wait before closing the menu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay?: number | undefined; + /** + * Whether the menu should also open when the trigger is hovered. + */ + openOnHover?: boolean | undefined; + /** + * The components used for each slot inside. + */ + slots?: Menu2TriggerSlots | undefined; + /** + * The props used for each slot inside. + */ + slotProps?: Menu2TriggerSlotProps | undefined; + /** + * Styles applied to the root element. + */ + style?: React.CSSProperties | undefined; + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx?: SxProps | undefined; +} + +interface Menu2TriggerOwnerState extends Menu2TriggerProps { + disabled: boolean; +} + +export interface Menu2TriggerSlotProps extends Menu2RootSlotProps {} + +const useUtilityClasses = (ownerState: Menu2TriggerOwnerState) => { + const { classes } = ownerState; + + const slots = { + root: ['root'], + disabled: ['disabled'], + open: ['open'], + }; + + return { + ...classes, + ...composeClasses(slots, getMenu2TriggerUtilityClass, classes), + }; +}; + +const Menu2TriggerRoot = styled(Button, { + name: 'MuiMenu2Trigger', + slot: 'Root', + overridesResolver: (props, styles) => styles.root, +})({}) as any; + +const BaseMenuTrigger = BaseMenu.Trigger as any; +/** + * + * Demos: + * + * - [Menu](https://mui.com/material-ui/react-menu/) + */ +const Menu2Trigger = React.forwardRef(function Menu2Trigger( + inProps: Menu2TriggerProps, + ref: React.ForwardedRef, +) { + const props = useDefaultProps({ + props: inProps, + name: 'MuiMenu2Trigger', + }); + + const { href: ignoredHref, ...propsWithoutHref } = props as Menu2TriggerProps & { + href?: unknown; + }; + void ignoredHref; + + const { + className, + classes: classesProp, + component, + disabled = false, + nativeButton: nativeButtonProp, + slotProps, + slots, + sx, + style, + ...other + } = propsWithoutHref; + const ownerState = { + ...propsWithoutHref, + classes: classesProp, + disabled, + }; + const classes = useUtilityClasses(ownerState); + const RootSlot = slots?.root ?? Menu2TriggerRoot; + + return ( + + clsx( + className, + classes.root, + state.open && classes.open, + state.disabled && classes.disabled, + ) + } + nativeButton={nativeButtonProp ?? isMenu2RootNativeButton(RootSlot, component, true)} + style={style} + {...other} + /> + ); +}) as ((props: Menu2TriggerProps & React.RefAttributes) => React.JSX.Element) & { + propTypes?: any; +}; + +Menu2Trigger.propTypes /* remove-proptypes */ = { + // ┌────────────────────────────── Warning ──────────────────────────────┐ + // │ These PropTypes are generated from the TypeScript type definitions. │ + // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │ + // └─────────────────────────────────────────────────────────────────────┘ + /** + * @ignore + */ + children: PropTypes.node, + /** + * Override or extend the styles applied to the component. + */ + classes: PropTypes.object, + /** + * CSS class applied to the element. + */ + className: PropTypes.string, + /** + * How long to wait before closing the menu that was opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 0 + */ + closeDelay: PropTypes.number, + /** + * The component used for the root node. + */ + component: PropTypes.elementType, + /** + * How long to wait before the menu may be opened on hover, in milliseconds. + * + * Requires the `openOnHover` prop. + * @default 100 + */ + delay: PropTypes.number, + /** + * Whether the component should ignore user interaction. + * @default false + */ + disabled: PropTypes.bool, + /** + * Whether the component is rendered as a native button. + * + * By default, this is inferred from the root slot and `component` prop. + */ + nativeButton: PropTypes.bool, + /** + * Whether the menu should also open when the trigger is hovered. + */ + openOnHover: PropTypes.bool, + /** + * The props used for each slot inside. + */ + slotProps: PropTypes.shape({ + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + */ + slots: PropTypes.shape({ + root: PropTypes.elementType, + }), + /** + * Styles applied to the root element. + */ + style: PropTypes.object, + /** + * The system prop that allows defining system overrides as well as additional CSS styles. + */ + sx: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])), + PropTypes.func, + PropTypes.object, + ]), +} as any; + +export default Menu2Trigger; diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts b/packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts new file mode 100644 index 00000000000000..da738f98abc084 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Trigger/index.d.ts @@ -0,0 +1,4 @@ +export { default } from './Menu2Trigger'; +export * from './Menu2Trigger'; +export { menu2TriggerClasses, getMenu2TriggerUtilityClass } from '../Unstable_Menu2/menu2Classes'; +export type { Menu2TriggerClasses, Menu2TriggerClassKey } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/Unstable_Menu2Trigger/index.js b/packages/mui-material/src/Unstable_Menu2Trigger/index.js new file mode 100644 index 00000000000000..f7afa354ca2cb7 --- /dev/null +++ b/packages/mui-material/src/Unstable_Menu2Trigger/index.js @@ -0,0 +1,3 @@ +export { default } from './Menu2Trigger'; +export * from './Menu2Trigger'; +export { menu2TriggerClasses, getMenu2TriggerUtilityClass } from '../Unstable_Menu2/menu2Classes'; diff --git a/packages/mui-material/src/styles/components.ts b/packages/mui-material/src/styles/components.ts index 3404eaee7c94f0..ca0f1ac67610e3 100644 --- a/packages/mui-material/src/styles/components.ts +++ b/packages/mui-material/src/styles/components.ts @@ -477,6 +477,114 @@ export interface Components { variants?: ComponentsVariants['MuiMenuList'] | undefined; } | undefined; + MuiMenu2?: + | { + defaultProps?: ComponentsProps['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; + MuiMenu2Popup?: + | { + defaultProps?: ComponentsProps['MuiMenu2Popup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Popup'] | undefined; + variants?: ComponentsVariants['MuiMenu2Popup'] | 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; + MuiMenu2SubmenuPopup?: + | { + defaultProps?: ComponentsProps['MuiMenu2SubmenuPopup'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2SubmenuPopup'] | undefined; + variants?: ComponentsVariants['MuiMenu2SubmenuPopup'] | undefined; + } + | undefined; + MuiMenu2SubmenuRoot?: + | { + defaultProps?: ComponentsProps['MuiMenu2SubmenuRoot'] | undefined; + } + | undefined; + MuiMenu2SubmenuTrigger?: + | { + defaultProps?: ComponentsProps['MuiMenu2SubmenuTrigger'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2SubmenuTrigger'] | undefined; + variants?: ComponentsVariants['MuiMenu2SubmenuTrigger'] | undefined; + } + | undefined; + MuiMenu2Trigger?: + | { + defaultProps?: ComponentsProps['MuiMenu2Trigger'] | undefined; + styleOverrides?: ComponentsOverrides['MuiMenu2Trigger'] | undefined; + variants?: ComponentsVariants['MuiMenu2Trigger'] | undefined; + } + | undefined; MuiMobileStepper?: | { defaultProps?: ComponentsProps['MuiMobileStepper'] | undefined; diff --git a/packages/mui-material/src/styles/overrides.ts b/packages/mui-material/src/styles/overrides.ts index fa9e23fd15ab1a..e72ab84d71adc8 100644 --- a/packages/mui-material/src/styles/overrides.ts +++ b/packages/mui-material/src/styles/overrides.ts @@ -67,6 +67,20 @@ import { ListSubheaderClassKey } from '../ListSubheader'; import { MenuClassKey } from '../Menu'; import { MenuItemClassKey } from '../MenuItem'; import { MenuListClassKey } from '../MenuList'; +import { Menu2CheckboxItemClassKey } from '../Unstable_Menu2CheckboxItem'; +import { Menu2CheckboxItemIndicatorClassKey } from '../Unstable_Menu2CheckboxItemIndicator'; +import { Menu2GroupClassKey } from '../Unstable_Menu2Group'; +import { Menu2GroupLabelClassKey } from '../Unstable_Menu2GroupLabel'; +import { Menu2ItemClassKey } from '../Unstable_Menu2Item'; +import { Menu2LinkItemClassKey } from '../Unstable_Menu2LinkItem'; +import { Menu2PopupClassKey } from '../Unstable_Menu2Popup'; +import { Menu2RadioGroupClassKey } from '../Unstable_Menu2RadioGroup'; +import { Menu2RadioItemClassKey } from '../Unstable_Menu2RadioItem'; +import { Menu2RadioItemIndicatorClassKey } from '../Unstable_Menu2RadioItemIndicator'; +import { Menu2SeparatorClassKey } from '../Unstable_Menu2Separator'; +import { Menu2SubmenuPopupClassKey } from '../Unstable_Menu2SubmenuPopup'; +import { Menu2SubmenuTriggerClassKey } from '../Unstable_Menu2SubmenuTrigger'; +import { Menu2TriggerClassKey } from '../Unstable_Menu2Trigger'; import { MobileStepperClassKey } from '../MobileStepper'; import { ModalClassKey } from '../Modal'; import { NativeSelectClassKey } from '../NativeSelect'; @@ -211,6 +225,20 @@ export interface ComponentNameToClassKey { MuiMenu: MenuClassKey; MuiMenuItem: MenuItemClassKey; MuiMenuList: MenuListClassKey; + MuiMenu2CheckboxItem: Menu2CheckboxItemClassKey; + MuiMenu2CheckboxItemIndicator: Menu2CheckboxItemIndicatorClassKey; + MuiMenu2Group: Menu2GroupClassKey; + MuiMenu2GroupLabel: Menu2GroupLabelClassKey; + MuiMenu2Item: Menu2ItemClassKey; + MuiMenu2LinkItem: Menu2LinkItemClassKey; + MuiMenu2Popup: Menu2PopupClassKey; + MuiMenu2RadioGroup: Menu2RadioGroupClassKey; + MuiMenu2RadioItem: Menu2RadioItemClassKey; + MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorClassKey; + MuiMenu2Separator: Menu2SeparatorClassKey; + MuiMenu2SubmenuPopup: Menu2SubmenuPopupClassKey; + MuiMenu2SubmenuTrigger: Menu2SubmenuTriggerClassKey; + MuiMenu2Trigger: Menu2TriggerClassKey; MuiMobileStepper: MobileStepperClassKey; MuiModal: ModalClassKey; MuiNativeSelect: NativeSelectClassKey; diff --git a/packages/mui-material/src/styles/props.ts b/packages/mui-material/src/styles/props.ts index 35d8b8783307af..88858b6a69df9b 100644 --- a/packages/mui-material/src/styles/props.ts +++ b/packages/mui-material/src/styles/props.ts @@ -64,6 +64,22 @@ import { ListProps } from '../List'; import { ListSubheaderProps } from '../ListSubheader'; import { MenuItemProps } from '../MenuItem'; import { MenuListProps } from '../MenuList'; +import { 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 { Menu2PopupProps } from '../Unstable_Menu2Popup'; +import { Menu2RadioGroupProps } from '../Unstable_Menu2RadioGroup'; +import { Menu2RadioItemProps } from '../Unstable_Menu2RadioItem'; +import { Menu2RadioItemIndicatorProps } from '../Unstable_Menu2RadioItemIndicator'; +import { Menu2SeparatorProps } from '../Unstable_Menu2Separator'; +import { Menu2SubmenuPopupProps } from '../Unstable_Menu2SubmenuPopup'; +import { Menu2SubmenuRootProps } from '../Unstable_Menu2SubmenuRoot'; +import { Menu2SubmenuTriggerProps } from '../Unstable_Menu2SubmenuTrigger'; +import { Menu2TriggerProps } from '../Unstable_Menu2Trigger'; import { MenuProps } from '../Menu'; import { MobileStepperProps } from '../MobileStepper'; import { ModalProps } from '../Modal'; @@ -189,6 +205,22 @@ export interface ComponentsPropsList { MuiMenu: MenuProps; MuiMenuItem: MenuItemProps; MuiMenuList: MenuListProps; + MuiMenu2: Menu2Props; + MuiMenu2CheckboxItem: Menu2CheckboxItemProps; + MuiMenu2CheckboxItemIndicator: Menu2CheckboxItemIndicatorProps; + MuiMenu2Group: Menu2GroupProps; + MuiMenu2GroupLabel: Menu2GroupLabelProps; + MuiMenu2Item: Menu2ItemProps; + MuiMenu2LinkItem: Menu2LinkItemProps; + MuiMenu2Popup: Menu2PopupProps; + MuiMenu2RadioGroup: Menu2RadioGroupProps; + MuiMenu2RadioItem: Menu2RadioItemProps; + MuiMenu2RadioItemIndicator: Menu2RadioItemIndicatorProps; + MuiMenu2Separator: Menu2SeparatorProps; + MuiMenu2SubmenuPopup: Menu2SubmenuPopupProps; + MuiMenu2SubmenuRoot: Menu2SubmenuRootProps; + MuiMenu2SubmenuTrigger: Menu2SubmenuTriggerProps; + MuiMenu2Trigger: Menu2TriggerProps; MuiMobileStepper: MobileStepperProps; MuiModal: ModalProps; MuiNativeSelect: NativeSelectProps; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90067d3f1cc520..120b33dc5c939e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -694,7 +694,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.7(react@19.2.7))(react@19.2.7) '@docsearch/react': specifier: catalog:docs @@ -1108,6 +1108,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.7(react@19.2.7))(react@19.2.7) '@emotion/react': specifier: ^11.5.0 version: 11.14.0(@types/react@19.2.17)(react@19.2.7) @@ -6455,7 +6458,6 @@ packages: conventional-changelog-core@5.0.1: resolution: {integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==} engines: {node: '>=14'} - deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-preset-loader@3.0.0: resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==}