From 382c3579e8b339925397d6e4f37c3154c1b5729c Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Wed, 3 Jan 2024 16:34:42 -0300 Subject: [PATCH 01/11] Deprecate transition props and add slots API --- docs/pages/material-ui/api/accordion.json | 20 +++++++++-- .../api-docs/accordion/accordion.json | 4 +++ .../mui-material/src/Accordion/Accordion.d.ts | 24 +++++++++++++- .../mui-material/src/Accordion/Accordion.js | 33 ++++++++++++++++--- .../src/Accordion/Accordion.test.js | 19 ++++++++++- 5 files changed, 91 insertions(+), 9 deletions(-) diff --git a/docs/pages/material-ui/api/accordion.json b/docs/pages/material-ui/api/accordion.json index 0378f725fdec3e..2791940686d8bc 100644 --- a/docs/pages/material-ui/api/accordion.json +++ b/docs/pages/material-ui/api/accordion.json @@ -13,6 +13,14 @@ "describedArgs": ["event", "expanded"] } }, + "slotProps": { + "type": { "name": "shape", "description": "{ transition?: object }" }, + "default": "{}" + }, + "slots": { + "type": { "name": "shape", "description": "{ transition?: elementType }" }, + "default": "{}" + }, "square": { "type": { "name": "bool" }, "default": "false" }, "sx": { "type": { @@ -21,8 +29,16 @@ }, "additionalInfo": { "sx": true } }, - "TransitionComponent": { "type": { "name": "elementType" }, "default": "Collapse" }, - "TransitionProps": { "type": { "name": "object" } } + "TransitionComponent": { + "type": { "name": "elementType" }, + "deprecated": true, + "deprecationInfo": "Use slots.transition instead. This prop will be removed in v7." + }, + "TransitionProps": { + "type": { "name": "object" }, + "deprecated": true, + "deprecationInfo": "Use slotProps.transition instead. This prop will be removed in v7." + } }, "name": "Accordion", "imports": [ diff --git a/docs/translations/api-docs/accordion/accordion.json b/docs/translations/api-docs/accordion/accordion.json index a7d4336c0c3d50..957195a3b50f53 100644 --- a/docs/translations/api-docs/accordion/accordion.json +++ b/docs/translations/api-docs/accordion/accordion.json @@ -18,6 +18,10 @@ "expanded": "The expanded state of the accordion." } }, + "slotProps": { + "description": "The extra props for the slot components. You can override the existing props or add new ones." + }, + "slots": { "description": "The components used for each slot inside." }, "square": { "description": "If true, rounded corners are disabled." }, "sx": { "description": "The system prop that allows defining system overrides as well as additional CSS styles." diff --git a/packages/mui-material/src/Accordion/Accordion.d.ts b/packages/mui-material/src/Accordion/Accordion.d.ts index ca9becea5b1f40..4e637137dc2b92 100644 --- a/packages/mui-material/src/Accordion/Accordion.d.ts +++ b/packages/mui-material/src/Accordion/Accordion.d.ts @@ -6,6 +6,8 @@ import { AccordionClasses } from './accordionClasses'; import { OverridableComponent, OverrideProps } from '../OverridableComponent'; import { ExtendPaperTypeMap } from '../Paper/Paper'; +export interface AccordionTransitionSlotPropsOverrides {} + export type AccordionTypeMap< AdditionalProps = {}, RootComponent extends React.ElementType = 'div', @@ -47,6 +49,25 @@ export type AccordionTypeMap< * @param {boolean} expanded The `expanded` state of the accordion. */ onChange?: (event: React.SyntheticEvent, expanded: boolean) => void; + /** + * The components used for each slot inside. + * + * @default {} + */ + slots?: { + transition?: React.JSXElementConstructor< + TransitionProps & { children?: React.ReactElement } + >; + }; + /** + * The extra props for the slot components. + * You can override the existing props or add new ones. + * + * @default {} + */ + slotProps?: { + transition?: TransitionProps & AccordionTransitionSlotPropsOverrides; + }; /** * The system prop that allows defining system overrides as well as additional CSS styles. */ @@ -54,7 +75,7 @@ export type AccordionTypeMap< /** * The component used for the transition. * [Follow this guide](/material-ui/transitions/#transitioncomponent-prop) to learn more about the requirements for this component. - * @default Collapse + * @deprecated Use `slots.transition` instead. This prop will be removed in v7. */ TransitionComponent?: React.JSXElementConstructor< TransitionProps & { children?: React.ReactElement } @@ -62,6 +83,7 @@ export type AccordionTypeMap< /** * Props applied to the transition element. * By default, the element is based on this [`Transition`](http://reactcommunity.org/react-transition-group/transition/) component. + * @deprecated Use `slotProps.transition` instead. This prop will be removed in v7. */ TransitionProps?: TransitionProps; }; diff --git a/packages/mui-material/src/Accordion/Accordion.js b/packages/mui-material/src/Accordion/Accordion.js index a96f546ec4316b..3a3c5fa0d90605 100644 --- a/packages/mui-material/src/Accordion/Accordion.js +++ b/packages/mui-material/src/Accordion/Accordion.js @@ -126,8 +126,10 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) { expanded: expandedProp, onChange, square = false, - TransitionComponent = Collapse, - TransitionProps, + slots = {}, + slotProps = {}, + TransitionComponent: TransitionComponentProp, + TransitionProps: TransitionPropsProp, ...other } = props; @@ -165,6 +167,9 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) { const classes = useUtilityClasses(ownerState); + const TransitionSlot = slots.transition ?? TransitionComponentProp ?? Collapse; + const transitionProps = slotProps.transition ?? TransitionPropsProp; + return ( {summary} - +
{children}
-
+
); }); @@ -246,6 +251,23 @@ Accordion.propTypes /* remove-proptypes */ = { * @param {boolean} expanded The `expanded` state of the accordion. */ onChange: PropTypes.func, + /** + * The extra props for the slot components. + * You can override the existing props or add new ones. + * + * @default {} + */ + slotProps: PropTypes.shape({ + transition: PropTypes.object, + }), + /** + * The components used for each slot inside. + * + * @default {} + */ + slots: PropTypes.shape({ + transition: PropTypes.elementType, + }), /** * If `true`, rounded corners are disabled. * @default false @@ -262,12 +284,13 @@ Accordion.propTypes /* remove-proptypes */ = { /** * The component used for the transition. * [Follow this guide](/material-ui/transitions/#transitioncomponent-prop) to learn more about the requirements for this component. - * @default Collapse + * @deprecated Use `slots.transition` instead. This prop will be removed in v7. */ TransitionComponent: PropTypes.elementType, /** * Props applied to the transition element. * By default, the element is based on this [`Transition`](http://reactcommunity.org/react-transition-group/transition/) component. + * @deprecated Use `slotProps.transition` instead. This prop will be removed in v7. */ TransitionProps: PropTypes.object, }; diff --git a/packages/mui-material/src/Accordion/Accordion.test.js b/packages/mui-material/src/Accordion/Accordion.test.js index 60d596a0abf0e2..b10bebe116caa9 100644 --- a/packages/mui-material/src/Accordion/Accordion.test.js +++ b/packages/mui-material/src/Accordion/Accordion.test.js @@ -28,7 +28,12 @@ describe('', () => { refInstanceof: window.HTMLDivElement, muiName: 'MuiAccordion', testVariantProps: { variant: 'rounded' }, - skip: ['componentProp', 'componentsProp'], + slots: { + transition: { + expectedClassName: '', + }, + }, + skip: ['componentProp', 'componentsProp', 'slotPropsCallback'], })); it('should render and not be controlled', () => { @@ -210,4 +215,16 @@ describe('', () => { 'MUI: A component is changing the uncontrolled expanded state of Accordion to be controlled.', ); }); + + describe('prop: TransitionProps', () => { + it('should apply properties to the Transition component', () => { + const { getByTestId } = render( + + {minimalChildren} + , + ); + + expect(getByTestId('transition-testid')).not.to.equal(null); + }); + }); }); From 90429bd0ac23387d83f7066eb38ee1c8203f726f Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Fri, 5 Jan 2024 11:13:45 -0300 Subject: [PATCH 02/11] Update unmountOnExit behaviour docs and tests --- .../components/accordion/accordion.md | 2 +- .../src/Accordion/Accordion.test.js | 26 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/data/material/components/accordion/accordion.md b/docs/data/material/components/accordion/accordion.md index df19e8192a0318..0a7801337a6db5 100644 --- a/docs/data/material/components/accordion/accordion.md +++ b/docs/data/material/components/accordion/accordion.md @@ -92,7 +92,7 @@ This default behavior has server-side rendering and SEO in mind. If you render the Accordion Details with a big component tree nested inside, or if you have many Accordions, you may want to change this behavior by setting `unmountOnExit` to `true` inside the `TransitionProps` prop to improve performance: ```jsx - + ``` ## Accessibility diff --git a/packages/mui-material/src/Accordion/Accordion.test.js b/packages/mui-material/src/Accordion/Accordion.test.js index b10bebe116caa9..f2778ff36f0fec 100644 --- a/packages/mui-material/src/Accordion/Accordion.test.js +++ b/packages/mui-material/src/Accordion/Accordion.test.js @@ -30,7 +30,7 @@ describe('', () => { testVariantProps: { variant: 'rounded' }, slots: { transition: { - expectedClassName: '', + testWithElement: null, }, }, skip: ['componentProp', 'componentsProp', 'slotPropsCallback'], @@ -227,4 +227,28 @@ describe('', () => { expect(getByTestId('transition-testid')).not.to.equal(null); }); }); + + describe('details unmounting behavior', () => { + it('does not unmount by default', () => { + const { queryByTestId } = render( + + Summary +
Details
+
, + ); + + expect(queryByTestId('details')).not.to.equal(null); + }); + + it('unmounts if opted in via slotProps.transition', () => { + const { queryByTestId } = render( + + Summary +
Details
+
, + ); + + expect(queryByTestId('details')).to.equal(null); + }); + }); }); From 43428e556e76e15d1729758fb9432a63b19f2e6e Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Fri, 5 Jan 2024 14:42:01 -0300 Subject: [PATCH 03/11] Deprecate AccordionSummary's contentGutters class --- docs/pages/material-ui/api/accordion-summary.json | 4 +++- .../src/AccordionSummary/accordionSummaryClasses.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/pages/material-ui/api/accordion-summary.json b/docs/pages/material-ui/api/accordion-summary.json index 08388ce4937d85..05cd58c062fa4b 100644 --- a/docs/pages/material-ui/api/accordion-summary.json +++ b/docs/pages/material-ui/api/accordion-summary.json @@ -28,7 +28,9 @@ "key": "contentGutters", "className": "MuiAccordionSummary-contentGutters", "description": "Styles applied to the children wrapper element unless `disableGutters={true}`.", - "isGlobal": false + "isGlobal": false, + "isDeprecated": true, + "deprecationInfo": "Combine the .MuiAccordionSummary-gutters and .MuiAccordionSummary-content classes instead." }, { "key": "disabled", diff --git a/packages/mui-material/src/AccordionSummary/accordionSummaryClasses.ts b/packages/mui-material/src/AccordionSummary/accordionSummaryClasses.ts index 0be8219014deed..7f2c4669cb9668 100644 --- a/packages/mui-material/src/AccordionSummary/accordionSummaryClasses.ts +++ b/packages/mui-material/src/AccordionSummary/accordionSummaryClasses.ts @@ -12,7 +12,10 @@ export interface AccordionSummaryClasses { disabled: string; /** Styles applied to the root element unless `disableGutters={true}`. */ gutters: string; - /** Styles applied to the children wrapper element unless `disableGutters={true}`. */ + /** + * Styles applied to the children wrapper element unless `disableGutters={true}`. + * @deprecated Combine the [.MuiAccordionSummary-gutters](/material-ui/api/accordion-summary/#AccordionSummary-classes-gutters) and [.MuiAccordionSummary-content](/material-ui/api/accordion-summary/#AccordionSummary-classes-content) classes instead. + */ contentGutters: string; /** Styles applied to the children wrapper element. */ content: string; From 9415fb940775ad824847c80292231cec148f1784 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 11 Jan 2024 13:36:56 -0300 Subject: [PATCH 04/11] Update contentGutters deprecation info --- docs/pages/material-ui/api/accordion-summary.json | 3 +-- .../api-docs/accordion-summary/accordion-summary.json | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/pages/material-ui/api/accordion-summary.json b/docs/pages/material-ui/api/accordion-summary.json index 05cd58c062fa4b..fe7addfbbbe5d5 100644 --- a/docs/pages/material-ui/api/accordion-summary.json +++ b/docs/pages/material-ui/api/accordion-summary.json @@ -29,8 +29,7 @@ "className": "MuiAccordionSummary-contentGutters", "description": "Styles applied to the children wrapper element unless `disableGutters={true}`.", "isGlobal": false, - "isDeprecated": true, - "deprecationInfo": "Combine the .MuiAccordionSummary-gutters and .MuiAccordionSummary-content classes instead." + "isDeprecated": true }, { "key": "disabled", diff --git a/docs/translations/api-docs/accordion-summary/accordion-summary.json b/docs/translations/api-docs/accordion-summary/accordion-summary.json index 658d7dc33f85c5..6586710605ec82 100644 --- a/docs/translations/api-docs/accordion-summary/accordion-summary.json +++ b/docs/translations/api-docs/accordion-summary/accordion-summary.json @@ -19,7 +19,8 @@ "contentGutters": { "description": "Styles applied to {{nodeName}} unless {{conditions}}.", "nodeName": "the children wrapper element", - "conditions": "disableGutters={true}" + "conditions": "disableGutters={true}", + "deprecationInfo": "Combine the .MuiAccordionSummary-gutters and .MuiAccordionSummary-content classes instead." }, "disabled": { "description": "State class applied to {{nodeName}} if {{conditions}}.", From 07ccb76ef756dc4b090851f8ed15808329bf3708 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 11 Jan 2024 16:04:06 -0300 Subject: [PATCH 05/11] Refactor Accordion transition slots to use Joy's slots architecture --- .../mui-material/src/Accordion/Accordion.d.ts | 43 +-- .../mui-material/src/Accordion/Accordion.js | 21 +- .../src/Accordion/Accordion.test.js | 2 +- packages/mui-material/src/utils/slotsTypes.ts | 39 +++ .../mui-material/src/utils/useSlot.test.tsx | 290 ++++++++++++++++++ packages/mui-material/src/utils/useSlot.ts | 172 +++++++++++ 6 files changed, 539 insertions(+), 28 deletions(-) create mode 100644 packages/mui-material/src/utils/slotsTypes.ts create mode 100644 packages/mui-material/src/utils/useSlot.test.tsx create mode 100644 packages/mui-material/src/utils/useSlot.ts diff --git a/packages/mui-material/src/Accordion/Accordion.d.ts b/packages/mui-material/src/Accordion/Accordion.d.ts index 4e637137dc2b92..ab6ec22b5a2f59 100644 --- a/packages/mui-material/src/Accordion/Accordion.d.ts +++ b/packages/mui-material/src/Accordion/Accordion.d.ts @@ -5,9 +5,29 @@ import { TransitionProps } from '../transitions/transition'; import { AccordionClasses } from './accordionClasses'; import { OverridableComponent, OverrideProps } from '../OverridableComponent'; import { ExtendPaperTypeMap } from '../Paper/Paper'; +import { CreateSlotsAndSlotProps, SlotProps } from '../utils/slotsTypes'; + +export interface AccordionSlots { + /** + * The component that renders the transition. + * @default Collapse + */ + transition?: React.ElementType; +} export interface AccordionTransitionSlotPropsOverrides {} +export type AccordionSlotsAndSlotProps = CreateSlotsAndSlotProps< + AccordionSlots, + { + transition: SlotProps< + React.ElementType, + AccordionTransitionSlotPropsOverrides, + AccordionOwnerState + >; + } +>; + export type AccordionTypeMap< AdditionalProps = {}, RootComponent extends React.ElementType = 'div', @@ -49,25 +69,6 @@ export type AccordionTypeMap< * @param {boolean} expanded The `expanded` state of the accordion. */ onChange?: (event: React.SyntheticEvent, expanded: boolean) => void; - /** - * The components used for each slot inside. - * - * @default {} - */ - slots?: { - transition?: React.JSXElementConstructor< - TransitionProps & { children?: React.ReactElement } - >; - }; - /** - * The extra props for the slot components. - * You can override the existing props or add new ones. - * - * @default {} - */ - slotProps?: { - transition?: TransitionProps & AccordionTransitionSlotPropsOverrides; - }; /** * The system prop that allows defining system overrides as well as additional CSS styles. */ @@ -86,7 +87,7 @@ export type AccordionTypeMap< * @deprecated Use `slotProps.transition` instead. This prop will be removed in v7. */ TransitionProps?: TransitionProps; - }; + } & AccordionSlotsAndSlotProps; defaultComponent: RootComponent; }, 'onChange' | 'classes' @@ -112,4 +113,6 @@ export type AccordionProps< component?: React.ElementType; }; +export interface AccordionOwnerState extends AccordionProps {} + export default Accordion; diff --git a/packages/mui-material/src/Accordion/Accordion.js b/packages/mui-material/src/Accordion/Accordion.js index 3a3c5fa0d90605..88c050587c28fe 100644 --- a/packages/mui-material/src/Accordion/Accordion.js +++ b/packages/mui-material/src/Accordion/Accordion.js @@ -11,6 +11,7 @@ import Collapse from '../Collapse'; import Paper from '../Paper'; import AccordionContext from './AccordionContext'; import useControlled from '../utils/useControlled'; +import useSlot from '../utils/useSlot'; import accordionClasses, { getAccordionUtilityClass } from './accordionClasses'; const useUtilityClasses = (ownerState) => { @@ -167,8 +168,17 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) { const classes = useUtilityClasses(ownerState); - const TransitionSlot = slots.transition ?? TransitionComponentProp ?? Collapse; - const transitionProps = slotProps.transition ?? TransitionPropsProp; + const backwardCompatibleSlots = { transition: TransitionComponentProp, ...slots }; + const backwardCompatibleSlotProps = { transition: TransitionPropsProp, ...slotProps }; + + const [TransitionSlot, transitionProps] = useSlot('transition', { + elementType: Collapse, + externalForwardedProps: { + slots: backwardCompatibleSlots, + slotProps: backwardCompatibleSlotProps, + }, + ownerState, + }); return ( ', () => { testWithElement: null, }, }, - skip: ['componentProp', 'componentsProp', 'slotPropsCallback'], + skip: ['componentProp', 'componentsProp'], })); it('should render and not be controlled', () => { diff --git a/packages/mui-material/src/utils/slotsTypes.ts b/packages/mui-material/src/utils/slotsTypes.ts new file mode 100644 index 00000000000000..ce24d94a169929 --- /dev/null +++ b/packages/mui-material/src/utils/slotsTypes.ts @@ -0,0 +1,39 @@ +import { SxProps } from '@mui/system'; + +export type SlotCommonProps = { + component?: React.ElementType; + sx?: SxProps; +}; + +export type SlotProps = + // omit `color` from HTML attributes because it conflicts with Material UI `color` prop. + | (Omit, 'color'> & + TOverrides & + SlotCommonProps & + Record) + | (( + ownerState: TOwnerState, + ) => Omit, 'color'> & + TOverrides & + SlotCommonProps & + Record); + +/** + * Use the keys of `Slots` to make sure that K contains all of the keys + * + * @example CreateSlotsAndSlotProps<{ root: React.ElementType, decorator: React.ElementType }, { root: ..., decorator: ... }> + */ +export type CreateSlotsAndSlotProps> = { + /** + * The components used for each slot inside. + * @default {} + */ + slots?: Slots; + /** + * The props used for each slot inside. + * @default {} + */ + slotProps?: { + [P in keyof K]?: K[P]; + }; +}; diff --git a/packages/mui-material/src/utils/useSlot.test.tsx b/packages/mui-material/src/utils/useSlot.test.tsx new file mode 100644 index 00000000000000..38b23c00944d18 --- /dev/null +++ b/packages/mui-material/src/utils/useSlot.test.tsx @@ -0,0 +1,290 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { createRenderer } from '@mui-internal/test-utils'; +import { Popper } from '@mui/base/Popper'; +import { styled } from '../styles'; +import { SlotProps } from './slotsTypes'; +import useSlot from './useSlot'; + +describe('useSlot', () => { + const { render } = createRenderer(); + + describe('single slot', () => { + const ItemRoot = styled('button')({}); + const Item = React.forwardRef< + HTMLButtonElement, + { component?: React.ElementType; href?: string } + >((props, ref) => { + const [SlotRoot, rootProps] = useSlot('root', { + ref, + className: 'root', + elementType: ItemRoot, + externalForwardedProps: props, + ownerState: {}, + }); + return ; + }); + + it('should render correct tag', () => { + const { getByRole } = render(); + expect(getByRole('button')).toBeVisible(); + }); + + it('should change leaf component and spread props', () => { + const { getByRole } = render(); + expect(getByRole('link')).toBeVisible(); + }); + }); + + describe('multiple slots', () => { + const ItemRoot = styled('button')({}); + const ItemDecorator = styled('span')({}); + const Item = React.forwardRef< + HTMLButtonElement, + { + className?: string; + component?: React.ElementType; + href?: string; + slots?: { root?: React.ElementType; decorator?: React.ElementType }; + slotProps?: { + root?: SlotProps<'button', Record, {}>; + decorator?: SlotProps<'span', { size?: 'sm' | 'md' } & Record, {}>; + }; + } + >((props, ref) => { + const [SlotRoot, rootProps] = useSlot('root', { + ref, + className: 'root', + elementType: ItemRoot, + externalForwardedProps: props, + ownerState: {}, + }); + const [SlotDecorator, decoratorProps] = useSlot('decorator', { + className: 'decorator', + elementType: ItemDecorator, + externalForwardedProps: props, + ownerState: {}, + getSlotOwnerState: (mergedProps) => ({ + size: mergedProps.size ?? 'md', + }), + }); + return ( + + + + ); + }); + + it('should render both tags', () => { + const { getByRole } = render(); + expect(getByRole('button')).toBeVisible(); + expect(getByRole('button').firstChild).to.have.tagName('span'); + }); + + it('should have classes', () => { + const { getByRole } = render(); + expect(getByRole('button')).to.have.class('root'); + expect(getByRole('button').firstChild).to.have.class('decorator'); + }); + + it('should append classes', () => { + const { getByRole } = render( + , + ); + expect(getByRole('button')).to.have.class('root'); + expect(getByRole('button')).to.have.class('foo-bar'); + expect(getByRole('button').firstChild).to.have.class('decorator'); + expect(getByRole('button').firstChild).to.have.class('foo-bar'); + }); + + it('slot has default size `md`', () => { + const { getByRole } = render(); + expect(getByRole('button').firstChild).to.have.class('size-md'); + }); + + it('slot ownerstate should be overridable', () => { + const { getByRole } = render(); + expect(getByRole('button').firstChild).to.have.class('size-sm'); + }); + + it('slotProps has higher priority', () => { + const { getByRole } = render( + , + ); + expect(getByRole('button')).to.have.attribute('data-item', 'bar'); + }); + + it('can change root leaf component with `component` prop', () => { + const { getByRole } = render(); + expect(getByRole('link')).toBeVisible(); + }); + + it('use slotProps `component` over `component` prop', () => { + const { getByRole } = render( + , + ); + expect(getByRole('link')).toBeVisible(); + }); + + it('can change decorator leaf component', () => { + const { getByRole } = render(); + expect(getByRole('button').firstChild).to.have.tagName('div'); + }); + }); + + /** + * Simulate `Tooltip`, ...etc + */ + describe('unstyled popper as the root slot', () => { + const ItemRoot = styled('div')({}); + function Item(props: { + component?: React.ElementType; + slots?: { + root?: React.ElementType; + }; + slotProps?: { + root?: SlotProps<'div', Record, {}>; + }; + }) { + const ref = React.useRef(null); + const [SlotRoot, rootProps] = useSlot('root', { + ref, + className: 'root', + elementType: Popper, + externalForwardedProps: props, + ownerState: {}, + additionalProps: { + open: true, // !!force the popper to always visible for testing + anchorEl: () => document.createElement('div'), + }, + internalForwardedProps: { + slots: { root: ItemRoot }, + }, + }); + return ; + } + + it('should render popper with styled-component', () => { + const { getByRole } = render(); + expect(getByRole('tooltip')).toBeVisible(); + expect(getByRole('tooltip')).to.have.tagName('div'); + }); + + it('the root slot should be replaceable', () => { + const Listbox = React.forwardRef( + function Listbox({ component }, ref) { + return
    ; + }, + ); + + const { getByRole } = render(); + expect(getByRole('list')).toBeVisible(); + expect(getByRole('list')).not.to.have.attribute('class'); + // to test that the `component` prop should not forward to the custom slot. + expect(getByRole('list')).not.to.have.attribute('data-component'); + }); + + it('the root component can be changed', () => { + const { getByRole } = render(); + expect(getByRole('tooltip')).to.have.tagName('aside'); + }); + }); + + /** + * Simulate `Autocomplete`, `Select`, ...etc + */ + describe('multiple slots with unstyled popper', () => { + const ItemRoot = styled('button')({}); + const ItemListbox = styled('ul')({ + margin: 'initial', // prevent Popper error. + }); + const ItemOption = styled('div')({}); + + function Item(props: { + component?: React.ElementType; + slots?: { + root?: React.ElementType; + listbox?: React.ElementType; + option?: React.ElementType; + }; + slotProps?: { + root?: SlotProps<'button', Record, {}>; + listbox?: SlotProps<'ul', Record, {}>; + option?: SlotProps<'div', Record, {}>; + }; + }) { + const ref = React.useRef(null); + const [SlotRoot, rootProps] = useSlot('root', { + ref, + className: 'root', + elementType: ItemRoot, + externalForwardedProps: props, + ownerState: {}, + }); + const [SlotListbox, listboxProps] = useSlot('listbox', { + className: 'listbox', + elementType: Popper as unknown as 'ul', + externalForwardedProps: props, + ownerState: {}, + additionalProps: { + open: true, // !!force the popper to always visible for testing + role: 'menu', + anchorEl: () => document.createElement('div'), + }, + internalForwardedProps: { + slots: { root: ItemListbox }, + }, + }); + const [SlotOption, optionProps] = useSlot('option', { + className: 'option', + elementType: ItemOption, + externalForwardedProps: props, + ownerState: {}, + additionalProps: { + role: 'menuitem', + }, + }); + return ( + + + + + + + ); + } + + it('should render popper with styled-component', () => { + const { getByRole } = render(); + expect(getByRole('menu')).toBeVisible(); + expect(getByRole('menu')).to.have.tagName('ul'); + expect(getByRole('menu')).to.have.class('listbox'); + expect(getByRole('menuitem')).to.have.tagName('li'); + }); + + it('the listbox slot should be replaceable', () => { + function Listbox({ component }: { component?: React.ElementType }) { + return
      ; + } + + const { getByRole } = render(); + expect(getByRole('list')).toBeVisible(); + expect(getByRole('list')).not.to.have.attribute('class'); + // to test that the `component` prop should not forward to the custom slot. + expect(getByRole('list')).not.to.have.attribute('data-component'); + }); + + it('the listbox leaf component can be changed', () => { + const { getByRole } = render(); + expect(getByRole('menu')).to.have.tagName('div'); + }); + + it('the option leaf component can be changed', () => { + const { getByRole } = render(); + expect(getByRole('menuitem')).to.have.tagName('div'); + }); + }); +}); diff --git a/packages/mui-material/src/utils/useSlot.ts b/packages/mui-material/src/utils/useSlot.ts new file mode 100644 index 00000000000000..8709c030e95eab --- /dev/null +++ b/packages/mui-material/src/utils/useSlot.ts @@ -0,0 +1,172 @@ +'use client'; +import * as React from 'react'; +import { ClassValue } from 'clsx'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import { appendOwnerState, resolveComponentProps, mergeSlotProps } from '@mui/base/utils'; + +export type WithCommonProps = T & { + className?: string; + style?: React.CSSProperties; + ref?: React.Ref; +}; + +type EventHandlers = Record>; + +type ExtractComponentProps

      = P extends infer T | ((ownerState: any) => infer T) ? T : never; + +/** + * An internal function to create a Material UI slot. + * + * This is an advanced version of Base UI `useSlotProps` because Material UI allows leaf component to be customized via `component` prop + * while Base UI does not need to support leaf component customization. + * + * @param {string} name: name of the slot + * @param {object} parameters + * @returns {[Slot, slotProps]} The slot's React component and the slot's props + * + * Note: the returned slot's props + * - will never contain `component` prop. + * - might contain `as` prop. + */ +export default function useSlot< + T extends string, + ElementType extends React.ElementType, + SlotProps, + OwnerState extends {}, + ExternalSlotProps extends { component?: React.ElementType; ref?: React.Ref }, + ExternalForwardedProps extends { + component?: React.ElementType; + slots?: { [k in T]?: React.ElementType }; + slotProps?: { + [k in T]?: ExternalSlotProps | ((ownerState: OwnerState) => ExternalSlotProps); + }; + }, + AdditionalProps, + SlotOwnerState extends {}, +>( + /** + * The slot's name. All Material UI components should have `root` slot. + * + * If the name is `root`, the logic behaves differently from other slots, + * e.g. the `externalForwardedProps` are spread to `root` slot but not other slots. + */ + name: T, + parameters: (T extends 'root' // root slot must pass a `ref` as a parameter + ? { ref: React.ForwardedRef } + : { ref?: React.ForwardedRef }) & { + /** + * The slot's className + */ + className: ClassValue | ClassValue[]; + /** + * The slot's default styled-component + */ + elementType: ElementType; + /** + * The component's ownerState + */ + ownerState: OwnerState; + /** + * The `other` props from the consumer. It has to contain `component`, `slots`, and `slotProps`. + * The function will use those props to calculate the final rendered element and the returned props. + * + * If the slot is not `root`, the rest of the `externalForwardedProps` are neglected. + */ + externalForwardedProps: ExternalForwardedProps; + getSlotProps?: (other: EventHandlers) => WithCommonProps; + additionalProps?: WithCommonProps; + + // Material UI specifics + /** + * For overriding the component's ownerState for the slot. + * This is required for some components that need styling via `ownerState`. + * + * It is a function because `slotProps.{slot}` can be a function which has to be resolved first. + */ + getSlotOwnerState?: ( + mergedProps: AdditionalProps & + SlotProps & + ExternalSlotProps & + ExtractComponentProps< + Exclude[T], undefined> + >, + ) => SlotOwnerState; + /** + * props forward to `T` only if the `slotProps.*.component` is not provided. + * e.g. Autocomplete's listbox uses Popper + StyledComponent + */ + internalForwardedProps?: any; + }, +) { + const { + className, + elementType: initialElementType, + ownerState, + externalForwardedProps, + getSlotOwnerState, + internalForwardedProps, + ...useSlotPropsParams + } = parameters; + const { + component: rootComponent, + slots = { [name]: undefined }, + slotProps = { [name]: undefined }, + ...other + } = externalForwardedProps; + + const elementType = slots[name] || initialElementType; + + // `slotProps[name]` can be a callback that receives the component's ownerState. + // `resolvedComponentsProps` is always a plain object. + const resolvedComponentsProps = resolveComponentProps(slotProps[name], ownerState); + + const { + props: { component: slotComponent, ...mergedProps }, + internalRef, + } = mergeSlotProps({ + className, + ...useSlotPropsParams, + externalForwardedProps: name === 'root' ? other : undefined, + externalSlotProps: resolvedComponentsProps, + }); + + const ref = useForkRef(internalRef, resolvedComponentsProps?.ref, parameters.ref); + + const slotOwnerState = getSlotOwnerState ? getSlotOwnerState(mergedProps as any) : {}; + const finalOwnerState = { ...ownerState, ...slotOwnerState } as any; + + const LeafComponent = (name === 'root' ? slotComponent || rootComponent : slotComponent) as + | React.ElementType + | undefined; + + const props = appendOwnerState( + elementType, + { + ...(name === 'root' && !rootComponent && !slots[name] && internalForwardedProps), + ...(name !== 'root' && !slots[name] && internalForwardedProps), + ...mergedProps, + ...(LeafComponent && { + as: LeafComponent, + }), + ref, + }, + finalOwnerState, + ); + + Object.keys(slotOwnerState).forEach((propName) => { + delete props[propName]; + }); + + return [elementType, props] as [ + ElementType, + { + className: string; + ownerState: OwnerState & SlotOwnerState; + } & AdditionalProps & + SlotProps & + ExternalSlotProps & + ExtractComponentProps< + Exclude[T], undefined> + >, + ]; +} From d54299e968e8524ea7eeaa9ab4c188ed8ab98890 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 11 Jan 2024 16:15:23 -0300 Subject: [PATCH 06/11] Base SlotProps type in Base UI's SlotComponentProps --- .../mui-material/src/Accordion/Accordion.d.ts | 2 +- .../src/utils/{slotsTypes.ts => types.ts} | 18 ++++++------------ .../mui-material/src/utils/useSlot.test.tsx | 2 +- 3 files changed, 8 insertions(+), 14 deletions(-) rename packages/mui-material/src/utils/{slotsTypes.ts => types.ts} (55%) diff --git a/packages/mui-material/src/Accordion/Accordion.d.ts b/packages/mui-material/src/Accordion/Accordion.d.ts index ab6ec22b5a2f59..67880b56dc4032 100644 --- a/packages/mui-material/src/Accordion/Accordion.d.ts +++ b/packages/mui-material/src/Accordion/Accordion.d.ts @@ -5,7 +5,7 @@ import { TransitionProps } from '../transitions/transition'; import { AccordionClasses } from './accordionClasses'; import { OverridableComponent, OverrideProps } from '../OverridableComponent'; import { ExtendPaperTypeMap } from '../Paper/Paper'; -import { CreateSlotsAndSlotProps, SlotProps } from '../utils/slotsTypes'; +import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types'; export interface AccordionSlots { /** diff --git a/packages/mui-material/src/utils/slotsTypes.ts b/packages/mui-material/src/utils/types.ts similarity index 55% rename from packages/mui-material/src/utils/slotsTypes.ts rename to packages/mui-material/src/utils/types.ts index ce24d94a169929..9a360accc8333c 100644 --- a/packages/mui-material/src/utils/slotsTypes.ts +++ b/packages/mui-material/src/utils/types.ts @@ -1,22 +1,16 @@ import { SxProps } from '@mui/system'; +import { SlotComponentProps } from '@mui/base'; export type SlotCommonProps = { component?: React.ElementType; sx?: SxProps; }; -export type SlotProps = - // omit `color` from HTML attributes because it conflicts with Material UI `color` prop. - | (Omit, 'color'> & - TOverrides & - SlotCommonProps & - Record) - | (( - ownerState: TOwnerState, - ) => Omit, 'color'> & - TOverrides & - SlotCommonProps & - Record); +export type SlotProps< + TSlotComponent extends React.ElementType, + TOverrides, + TOwnerState, +> = SlotComponentProps; /** * Use the keys of `Slots` to make sure that K contains all of the keys diff --git a/packages/mui-material/src/utils/useSlot.test.tsx b/packages/mui-material/src/utils/useSlot.test.tsx index 38b23c00944d18..7aab3a0233e590 100644 --- a/packages/mui-material/src/utils/useSlot.test.tsx +++ b/packages/mui-material/src/utils/useSlot.test.tsx @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { createRenderer } from '@mui-internal/test-utils'; import { Popper } from '@mui/base/Popper'; import { styled } from '../styles'; -import { SlotProps } from './slotsTypes'; +import { SlotProps } from './types'; import useSlot from './useSlot'; describe('useSlot', () => { From 7101a1040c425e8684d6a1cb2a610487a15c7403 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 11 Jan 2024 16:33:23 -0300 Subject: [PATCH 07/11] Update accordion docs json --- docs/pages/material-ui/api/accordion.json | 10 +++++++++- docs/translations/api-docs/accordion/accordion.json | 7 +++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/pages/material-ui/api/accordion.json b/docs/pages/material-ui/api/accordion.json index 2791940686d8bc..13b67ea903c107 100644 --- a/docs/pages/material-ui/api/accordion.json +++ b/docs/pages/material-ui/api/accordion.json @@ -14,7 +14,7 @@ } }, "slotProps": { - "type": { "name": "shape", "description": "{ transition?: object }" }, + "type": { "name": "shape", "description": "{ transition?: func
      | object }" }, "default": "{}" }, "slots": { @@ -45,6 +45,14 @@ "import Accordion from '@mui/material/Accordion';", "import { Accordion } from '@mui/material';" ], + "slots": [ + { + "name": "transition", + "description": "The component that renders the transition.", + "default": "Collapse", + "class": null + } + ], "classes": [ { "key": "disabled", diff --git a/docs/translations/api-docs/accordion/accordion.json b/docs/translations/api-docs/accordion/accordion.json index 957195a3b50f53..934dbc6106edfd 100644 --- a/docs/translations/api-docs/accordion/accordion.json +++ b/docs/translations/api-docs/accordion/accordion.json @@ -18,9 +18,7 @@ "expanded": "The expanded state of the accordion." } }, - "slotProps": { - "description": "The extra props for the slot components. You can override the existing props or add new ones." - }, + "slotProps": { "description": "The props used for each slot inside." }, "slots": { "description": "The components used for each slot inside." }, "square": { "description": "If true, rounded corners are disabled." }, "sx": { @@ -60,5 +58,6 @@ "nodeName": "the root element", "conditions": "square={true}" } - } + }, + "slotDescriptions": { "transition": "The component that renders the transition." } } From a30d83cfd4df561b0957f8de4111aa2c72571b64 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Thu, 11 Jan 2024 21:26:21 -0300 Subject: [PATCH 08/11] Fix default expanded --- packages/mui-material/src/Accordion/Accordion.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mui-material/src/Accordion/Accordion.js b/packages/mui-material/src/Accordion/Accordion.js index 88c050587c28fe..e890d2f7ad7768 100644 --- a/packages/mui-material/src/Accordion/Accordion.js +++ b/packages/mui-material/src/Accordion/Accordion.js @@ -180,6 +180,8 @@ const Accordion = React.forwardRef(function Accordion(inProps, ref) { ownerState, }); + delete transitionProps.ownerState; + return ( Date: Tue, 16 Jan 2024 11:41:35 -0300 Subject: [PATCH 09/11] Use the same TransitionComponent type for slots.transition --- docs/pages/material-ui/api/accordion.json | 2 +- docs/translations/api-docs/accordion/accordion.json | 4 +++- packages/mui-material/src/Accordion/Accordion.d.ts | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/pages/material-ui/api/accordion.json b/docs/pages/material-ui/api/accordion.json index 13b67ea903c107..c648b2abdff691 100644 --- a/docs/pages/material-ui/api/accordion.json +++ b/docs/pages/material-ui/api/accordion.json @@ -48,7 +48,7 @@ "slots": [ { "name": "transition", - "description": "The component that renders the transition.", + "description": "The component that renders the transition.\n[Follow this guide](/material-ui/transitions/#transitioncomponent-prop) to learn more about the requirements for this component.", "default": "Collapse", "class": null } diff --git a/docs/translations/api-docs/accordion/accordion.json b/docs/translations/api-docs/accordion/accordion.json index 934dbc6106edfd..5007bcb2c4177b 100644 --- a/docs/translations/api-docs/accordion/accordion.json +++ b/docs/translations/api-docs/accordion/accordion.json @@ -59,5 +59,7 @@ "conditions": "square={true}" } }, - "slotDescriptions": { "transition": "The component that renders the transition." } + "slotDescriptions": { + "transition": "The component that renders the transition.\n[Follow this guide](/material-ui/transitions/#transitioncomponent-prop) to learn more about the requirements for this component." + } } diff --git a/packages/mui-material/src/Accordion/Accordion.d.ts b/packages/mui-material/src/Accordion/Accordion.d.ts index 67880b56dc4032..2b6a15fbc26a37 100644 --- a/packages/mui-material/src/Accordion/Accordion.d.ts +++ b/packages/mui-material/src/Accordion/Accordion.d.ts @@ -10,9 +10,12 @@ import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types'; export interface AccordionSlots { /** * The component that renders the transition. + * [Follow this guide](/material-ui/transitions/#transitioncomponent-prop) to learn more about the requirements for this component. * @default Collapse */ - transition?: React.ElementType; + transition?: React.JSXElementConstructor< + TransitionProps & { children?: React.ReactElement } + >; } export interface AccordionTransitionSlotPropsOverrides {} From ae47cb5a1feedd39003ba4c9506ba93cf979bba4 Mon Sep 17 00:00:00 2001 From: Diego Andai Date: Tue, 16 Jan 2024 12:13:09 -0300 Subject: [PATCH 10/11] Update accordion transition demos to use slots --- .../material/components/accordion/AccordionTransition.js | 4 ++-- .../material/components/accordion/AccordionTransition.tsx | 6 +++--- docs/data/material/components/accordion/accordion.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/data/material/components/accordion/AccordionTransition.js b/docs/data/material/components/accordion/AccordionTransition.js index 1eab8dc1a5543f..81aeeee8744444 100644 --- a/docs/data/material/components/accordion/AccordionTransition.js +++ b/docs/data/material/components/accordion/AccordionTransition.js @@ -18,8 +18,8 @@ export default function AccordionTransition() { Date: Tue, 16 Jan 2024 12:30:54 -0300 Subject: [PATCH 11/11] Remove reference to `TransitionProps` in docs --- docs/data/material/components/accordion/accordion.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data/material/components/accordion/accordion.md b/docs/data/material/components/accordion/accordion.md index b77b56e4522d33..f96a398f6888d2 100644 --- a/docs/data/material/components/accordion/accordion.md +++ b/docs/data/material/components/accordion/accordion.md @@ -89,7 +89,7 @@ The demo below also shows a bit of visual customziation. The Accordion content is mounted by default even if it's not expanded. This default behavior has server-side rendering and SEO in mind. -If you render the Accordion Details with a big component tree nested inside, or if you have many Accordions, you may want to change this behavior by setting `unmountOnExit` to `true` inside the `TransitionProps` prop to improve performance: +If you render the Accordion Details with a big component tree nested inside, or if you have many Accordions, you may want to change this behavior by setting `unmountOnExit` to `true` inside the `slotProps.transition` prop to improve performance: ```jsx