diff --git a/docs/pages/material-ui/api/popover.json b/docs/pages/material-ui/api/popover.json index d362711be03930..b573288f1783b7 100644 --- a/docs/pages/material-ui/api/popover.json +++ b/docs/pages/material-ui/api/popover.json @@ -28,6 +28,19 @@ "onClose": { "type": { "name": "func" } }, "PaperProps": { "type": { "name": "shape", "description": "{ component?: element type }" }, + "default": "{}", + "deprecated": true, + "deprecationInfo": "Use slotProps.paper instead." + }, + "slotProps": { + "type": { + "name": "shape", + "description": "{ paper?: func
| object, root?: func
| object }" + }, + "default": "{}" + }, + "slots": { + "type": { "name": "shape", "description": "{ paper?: elementType, root?: elementType }" }, "default": "{}" }, "sx": { diff --git a/docs/translations/api-docs/popover/popover.json b/docs/translations/api-docs/popover/popover.json index cf8736c17df4db..a2dfd5653313de 100644 --- a/docs/translations/api-docs/popover/popover.json +++ b/docs/translations/api-docs/popover/popover.json @@ -13,7 +13,9 @@ "marginThreshold": "Specifies how close to the edge of the window the popover can appear.", "onClose": "Callback fired when the component requests to be closed. The reason parameter can optionally be used to control the response to onClose.", "open": "If true, the component is shown.", - "PaperProps": "Props applied to the Paper element.", + "PaperProps": "Props applied to the Paper element.
This prop is an alias for slotProps.paper and will be overriden by it if both are used.", + "slotProps": "The extra props for the slot components. You can override the existing props or add new ones.", + "slots": "The components used for each slot inside.", "sx": "The system prop that allows defining system overrides as well as additional CSS styles. See the `sx` page for more details.", "transformOrigin": "This is the point on the popover which will attach to the anchor's origin.
Options: vertical: [top, center, bottom, x(px)]; horizontal: [left, center, right, x(px)].", "TransitionComponent": "The component used for the transition. Follow this guide to learn more about the requirements for this component.", diff --git a/packages/mui-material/src/Menu/Menu.d.ts b/packages/mui-material/src/Menu/Menu.d.ts index c0f243b12353fc..16e78dd1cd12e3 100644 --- a/packages/mui-material/src/Menu/Menu.d.ts +++ b/packages/mui-material/src/Menu/Menu.d.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; import { InternalStandardProps as StandardProps } from '..'; +import { PaperProps } from '../Paper'; import { PopoverProps } from '../Popover'; import { MenuListProps } from '../MenuList'; import { Theme } from '../styles'; @@ -79,6 +80,8 @@ export interface MenuProps extends StandardProps { variant?: 'menu' | 'selectedMenu'; } +export declare const MenuPaper: React.FC; + /** * * Demos: diff --git a/packages/mui-material/src/Menu/Menu.js b/packages/mui-material/src/Menu/Menu.js index 3a47c89c13c092..6a507c09edf243 100644 --- a/packages/mui-material/src/Menu/Menu.js +++ b/packages/mui-material/src/Menu/Menu.js @@ -5,8 +5,7 @@ import clsx from 'clsx'; import { unstable_composeClasses as composeClasses } from '@mui/base'; import { HTMLElementType } from '@mui/utils'; import MenuList from '../MenuList'; -import Paper from '../Paper'; -import Popover from '../Popover'; +import Popover, { PopoverPaper } from '../Popover'; import styled, { rootShouldForwardProp } from '../styles/styled'; import useTheme from '../styles/useTheme'; import useThemeProps from '../styles/useThemeProps'; @@ -41,7 +40,7 @@ const MenuRoot = styled(Popover, { overridesResolver: (props, styles) => styles.root, })({}); -const MenuPaper = styled(Paper, { +export const MenuPaper = styled(PopoverPaper, { name: 'MuiMenu', slot: 'Paper', overridesResolver: (props, styles) => styles.paper, @@ -164,12 +163,14 @@ const Menu = React.forwardRef(function Menu(inProps, ref) { horizontal: isRtl ? 'right' : 'left', }} transformOrigin={isRtl ? RTL_ORIGIN : LTR_ORIGIN} - PaperProps={{ - as: MenuPaper, - ...PaperProps, - classes: { - ...PaperProps.classes, - root: classes.paper, + slots={{ paper: MenuPaper }} + slotProps={{ + paper: { + ...PaperProps, + classes: { + ...PaperProps.classes, + root: classes.paper, + }, }, }} className={classes.root} diff --git a/packages/mui-material/src/Menu/Menu.test.js b/packages/mui-material/src/Menu/Menu.test.js index d4a98a3632fc36..cfbe417e82873f 100644 --- a/packages/mui-material/src/Menu/Menu.test.js +++ b/packages/mui-material/src/Menu/Menu.test.js @@ -3,6 +3,7 @@ import { spy } from 'sinon'; import { expect } from 'chai'; import { createRenderer, + createMount, describeConformance, screen, fireEvent, @@ -11,9 +12,11 @@ import { import Menu, { menuClasses as classes } from '@mui/material/Menu'; import Popover from '@mui/material/Popover'; import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { MenuPaper } from './Menu'; describe('', () => { const { render } = createRenderer({ clock: 'fake' }); + const mount = createMount(); describeConformance( document.createElement('div')} open />, () => ({ classes, @@ -127,6 +130,27 @@ describe('', () => { }); }); + describe('prop: PaperProps', () => { + it('should be passed to the paper component', () => { + const customElevation = 12; + const customClasses = { rounded: { borderRadius: 12 } }; + const wrapper = mount( + , + ); + + expect(wrapper.find(MenuPaper).props().elevation).to.equal(customElevation); + expect(wrapper.find(MenuPaper).props().classes).to.contain(customClasses); + }); + }); + it('should pass onClose prop to Popover', () => { const handleClose = spy(); render(); @@ -275,15 +299,21 @@ describe('', () => { }); }); - describe('should be customizable in theme', () => { - it('should override Paper styles in Menu taking MuiMenu.paper styles into account', function test() { + describe('theme customization', () => { + it('should override Menu Paper styles following correct precedence', function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); } + + const menuPaperOverrides = { borderRadius: 4 }; + const popoverPaperOverrides = { borderRadius: 8, height: 100 }; + const rootPaperOverrides = { borderRadius: 16, height: 200, width: 200 }; + const theme = createTheme({ components: { - MuiMenu: { styleOverrides: { paper: { borderRadius: 4 } } }, - MuiPaper: { styleOverrides: { rounded: { borderRadius: 90 } } }, + MuiMenu: { styleOverrides: { paper: menuPaperOverrides } }, + MuiPopover: { styleOverrides: { paper: popoverPaperOverrides } }, + MuiPaper: { styleOverrides: { root: rootPaperOverrides } }, }, }); @@ -301,14 +331,16 @@ describe('', () => { const paper = screen.getByTestId('paper'); expect(paper).toHaveComputedStyle({ - borderTopLeftRadius: '4px', - borderBottomLeftRadius: '4px', - borderTopRightRadius: '4px', - borderBottomRightRadius: '4px', + borderTopLeftRadius: `${menuPaperOverrides.borderRadius}px`, + borderBottomLeftRadius: `${menuPaperOverrides.borderRadius}px`, + borderTopRightRadius: `${menuPaperOverrides.borderRadius}px`, + borderBottomRightRadius: `${menuPaperOverrides.borderRadius}px`, + height: `${popoverPaperOverrides.height}px`, + width: `${rootPaperOverrides.width}px`, }); }); - it('should override Paper styles in Menu using styles in MuiPaper slot', function test() { + it('should override Menu Paper styles using styles in MuiPaper slot', function test() { if (/jsdom/.test(window.navigator.userAgent)) { this.skip(); } @@ -340,4 +372,16 @@ describe('', () => { }); }); }); + + describe('paper', () => { + it('should use MenuPaper component', () => { + const wrapper = mount( + +
+
, + ); + + expect(wrapper.find(MenuPaper)).to.have.length(1); + }); + }); }); diff --git a/packages/mui-material/src/Popover/Popover.d.ts b/packages/mui-material/src/Popover/Popover.d.ts index f889abbd8a0a98..3efa1773cd57dc 100644 --- a/packages/mui-material/src/Popover/Popover.d.ts +++ b/packages/mui-material/src/Popover/Popover.d.ts @@ -1,8 +1,9 @@ import * as React from 'react'; import { SxProps } from '@mui/system'; +import { SlotComponentProps } from '@mui/base'; import { InternalStandardProps as StandardProps } from '..'; -import { PaperProps } from '../Paper'; -import { ModalProps } from '../Modal'; +import Paper, { PaperProps } from '../Paper'; +import Modal, { ModalOwnerState, ModalProps } from '../Modal'; import { Theme } from '../styles'; import { TransitionProps } from '../transitions/transition'; import { PopoverClasses } from './popoverClasses'; @@ -19,7 +20,8 @@ export interface PopoverPosition { export type PopoverReference = 'anchorEl' | 'anchorPosition' | 'none'; -export interface PopoverProps extends StandardProps { +export interface PopoverProps + extends StandardProps, 'children'> { /** * A ref for imperative actions. * It currently only supports updatePosition() action. @@ -88,9 +90,32 @@ export interface PopoverProps extends StandardProps { open: boolean; /** * Props applied to the [`Paper`](/material-ui/api/paper/) element. + * + * This prop is an alias for `slotProps.paper` and will be overriden by it if both are used. + * @deprecated Use `slotProps.paper` instead. + * * @default {} */ PaperProps?: Partial; + /** + * The components used for each slot inside. + * + * @default {} + */ + slots?: { + root?: React.ElementType; + paper?: React.ElementType; + }; + /** + * The extra props for the slot components. + * You can override the existing props or add new ones. + * + * @default {} + */ + slotProps?: { + root?: SlotComponentProps; + paper?: SlotComponentProps; + }; /** * The system prop that allows defining system overrides as well as additional CSS styles. */ @@ -140,6 +165,12 @@ export function getOffsetLeft( horizontal: number | 'center' | 'right' | 'left', ): number; +type PopoverRootProps = NonNullable['root']; +type PopoverPaperProps = NonNullable['paper']; + +export declare const PopoverRoot: React.FC; +export declare const PopoverPaper: React.FC; + /** * * Demos: diff --git a/packages/mui-material/src/Popover/Popover.js b/packages/mui-material/src/Popover/Popover.js index 8c28b441130b90..ef7c8a9fb0f146 100644 --- a/packages/mui-material/src/Popover/Popover.js +++ b/packages/mui-material/src/Popover/Popover.js @@ -1,7 +1,11 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { unstable_composeClasses as composeClasses } from '@mui/base'; +import { + unstable_composeClasses as composeClasses, + useSlotProps, + isHostComponent, +} from '@mui/base'; import { chainPropTypes, integerPropType, @@ -17,7 +21,7 @@ import ownerWindow from '../utils/ownerWindow'; import useForkRef from '../utils/useForkRef'; import Grow from '../Grow'; import Modal from '../Modal'; -import Paper from '../Paper'; +import PaperBase from '../Paper'; import { getPopoverUtilityClass } from './popoverClasses'; export function getOffsetTop(rect, vertical) { @@ -69,13 +73,13 @@ const useUtilityClasses = (ownerState) => { return composeClasses(slots, getPopoverUtilityClass, classes); }; -const PopoverRoot = styled(Modal, { +export const PopoverRoot = styled(Modal, { name: 'MuiPopover', slot: 'Root', overridesResolver: (props, styles) => styles.root, })({}); -const PopoverPaper = styled(Paper, { +export const PopoverPaper = styled(PaperBase, { name: 'MuiPopover', slot: 'Paper', overridesResolver: (props, styles) => styles.paper, @@ -110,7 +114,9 @@ const Popover = React.forwardRef(function Popover(inProps, ref) { elevation = 8, marginThreshold = 16, open, - PaperProps = {}, + PaperProps: PaperPropsProp = {}, + slots, + slotProps, transformOrigin = { vertical: 'top', horizontal: 'left', @@ -120,8 +126,11 @@ const Popover = React.forwardRef(function Popover(inProps, ref) { TransitionProps: { onEntering, ...TransitionProps } = {}, ...other } = props; + + const externalPaperSlotProps = slotProps?.paper ?? PaperPropsProp; + const paperRef = React.useRef(); - const handlePaperRef = useForkRef(paperRef, PaperProps.ref); + const handlePaperRef = useForkRef(paperRef, externalPaperSlotProps.ref); const ownerState = { ...props, @@ -129,7 +138,7 @@ const Popover = React.forwardRef(function Popover(inProps, ref) { anchorReference, elevation, marginThreshold, - PaperProps, + externalPaperSlotProps, transformOrigin, TransitionComponent, transitionDuration: transitionDurationProp, @@ -359,16 +368,41 @@ const Popover = React.forwardRef(function Popover(inProps, ref) { const container = containerProp || (anchorEl ? ownerDocument(resolveAnchorEl(anchorEl)).body : undefined); + const RootSlot = slots?.root ?? PopoverRoot; + const PaperSlot = slots?.paper ?? PopoverPaper; + + const paperProps = useSlotProps({ + elementType: PaperSlot, + externalSlotProps: { + ...externalPaperSlotProps, + style: isPositioned + ? externalPaperSlotProps.style + : { ...externalPaperSlotProps.style, opacity: 0 }, + }, + additionalProps: { + elevation, + ref: handlePaperRef, + }, + ownerState, + className: clsx(classes.paper, externalPaperSlotProps?.className), + }); + + const { slotProps: rootSlotPropsProp, ...rootProps } = useSlotProps({ + elementType: RootSlot, + externalSlotProps: slotProps?.root || {}, + externalForwardedProps: other, + additionalProps: { + ref, + slotProps: { backdrop: { invisible: true } }, + container, + open, + }, + ownerState, + className: clsx(classes.root, className), + }); + return ( - + - - {children} - + {children} - + ); }); @@ -519,11 +544,34 @@ Popover.propTypes /* remove-proptypes */ = { open: PropTypes.bool.isRequired, /** * Props applied to the [`Paper`](/material-ui/api/paper/) element. + * + * This prop is an alias for `slotProps.paper` and will be overriden by it if both are used. + * @deprecated Use `slotProps.paper` instead. + * * @default {} */ PaperProps: PropTypes /* @typescript-to-proptypes-ignore */.shape({ component: elementTypeAcceptingRef, }), + /** + * The extra props for the slot components. + * You can override the existing props or add new ones. + * + * @default {} + */ + slotProps: PropTypes.shape({ + paper: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside. + * + * @default {} + */ + slots: PropTypes.shape({ + paper: PropTypes.elementType, + root: PropTypes.elementType, + }), /** * The system prop that allows defining system overrides as well as additional CSS styles. */ diff --git a/packages/mui-material/src/Popover/Popover.test.js b/packages/mui-material/src/Popover/Popover.test.js index 50f74a2fa24acc..d591040c56bf2f 100644 --- a/packages/mui-material/src/Popover/Popover.test.js +++ b/packages/mui-material/src/Popover/Popover.test.js @@ -1,15 +1,16 @@ import * as React from 'react'; import { expect } from 'chai'; -import { spy, stub } from 'sinon'; +import { spy, stub, match } from 'sinon'; import { act, createMount, createRenderer, describeConformance, screen } from 'test/utils'; import PropTypes from 'prop-types'; import Grow from '@mui/material/Grow'; import Modal from '@mui/material/Modal'; import Paper from '@mui/material/Paper'; -import Popover, { popoverClasses as classes } from '@mui/material/Popover'; +import Popover, { popoverClasses as classes, PopoverPaper } from '@mui/material/Popover'; import { ThemeProvider, createTheme } from '@mui/material/styles'; import { getOffsetLeft, getOffsetTop } from './Popover'; import useForkRef from '../utils/useForkRef'; +import styled from '../styles/styled'; const FakePaper = React.forwardRef(function FakeWidthPaper(props, ref) { const handleMocks = React.useCallback((paperInstance) => { @@ -34,6 +35,14 @@ const FakePaper = React.forwardRef(function FakeWidthPaper(props, ref) { ); }); +const ReplacementPaper = styled(Paper, { + name: 'ReplacementPaper', + slot: 'Paper', + overridesResolver: (props, styles) => styles.paper, +})({ + backgroundColor: 'red', +}); + describe('', () => { const { clock, render } = createRenderer({ clock: 'fake' }); const mount = createMount(); @@ -45,6 +54,17 @@ describe('', () => { muiName: 'MuiPopover', refInstanceof: window.HTMLDivElement, testDeepOverrides: { slotName: 'paper', slotClassName: classes.paper }, + slots: { + root: { + expectedClassName: classes.root, + }, + paper: { + expectedClassName: classes.paper, + testWithComponent: React.forwardRef((props, ref) => ( + + )), + }, + }, skip: [ 'rootClass', // portal, can't determine the root 'componentProp', @@ -53,6 +73,7 @@ describe('', () => { 'themeStyleOverrides', // portal, can't determine the root 'themeVariants', 'reactTestRenderer', // react-transition-group issue + 'slotPropsCallback', // not supported yet ], })); @@ -285,8 +306,8 @@ describe('', () => { }); describe('paper', () => { - it('should have Paper as a child of Transition', () => { - render( + it('should have PopoverPaper as a child of Transition', () => { + const wrapper = mount( ', () => { , ); + expect(wrapper.find(PopoverPaper)).to.have.lengthOf(1); expect(screen.getByTestId('paper')).not.to.equal(null); }); @@ -320,27 +342,46 @@ describe('', () => { , ); - expect(wrapper.find(Paper).props().elevation).to.equal(8); + expect(wrapper.find(PopoverPaper).props().elevation).to.equal(8); wrapper.setProps({ elevation: 16 }); - expect(wrapper.find(Paper).props().elevation).to.equal(16); + expect(wrapper.find(PopoverPaper).props().elevation).to.equal(16); }); }); - describe('PaperProps.ref', () => { - it('should position popover correctly', () => { - const handleEntering = spy(); - render( - null }} - TransitionProps={{ onEntering: handleEntering }} - > -
- , - ); - expect(handleEntering.args[0][0]).toHaveInlineStyle({ top: '16px', left: '16px' }); + describe('prop: PaperProps', () => { + describe('ref', () => { + it('should position popover correctly', () => { + const handleEntering = spy(); + render( + null }} + TransitionProps={{ onEntering: handleEntering }} + > +
+ , + ); + expect(handleEntering.args[0][0]).toHaveInlineStyle({ top: '16px', left: '16px' }); + }); + }); + + describe('className', () => { + it('should add the className to the paper', () => { + const className = 'MyPaperClassName'; + render( + +
+ , + ); + + expect(screen.getByTestId('paper')).to.have.class(className); + }); }); }); @@ -368,6 +409,33 @@ describe('', () => { expect(element.style.transformOrigin).to.match(/-16px -16px( 0px)?/); }); }); + + describe('paper styles', () => { + it('should have opacity 1 only after onEntering has been called', () => { + const onEnteringSpy = spy(); + const paperRenderSpy = spy(PopoverPaper, 'render'); + + const wrapper = mount( + +
+ , + ); + + wrapper.setProps({ open: true }); + + expect( + paperRenderSpy + .withArgs(match({ style: { opacity: 1 } })) + .firstCall.calledAfter(onEnteringSpy.lastCall), + ).to.equal(true); + }); + }); }); describe('prop: anchorEl', () => { @@ -902,4 +970,44 @@ describe('', () => { ), ).not.to.throw(); }); + + describe('prop: slotProps', () => { + describe('paper', () => { + it('should override PaperProps', () => { + const slotPropsElevation = 12; + const paperPropsElevation = 14; + + const wrapper = mount( + +
+ , + ); + + expect(slotPropsElevation).not.to.equal(paperPropsElevation); + expect(wrapper.find(PopoverPaper).props().elevation).to.equal(slotPropsElevation); + }); + + it('should position popover correctly when ref is provided', () => { + const handleEntering = spy(); + const paperRef = { current: null }; + render( + +
+ , + ); + expect(paperRef.current).not.to.equal(null); + expect(handleEntering.args[0][0]).toHaveInlineStyle({ top: '16px', left: '16px' }); + }); + }); + }); });