From ba35dfcfa205fdb73d90d8d86a539b83e22f0139 Mon Sep 17 00:00:00 2001 From: Siriwat K Date: Mon, 6 Jan 2025 20:10:28 +0700 Subject: [PATCH] [material-ui] Add `mergeSlotProps` for extending components (#44809) --- .../guides/composition/composition.md | 43 ++++++++ packages/mui-material/src/utils/index.d.ts | 1 + packages/mui-material/src/utils/index.js | 1 + .../src/utils/mergeSlotProps.spec.tsx | 64 ++++++++++++ .../src/utils/mergeSlotProps.test.ts | 99 +++++++++++++++++++ .../mui-material/src/utils/mergeSlotProps.ts | 43 ++++++++ 6 files changed, 251 insertions(+) create mode 100644 packages/mui-material/src/utils/mergeSlotProps.spec.tsx create mode 100644 packages/mui-material/src/utils/mergeSlotProps.test.ts create mode 100644 packages/mui-material/src/utils/mergeSlotProps.ts diff --git a/docs/data/material/guides/composition/composition.md b/docs/data/material/guides/composition/composition.md index 00190c9f867791..cc77b9161e6e53 100644 --- a/docs/data/material/guides/composition/composition.md +++ b/docs/data/material/guides/composition/composition.md @@ -22,6 +22,49 @@ WrappedIcon.muiName = Icon.muiName; {{"demo": "Composition.js"}} +### Forwarding slot props + +Use the `mergeSlotProps` utility function to merge custom props with the slot props. +If the arguments are functions then they'll be resolved before merging, and the result from the first argument will override the second. + +```jsx +import Tooltip, { TooltipProps } from '@mui/material/Tooltip'; +import { mergeSlotProps } from '@mui/material/utils'; + +export const CustomTooltip = (props: TooltipProps) => { + const { children, title, sx: sxProps } = props; + + return ( + {title}} + slotProps={{ + ...props.slotProps, + popper: mergeSlotProps(props.slotProps?.popper, { + className: 'custom-tooltip-popper', + disablePortal: true, + placement: 'top', + }), + }} + > + {children} + + ); +}; +``` + +:::info +`className` values are concatenated rather than overriding one another. +In the snippet above, the `custom-tooltip-popper` class is applied to the Tooltip's popper slot. +If you added another `className` via the `slotProps` prop on the Custom Tooltip—as shown below—then both would be present on the rendered popper slot: + +```js + +``` + +The popper slot in the original example would now have both classes applied to it, in addition to any others that may be present: `"[…] custom-tooltip-popper foo"`. +::: + ## Component prop Material UI allows you to change the root element that will be rendered via a prop called `component`. diff --git a/packages/mui-material/src/utils/index.d.ts b/packages/mui-material/src/utils/index.d.ts index ccba6bbec929e1..d5889c9328fc49 100644 --- a/packages/mui-material/src/utils/index.d.ts +++ b/packages/mui-material/src/utils/index.d.ts @@ -16,4 +16,5 @@ export { default as unsupportedProp } from './unsupportedProp'; export { default as useControlled } from './useControlled'; export { default as useEventCallback } from './useEventCallback'; export { default as useForkRef } from './useForkRef'; +export { default as mergeSlotProps } from './mergeSlotProps'; export * from './types'; diff --git a/packages/mui-material/src/utils/index.js b/packages/mui-material/src/utils/index.js index a43e01cd154380..0dde8237b711af 100644 --- a/packages/mui-material/src/utils/index.js +++ b/packages/mui-material/src/utils/index.js @@ -18,6 +18,7 @@ export { default as unsupportedProp } from './unsupportedProp'; export { default as useControlled } from './useControlled'; export { default as useEventCallback } from './useEventCallback'; export { default as useForkRef } from './useForkRef'; +export { default as mergeSlotProps } from './mergeSlotProps'; // TODO: remove this export once ClassNameGenerator is stable // eslint-disable-next-line @typescript-eslint/naming-convention export const unstable_ClassNameGenerator = { diff --git a/packages/mui-material/src/utils/mergeSlotProps.spec.tsx b/packages/mui-material/src/utils/mergeSlotProps.spec.tsx new file mode 100644 index 00000000000000..61801e166311b1 --- /dev/null +++ b/packages/mui-material/src/utils/mergeSlotProps.spec.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { expectType } from '@mui/types'; +import Box from '@mui/material/Box'; +import Tooltip, { TooltipProps } from '@mui/material/Tooltip'; +import { mergeSlotProps, SlotComponentProps } from '@mui/material/utils'; + +// without explicit type +const slotProps = mergeSlotProps(undefined, { className: 'foo', 'aria-label': 'bar' }); +expectType, typeof slotProps>(slotProps); + +// explicit external slot props type +const defaultProps = mergeSlotProps<{ foo: string }>(undefined, { foo: 'bar' }); +expectType<{ foo: string }, typeof defaultProps>(defaultProps); + +// explicit slot props type with function +const externalSlotProps = mergeSlotProps< + (ownerState: { foo: string }) => { foo: string }, + { foo: string } +>(() => ({ foo: 'external' }), { foo: 'default' })({ foo: '' }); +expectType<{ foo: string }, typeof externalSlotProps>(externalSlotProps); + +export const CustomTooltip = (props: TooltipProps) => { + const { children, title } = props; + + return ( + {title}} + slotProps={{ + ...props.slotProps, + popper: mergeSlotProps(props.slotProps?.popper, { + className: 'custom-tooltip', + disablePortal: true, + placement: 'top', + }), + }} + > + {children} + + ); +}; + +export const CustomTooltip2 = (props: TooltipProps) => { + const { children, title } = props; + + // to ensure that the return type of `mergeSlotProps` is correctly inferred + const popperProps = mergeSlotProps(props.slotProps?.popper, { + className: 'custom-tooltip', + disablePortal: true, + placement: 'top', + }); + return ( + {title}} + slotProps={{ + ...props.slotProps, + popper: popperProps, + }} + > + {children} + + ); +}; diff --git a/packages/mui-material/src/utils/mergeSlotProps.test.ts b/packages/mui-material/src/utils/mergeSlotProps.test.ts new file mode 100644 index 00000000000000..b6e93114515ad3 --- /dev/null +++ b/packages/mui-material/src/utils/mergeSlotProps.test.ts @@ -0,0 +1,99 @@ +import { expect } from 'chai'; + +import mergeSlotProps from './mergeSlotProps'; + +type OwnerState = { + className: string; + 'aria-label'?: string; +}; + +describe('utils/index.js', () => { + describe('mergeSlotProps', () => { + it('external slot props is undefined', () => { + expect( + mergeSlotProps(undefined, { + className: 'default', + 'aria-label': 'foo', + }), + ).to.deep.equal({ + className: 'default', + 'aria-label': 'foo', + }); + }); + + it('external slot props should override', () => { + expect( + mergeSlotProps( + { className: 'external', 'aria-label': 'bar' }, + { className: 'default', 'aria-label': 'foo' }, + ), + ).to.deep.equal({ + className: 'default external', + 'aria-label': 'bar', + }); + }); + + it('external slot props is a function', () => { + expect( + mergeSlotProps<(ownerState: OwnerState) => OwnerState, OwnerState>( + () => ({ + className: 'external', + }), + { className: 'default', 'aria-label': 'foo' }, + )({ className: '' }), + ).to.deep.equal({ + className: 'default external', + 'aria-label': 'foo', + }); + }); + + it('default slot props is a function', () => { + expect( + mergeSlotProps OwnerState>( + { + className: 'external', + }, + () => ({ className: 'default', 'aria-label': 'foo' }), + )({ className: 'base' }), + ).to.deep.equal({ + className: 'base default external', + 'aria-label': 'foo', + }); + }); + + it('both slot props are functions', () => { + expect( + mergeSlotProps<(ownerState: OwnerState) => OwnerState>( + () => ({ + className: 'external', + }), + () => ({ + className: 'default', + 'aria-label': 'foo', + }), + )({ className: 'base' }), + ).to.deep.equal({ + className: 'base default external', + 'aria-label': 'foo', + }); + }); + + it('external callback should be called with default slot props', () => { + expect( + mergeSlotProps<(ownerState: OwnerState) => OwnerState>( + ({ 'aria-label': ariaLabel }) => ({ + className: 'external', + 'aria-label': ariaLabel === 'foo' ? 'bar' : 'baz', + }), + () => ({ + className: 'default', + 'aria-label': 'foo', + }), + )({ className: 'base', 'aria-label': 'unknown' }), + ).to.deep.equal({ + className: 'base default external', + 'aria-label': 'bar', + }); + }); + }); +}); diff --git a/packages/mui-material/src/utils/mergeSlotProps.ts b/packages/mui-material/src/utils/mergeSlotProps.ts new file mode 100644 index 00000000000000..f458a4c985d8a8 --- /dev/null +++ b/packages/mui-material/src/utils/mergeSlotProps.ts @@ -0,0 +1,43 @@ +import { SlotComponentProps } from '@mui/utils'; +import clsx from 'clsx'; + +export default function mergeSlotProps< + T extends SlotComponentProps, + K = T, + // infer external slot props first to provide autocomplete for default slot props + U = T extends Function ? T : K extends Function ? K : T extends undefined ? K : T, +>(externalSlotProps: T | undefined, defaultSlotProps: K): U { + if (!externalSlotProps) { + return defaultSlotProps as unknown as U; + } + if (typeof externalSlotProps === 'function' || typeof defaultSlotProps === 'function') { + return ((ownerState: Record) => { + const defaultSlotPropsValue = + typeof defaultSlotProps === 'function' ? defaultSlotProps(ownerState) : defaultSlotProps; + const externalSlotPropsValue = + typeof externalSlotProps === 'function' + ? externalSlotProps({ ...ownerState, ...defaultSlotPropsValue }) + : externalSlotProps; + + const className = clsx( + ownerState?.className, + defaultSlotPropsValue?.className, + externalSlotPropsValue?.className, + ); + return { + ...defaultSlotPropsValue, + ...externalSlotPropsValue, + ...(!!className && { className }), + }; + }) as U; + } + const className = clsx( + (defaultSlotProps as Record)?.className, + externalSlotProps?.className, + ); + return { + ...defaultSlotProps, + ...externalSlotProps, + ...(!!className && { className }), + } as U; +}