From e19c84a18ce3dd624984758d65879fc87e8f725d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 2 Jun 2022 15:18:42 +0200 Subject: [PATCH 1/4] Accept function in Menu's componentsProps --- .../src/MenuUnstyled/MenuUnstyled.test.tsx | 8 +---- .../src/MenuUnstyled/MenuUnstyled.tsx | 30 ++++++++++------ .../src/MenuUnstyled/MenuUnstyled.types.ts | 15 ++++++-- packages/mui-base/src/MenuUnstyled/useMenu.ts | 34 ++++++++++++------- .../src/MenuUnstyled/useMenu.types.ts | 9 +++-- packages/mui-base/src/utils/types.ts | 6 ++-- 6 files changed, 65 insertions(+), 37 deletions(-) diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx index 8529227bd92cad..60408943f15377 100644 --- a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.test.tsx @@ -34,13 +34,7 @@ describe('MenuUnstyled', () => { expectedClassName: menuUnstyledClasses.listbox, }, }, - skip: [ - 'reactTestRenderer', - 'propsSpread', - 'componentProp', - 'componentsProp', - 'componentsPropsCallbacks', // not implemented yet - ], + skip: ['reactTestRenderer', 'propsSpread', 'componentProp', 'componentsProp'], })); describe('keyboard navigation', () => { diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx index 70cbe246ffb7e1..3e727dc1207240 100644 --- a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { HTMLElementType, refType } from '@mui/utils'; +import { HTMLElementType, refType, unstable_useForkRef as useForkRef } from '@mui/utils'; import appendOwnerState from '../utils/appendOwnerState'; import MenuUnstyledContext, { MenuUnstyledContextType } from './MenuUnstyledContext'; import { @@ -15,6 +15,7 @@ import useMenu from './useMenu'; import composeClasses from '../composeClasses'; import PopperUnstyled from '../PopperUnstyled'; import { WithOptionalOwnerState } from '../utils'; +import resolveComponentProps from '../utils/resolveComponentProps'; function getUtilityClasses(ownerState: MenuUnstyledOwnerState) { const { open } = ownerState; @@ -48,6 +49,7 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( components = {}, componentsProps = {}, keepMounted = false, + listboxId, onClose, open = false, ...other @@ -64,8 +66,7 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( } = useMenu({ open, onClose, - listboxRef: componentsProps.listbox?.ref, - listboxId: componentsProps.listbox?.id, + listboxId, }); React.useImperativeHandle( @@ -85,6 +86,7 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( const classes = getUtilityClasses(ownerState); const Popper = component ?? components.Root ?? PopperUnstyled; + const popperComponentProps = resolveComponentProps(componentsProps.root, ownerState); const popperProps: MenuUnstyledRootSlotProps = appendOwnerState( Popper, { @@ -93,19 +95,23 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( open, keepMounted, role: undefined, - ...componentsProps.root, - className: clsx(classes.root, className, componentsProps.root?.className), + ...popperComponentProps, + className: clsx(classes.root, className, popperComponentProps?.className), + ref: useForkRef(popperComponentProps?.ref, forwardedRef), }, ownerState, ) as MenuUnstyledRootSlotProps; const Listbox = components.Listbox ?? 'ul'; + const listboxComponentProps = resolveComponentProps(componentsProps.listbox, ownerState); + const propsFromHook = getListboxProps(); const listboxProps: WithOptionalOwnerState = appendOwnerState( Listbox, { - ...componentsProps.listbox, - ...getListboxProps(), - className: clsx(classes.listbox, componentsProps.listbox?.className), + ...propsFromHook, + ...listboxComponentProps, + className: clsx(classes.listbox, listboxComponentProps?.className), + ref: useForkRef(propsFromHook.ref, listboxComponentProps?.ref), }, ownerState, ); @@ -170,8 +176,8 @@ MenuUnstyled.propTypes /* remove-proptypes */ = { * @ignore */ componentsProps: PropTypes.shape({ - listbox: PropTypes.object, - root: PropTypes.object, + listbox: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }), /** * Always keep the menu in the DOM. @@ -180,6 +186,10 @@ MenuUnstyled.propTypes /* remove-proptypes */ = { * @default false */ keepMounted: PropTypes.bool, + /** + * @ignore + */ + listboxId: PropTypes.string, /** * Triggered when focus leaves the menu and the menu should close. */ diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts index 6fca891e6013a6..a96cee5f866faa 100644 --- a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts @@ -1,5 +1,6 @@ import React from 'react'; import PopperUnstyled, { PopperUnstyledProps } from '../PopperUnstyled'; +import { SlotComponentProps } from '../utils'; import { UseMenuListboxSlotProps } from './useMenu.types'; export interface MenuUnstyledComponentsPropsOverrides {} @@ -29,9 +30,16 @@ export interface MenuUnstyledProps { Listbox?: React.ElementType; }; componentsProps?: { - root?: Partial> & - MenuUnstyledComponentsPropsOverrides; - listbox?: React.ComponentPropsWithRef<'ul'> & MenuUnstyledComponentsPropsOverrides; + root?: SlotComponentProps< + typeof PopperUnstyled, + MenuUnstyledComponentsPropsOverrides, + MenuUnstyledOwnerState + >; + listbox?: SlotComponentProps< + 'ul', + MenuUnstyledComponentsPropsOverrides, + MenuUnstyledOwnerState + >; }; /** * Always keep the menu in the DOM. @@ -40,6 +48,7 @@ export interface MenuUnstyledProps { * @default false */ keepMounted?: boolean; + listboxId?: string; /** * Triggered when focus leaves the menu and the menu should close. */ diff --git a/packages/mui-base/src/MenuUnstyled/useMenu.ts b/packages/mui-base/src/MenuUnstyled/useMenu.ts index 70d7625747a32f..aeeb24c456a071 100644 --- a/packages/mui-base/src/MenuUnstyled/useMenu.ts +++ b/packages/mui-base/src/MenuUnstyled/useMenu.ts @@ -7,7 +7,12 @@ import { useListbox, ActionTypes, } from '../ListboxUnstyled'; -import { MenuItemMetadata, MenuItemState, UseMenuParameters } from './useMenu.types'; +import { + MenuItemMetadata, + MenuItemState, + UseMenuListboxSlotProps, + UseMenuParameters, +} from './useMenu.types'; import { EventHandlers } from '../utils'; function stateReducer( @@ -38,7 +43,7 @@ function stateReducer( return newState; } -export default function useMenu(parameters: UseMenuParameters) { +export default function useMenu(parameters: UseMenuParameters = {}) { const { listboxRef: listboxRefProp, open = false, onClose, listboxId } = parameters; const [menuItems, setMenuItems] = React.useState>({}); @@ -97,8 +102,8 @@ export default function useMenu(parameters: UseMenuParameters) { } }, [open, highlightFirstItem]); - const createHandleKeyDown = (otherHandlers?: EventHandlers) => (e: React.KeyboardEvent) => { - otherHandlers?.onKeyDown?.(e); + const createHandleKeyDown = (otherHandlers: EventHandlers) => (e: React.KeyboardEvent) => { + otherHandlers.onKeyDown?.(e); if (e.defaultPrevented) { return; } @@ -108,8 +113,8 @@ export default function useMenu(parameters: UseMenuParameters) { } }; - const createHandleBlur = (otherHandlers?: EventHandlers) => (e: React.FocusEvent) => { - otherHandlers?.onBlur(e); + const createHandleBlur = (otherHandlers: EventHandlers) => (e: React.FocusEvent) => { + otherHandlers.onBlur?.(e); if (!listboxRef.current?.contains(e.relatedTarget)) { onClose?.(); @@ -123,15 +128,20 @@ export default function useMenu(parameters: UseMenuParameters) { } }, [highlightedOption, menuItems]); - const getListboxProps = (otherHandlers?: EventHandlers) => ({ - ...otherHandlers, - ...getRootProps({ + const getListboxProps = ( + otherHandlers: TOther = {} as TOther, + ): UseMenuListboxSlotProps => { + const rootProps = getRootProps({ ...otherHandlers, onBlur: createHandleBlur(otherHandlers), onKeyDown: createHandleKeyDown(otherHandlers), - }), - role: 'menu', - }); + }); + return { + ...otherHandlers, + ...rootProps, + role: 'menu', + }; + }; const getItemState = (id: string): MenuItemState => { const { disabled, highlighted } = getOptionState(id); diff --git a/packages/mui-base/src/MenuUnstyled/useMenu.types.ts b/packages/mui-base/src/MenuUnstyled/useMenu.types.ts index 14a8c4a90b66cf..25fcf8dc5b141a 100644 --- a/packages/mui-base/src/MenuUnstyled/useMenu.types.ts +++ b/packages/mui-base/src/MenuUnstyled/useMenu.types.ts @@ -4,8 +4,8 @@ import { UseListboxRootSlotProps } from '../ListboxUnstyled'; export interface MenuItemMetadata { id: string; disabled: boolean; - ref: React.RefObject; label?: string; + ref: React.RefObject; } export interface MenuItemState { @@ -17,7 +17,7 @@ export interface UseMenuParameters { open?: boolean; onClose?: () => void; listboxId?: string; - listboxRef?: React.Ref; + listboxRef?: React.Ref; } interface UseMenuListboxSlotEventHandlers { @@ -27,4 +27,7 @@ interface UseMenuListboxSlotEventHandlers { export type UseMenuListboxSlotProps = UseListboxRootSlotProps< Omit & UseMenuListboxSlotEventHandlers -> & { role: React.AriaRole }; +> & { + ref: React.Ref; + role: React.AriaRole; +}; diff --git a/packages/mui-base/src/utils/types.ts b/packages/mui-base/src/utils/types.ts index c7a4da73ca0fbb..b58f09b5332aa3 100644 --- a/packages/mui-base/src/utils/types.ts +++ b/packages/mui-base/src/utils/types.ts @@ -4,5 +4,7 @@ export type WithOptionalOwnerState = Omit>; export type SlotComponentProps = - | (React.ComponentPropsWithRef & TOverrides) - | ((ownerState: TOwnerState) => React.ComponentPropsWithRef & TOverrides); + | (Partial> & TOverrides) + | (( + ownerState: TOwnerState, + ) => Partial> & TOverrides); From 8757572767292a864626782427ad547896b6cd14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 2 Jun 2022 16:12:04 +0200 Subject: [PATCH 2/4] Accept functions in MenuItemUnstyled's componentsProps --- .../MenuItemUnstyled/MenuItemUnstyled.test.tsx | 1 - .../src/MenuItemUnstyled/MenuItemUnstyled.tsx | 17 +++++++++-------- .../MenuItemUnstyled/MenuItemUnstyled.types.ts | 9 +++++++-- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx index 3813ff1dd1c538..f0fb74abcfe60c 100644 --- a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.test.tsx @@ -45,7 +45,6 @@ describe('MenuItemUnstyled', () => { }, skip: [ 'reactTestRenderer', // Need to be wrapped in MenuUnstyledContext - 'componentsPropsCallbacks', // not implemented yet ], })); }); diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx index a5314d544b570b..fbdd04ba84d77d 100644 --- a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx @@ -1,13 +1,14 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { MenuItemOwnerState, MenuItemUnstyledProps } from './MenuItemUnstyled.types'; +import { MenuItemUnstyledOwnerState, MenuItemUnstyledProps } from './MenuItemUnstyled.types'; import { appendOwnerState } from '../utils'; import { getMenuItemUnstyledUtilityClass } from './menuItemUnstyledClasses'; import useMenuItem from './useMenuItem'; import composeClasses from '../composeClasses'; +import resolveComponentProps from '../utils/resolveComponentProps'; -function getUtilityClasses(ownerState: MenuItemOwnerState) { +function getUtilityClasses(ownerState: MenuItemUnstyledOwnerState) { const { disabled, focusVisible } = ownerState; const slots = { @@ -42,25 +43,25 @@ const MenuItemUnstyled = React.forwardRef(function MenuItemUnstyled( ...other } = props; - const Root = component ?? components.Root ?? 'li'; - const { getRootProps, disabled, focusVisible } = useMenuItem({ disabled: disabledProp, ref, label, }); - const ownerState: MenuItemOwnerState = { ...props, disabled, focusVisible }; + const ownerState: MenuItemUnstyledOwnerState = { ...props, disabled, focusVisible }; const classes = getUtilityClasses(ownerState); + const Root = component ?? components.Root ?? 'li'; + const rootComponentProps = resolveComponentProps(componentsProps.root, ownerState); const rootProps = appendOwnerState( Root, { ...other, - ...componentsProps.root, ...getRootProps(other), - className: clsx(classes.root, className, componentsProps.root?.className), + ...rootComponentProps, + className: clsx(classes.root, className, rootComponentProps?.className), }, ownerState, ); @@ -95,7 +96,7 @@ MenuItemUnstyled.propTypes /* remove-proptypes */ = { * @ignore */ componentsProps: PropTypes.shape({ - root: PropTypes.object, + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), }), /** * If `true`, the menu item will be disabled. diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts index 4ce538ca630728..d3fa87ae14c218 100644 --- a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.types.ts @@ -1,8 +1,9 @@ import * as React from 'react'; +import { SlotComponentProps } from '../utils'; export interface MenuItemUnstyledComponentsPropsOverrides {} -export interface MenuItemOwnerState extends MenuItemUnstyledProps { +export interface MenuItemUnstyledOwnerState extends MenuItemUnstyledProps { disabled: boolean; focusVisible: boolean; } @@ -21,7 +22,11 @@ export interface MenuItemUnstyledProps { Root?: React.ElementType; }; componentsProps?: { - root?: React.ComponentPropsWithRef<'li'> & MenuItemUnstyledComponentsPropsOverrides; + root?: SlotComponentProps< + 'li', + MenuItemUnstyledComponentsPropsOverrides, + MenuItemUnstyledOwnerState + >; }; /** * A text representation of the menu item's content. From 40bc0500fe529de7945ad373ff55e549dd06db65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Mon, 6 Jun 2022 12:15:10 +0200 Subject: [PATCH 3/4] Create and use the useSlotProps hook --- .../src/MenuItemUnstyled/MenuItemUnstyled.tsx | 21 +-- .../src/MenuUnstyled/MenuUnstyled.tsx | 46 +++-- .../src/MenuUnstyled/MenuUnstyled.types.ts | 3 +- packages/mui-base/src/utils/index.ts | 1 + .../mui-base/src/utils/mergeSlotProps.test.ts | 123 ++++++++++++++ packages/mui-base/src/utils/mergeSlotProps.ts | 158 ++++++++++++++++++ .../src/utils/omitEventHandlers.spec.ts | 31 ++++ .../src/utils/omitEventHandlers.test.ts | 29 ++++ .../mui-base/src/utils/omitEventHandlers.ts | 43 +++++ packages/mui-base/src/utils/useSlotProps.ts | 85 ++++++++++ 10 files changed, 499 insertions(+), 41 deletions(-) create mode 100644 packages/mui-base/src/utils/mergeSlotProps.test.ts create mode 100644 packages/mui-base/src/utils/mergeSlotProps.ts create mode 100644 packages/mui-base/src/utils/omitEventHandlers.spec.ts create mode 100644 packages/mui-base/src/utils/omitEventHandlers.test.ts create mode 100644 packages/mui-base/src/utils/omitEventHandlers.ts create mode 100644 packages/mui-base/src/utils/useSlotProps.ts diff --git a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx index fbdd04ba84d77d..86ff0e350bd6b1 100644 --- a/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx +++ b/packages/mui-base/src/MenuItemUnstyled/MenuItemUnstyled.tsx @@ -1,12 +1,10 @@ import * as React from 'react'; import PropTypes from 'prop-types'; -import clsx from 'clsx'; import { MenuItemUnstyledOwnerState, MenuItemUnstyledProps } from './MenuItemUnstyled.types'; -import { appendOwnerState } from '../utils'; import { getMenuItemUnstyledUtilityClass } from './menuItemUnstyledClasses'; import useMenuItem from './useMenuItem'; import composeClasses from '../composeClasses'; -import resolveComponentProps from '../utils/resolveComponentProps'; +import useSlotProps from '../utils/useSlotProps'; function getUtilityClasses(ownerState: MenuItemUnstyledOwnerState) { const { disabled, focusVisible } = ownerState; @@ -54,17 +52,14 @@ const MenuItemUnstyled = React.forwardRef(function MenuItemUnstyled( const classes = getUtilityClasses(ownerState); const Root = component ?? components.Root ?? 'li'; - const rootComponentProps = resolveComponentProps(componentsProps.root, ownerState); - const rootProps = appendOwnerState( - Root, - { - ...other, - ...getRootProps(other), - ...rootComponentProps, - className: clsx(classes.root, className, rootComponentProps?.className), - }, + const rootProps = useSlotProps({ + elementType: Root, + getSlotProps: getRootProps, + externalSlotProps: componentsProps.root, + externalForwardedProps: other, + className: [classes.root, className], ownerState, - ); + }); return {children}; }); diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx index 3e727dc1207240..971036637c461a 100644 --- a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx @@ -2,10 +2,8 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; import { HTMLElementType, refType, unstable_useForkRef as useForkRef } from '@mui/utils'; -import appendOwnerState from '../utils/appendOwnerState'; import MenuUnstyledContext, { MenuUnstyledContextType } from './MenuUnstyledContext'; import { - MenuUnstyledListboxSlotProps, MenuUnstyledOwnerState, MenuUnstyledProps, MenuUnstyledRootSlotProps, @@ -14,8 +12,7 @@ import { getMenuUnstyledUtilityClass } from './menuUnstyledClasses'; import useMenu from './useMenu'; import composeClasses from '../composeClasses'; import PopperUnstyled from '../PopperUnstyled'; -import { WithOptionalOwnerState } from '../utils'; -import resolveComponentProps from '../utils/resolveComponentProps'; +import useSlotProps from '../utils/useSlotProps'; function getUtilityClasses(ownerState: MenuUnstyledOwnerState) { const { open } = ownerState; @@ -85,36 +82,31 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( const classes = getUtilityClasses(ownerState); - const Popper = component ?? components.Root ?? PopperUnstyled; - const popperComponentProps = resolveComponentProps(componentsProps.root, ownerState); - const popperProps: MenuUnstyledRootSlotProps = appendOwnerState( - Popper, - { - ...other, + const Root = component ?? components.Root ?? PopperUnstyled; + const rootProps: MenuUnstyledRootSlotProps = useSlotProps({ + elementType: Root, + externalForwardedProps: other, + externalSlotProps: componentsProps.root, + additionalProps: { anchorEl, open, keepMounted, role: undefined, - ...popperComponentProps, - className: clsx(classes.root, className, popperComponentProps?.className), - ref: useForkRef(popperComponentProps?.ref, forwardedRef), }, + className: clsx(classes.root, className), ownerState, - ) as MenuUnstyledRootSlotProps; + }) as MenuUnstyledRootSlotProps; + + rootProps.ref = useForkRef(rootProps.ref, forwardedRef); const Listbox = components.Listbox ?? 'ul'; - const listboxComponentProps = resolveComponentProps(componentsProps.listbox, ownerState); - const propsFromHook = getListboxProps(); - const listboxProps: WithOptionalOwnerState = appendOwnerState( - Listbox, - { - ...propsFromHook, - ...listboxComponentProps, - className: clsx(classes.listbox, listboxComponentProps?.className), - ref: useForkRef(propsFromHook.ref, listboxComponentProps?.ref), - }, + const listboxProps = useSlotProps({ + elementType: Listbox, + getSlotProps: getListboxProps, + externalSlotProps: componentsProps.listbox, ownerState, - ); + className: classes.listbox, + }); const contextValue: MenuUnstyledContextType = { registerItem, @@ -125,11 +117,11 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( }; return ( - + {children} - + ); }); diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts index a96cee5f866faa..8326d420d82427 100644 --- a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.types.ts @@ -67,10 +67,11 @@ export interface MenuUnstyledOwnerState extends MenuUnstyledProps { export type MenuUnstyledRootSlotProps = { anchorEl: PopperUnstyledProps['anchorEl']; children?: React.ReactNode; - className: string | undefined; + className?: string; keepMounted: PopperUnstyledProps['keepMounted']; open: boolean; ownerState: MenuUnstyledOwnerState; + ref: React.Ref; }; export type MenuUnstyledListboxSlotProps = UseMenuListboxSlotProps & { diff --git a/packages/mui-base/src/utils/index.ts b/packages/mui-base/src/utils/index.ts index 849e58195b8fea..628e03491d72b9 100644 --- a/packages/mui-base/src/utils/index.ts +++ b/packages/mui-base/src/utils/index.ts @@ -2,4 +2,5 @@ export { default as appendOwnerState } from './appendOwnerState'; export { default as areArraysEqual } from './areArraysEqual'; export { default as extractEventHandlers } from './extractEventHandlers'; export { default as isHostComponent } from './isHostComponent'; +export { default as useSlotProps } from './useSlotProps'; export * from './types'; diff --git a/packages/mui-base/src/utils/mergeSlotProps.test.ts b/packages/mui-base/src/utils/mergeSlotProps.test.ts new file mode 100644 index 00000000000000..41f2af10344e7e --- /dev/null +++ b/packages/mui-base/src/utils/mergeSlotProps.test.ts @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import mergeSlotProps from './mergeSlotProps'; +import { EventHandlers } from './types'; + +describe('mergeSlotProps', () => { + it('overrides the internal props with the external ones', () => { + const getSlotProps = () => ({ + prop1: 'internal', + prop2: 'internal', + prop3: 'internal', + prop4: 'internal', + }); + + const additionalProps = { + prop1: 'additional', + prop2: 'additional', + prop3: 'additional', + }; + + const externalForwardedProps = { + prop1: 'externalForwarded', + prop2: 'externalForwarded', + }; + + const externalSlotProps = { + prop1: 'externalSlot', + }; + + const merged = mergeSlotProps({ + getSlotProps, + additionalProps, + externalForwardedProps, + externalSlotProps, + }); + + expect(merged.props.prop1).to.equal('externalSlot'); + expect(merged.props.prop2).to.equal('externalForwarded'); + expect(merged.props.prop3).to.equal('additional'); + expect(merged.props.prop4).to.equal('internal'); + }); + + it('joins all the class names', () => { + const getSlotProps = () => ({ + className: 'internal', + }); + + const additionalProps = { + className: 'additional', + }; + + const externalForwardedProps = { + className: 'externalForwarded', + }; + + const externalSlotProps = { + className: 'externalSlot', + }; + + const className = ['class1', 'class2']; + + const merged = mergeSlotProps({ + getSlotProps, + additionalProps, + externalForwardedProps, + externalSlotProps, + className, + }); + + expect(merged.props.className).to.contain('class1'); + expect(merged.props.className).to.contain('class2'); + expect(merged.props.className).to.contain('internal'); + expect(merged.props.className).to.contain('additional'); + expect(merged.props.className).to.contain('externalForwarded'); + expect(merged.props.className).to.contain('externalSlot'); + }); + + it('returns the ref returned from the getSlotProps function', () => { + const ref = React.createRef(); + const getSlotProps = () => ({ + ref, + }); + + const merged = mergeSlotProps({ + getSlotProps, + }); + + expect(merged.internalRef).to.equal(ref); + }); + + it('does not require any parameters', () => { + const merged = mergeSlotProps({}); + expect(merged.props).to.deep.equal({}); + }); + + it('passes the external event handlers to the getSlotProps function (if defined)', () => { + const externalClickHandler = () => {}; + const externalMouseOverHandler = () => {}; + + const getSlotProps = (eventHandlers: EventHandlers) => { + expect(eventHandlers.onClick).to.equal(externalClickHandler); + expect(eventHandlers.onMouseOver).to.equal(externalMouseOverHandler); + return {}; + }; + + const externalForwardedProps = { + onClick: externalClickHandler, + }; + + const externalSlotProps = { + onMouseOver: externalMouseOverHandler, + }; + + const merged = mergeSlotProps({ + getSlotProps, + externalForwardedProps, + externalSlotProps, + }); + + expect(Object.keys(merged.props)).not.to.contain('onClick'); + expect(Object.keys(merged.props)).not.to.contain('onMouseOver'); + }); +}); diff --git a/packages/mui-base/src/utils/mergeSlotProps.ts b/packages/mui-base/src/utils/mergeSlotProps.ts new file mode 100644 index 00000000000000..805af56233d7da --- /dev/null +++ b/packages/mui-base/src/utils/mergeSlotProps.ts @@ -0,0 +1,158 @@ +import clsx from 'clsx'; +import { Simplify } from '@mui/types'; +import { EventHandlers } from './types'; +import extractEventHandlers from './extractEventHandlers'; +import omitEventHandlers, { OmitEventHandlers } from './omitEventHandlers'; + +export type WithClassName = T & { + className?: string; +}; + +export type WithRef = T & { + ref?: React.Ref; +}; + +export interface MergeSlotPropsParameters< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, +> { + /** + * A function that returns the internal props of the component. + * It accepts the event handlers passed into the component by the user + * and is responsible for calling them where appropriate. + */ + getSlotProps?: (other: EventHandlers) => WithClassName; + /** + * Props provided to the `componentsProps.*` of the unstyled component. + */ + externalSlotProps?: WithClassName; + /** + * Extra props placed on the unstyled component that should be forwarded to the slot. + * This should usually be used only for the root slot. + */ + externalForwardedProps?: WithClassName; + /** + * Additional props to be placed on the slot. + */ + additionalProps?: WithClassName; + /** + * Extra class name(s) to be placed on the slot. + */ + className?: string | (string | undefined)[] | undefined; +} + +export type MergeSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, +> = { + props: Simplify< + SlotProps & + OmitEventHandlers & + OmitEventHandlers & + AdditionalProps & { className?: string } + >; + internalRef: React.Ref | undefined; +}; + +/** + * Merges the slot component internal props (usually coming from a hook) + * with the externally provided ones. + * + * The merge order is (the latter overrides the former): + * 1. The internal props (specified as a getter function to work with get*Props hook result) + * 2. Additional props (specified internally on an unstyled component) + * 3. External props specified on the owner component. These should only be used on a root slot. + * 4. External props specified in the `componentsProps.*` prop. + * 5. The `className` prop - combined from all the above. + * @param parameters + * @returns + */ +export default function mergeSlotProps< + SlotProps, + ExternalForwardedProps extends Record, + ExternalSlotProps extends Record, + AdditionalProps, +>( + parameters: MergeSlotPropsParameters< + WithRef, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps + >, +): MergeSlotPropsResult { + const { getSlotProps, additionalProps, externalSlotProps, externalForwardedProps, className } = + parameters; + + if (!getSlotProps) { + // The simpler case - getSlotProps is not defined, so no internal event handlers are defined, + // so we can simply merge all the props without having to worry about extracting event handlers. + const joinedClasses = clsx( + className, + additionalProps?.className, + externalForwardedProps?.className, + externalSlotProps?.className, + ); + + const props = { + ...additionalProps, + ...externalForwardedProps, + ...externalSlotProps, + className: joinedClasses, + } as Simplify< + SlotProps & + ExternalForwardedProps & + ExternalSlotProps & + AdditionalProps & { className?: string } + >; + + if (joinedClasses.length === 0) { + delete props.className; + } + + return { + props, + internalRef: undefined, + }; + } + + // In this case, getSlotProps is responsible for calling the external event handlers. + // We don't need to include them in the merged props because of this. + + const eventHandlers = extractEventHandlers({ ...externalForwardedProps, ...externalSlotProps }); + const componentsPropsWithoutEventHandlers = omitEventHandlers(externalSlotProps); + const otherPropsWithoutEventHandlers = omitEventHandlers(externalForwardedProps); + + const internalSlotProps = getSlotProps(eventHandlers); + const joinedClasses = clsx( + className, + internalSlotProps?.className, + additionalProps?.className, + externalForwardedProps?.className, + externalSlotProps?.className, + ); + + const props = { + ...internalSlotProps, + ...additionalProps, + ...otherPropsWithoutEventHandlers, + ...componentsPropsWithoutEventHandlers, + className: joinedClasses, + } as Simplify< + SlotProps & + OmitEventHandlers & + OmitEventHandlers & + AdditionalProps & { className?: string } + >; + if (joinedClasses.length === 0) { + delete props.className; + } + + return { + props, + internalRef: internalSlotProps.ref, + }; +} diff --git a/packages/mui-base/src/utils/omitEventHandlers.spec.ts b/packages/mui-base/src/utils/omitEventHandlers.spec.ts new file mode 100644 index 00000000000000..2ed5a21479b902 --- /dev/null +++ b/packages/mui-base/src/utils/omitEventHandlers.spec.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { OmitEventHandlers } from './omitEventHandlers'; + +interface Props { + onClick: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + on: () => void; + once: () => void; + onFieldThatLooksLikeEventHandler: number; + nonEventHandler: () => void; + n: number; + obj: { + onClick: () => void; + }; +} + +function test(props: OmitEventHandlers) { + // these are not event handlers + const { on, once, onFieldThatLooksLikeEventHandler, nonEventHandler, n, obj } = props; + + // nested fields are not affected + obj.onClick(); + + // @ts-expect-error - onClick is removed + props.onClick(); + + // @ts-expect-error - onKeyDown is removed + props.onKeyDown(); + + return props; +} diff --git a/packages/mui-base/src/utils/omitEventHandlers.test.ts b/packages/mui-base/src/utils/omitEventHandlers.test.ts new file mode 100644 index 00000000000000..fb6f54ea7fb489 --- /dev/null +++ b/packages/mui-base/src/utils/omitEventHandlers.test.ts @@ -0,0 +1,29 @@ +import { expect } from 'chai'; +import omitEventHandlers from './omitEventHandlers'; + +describe('omitEventHandlers', () => { + it('should remove functions with names beginning with `on` followed by uppercase letter', () => { + const obj = { + onClick: () => {}, + onKeyDown: () => {}, + foo: 12, + bar: 'baz', + onion: {}, + once: () => {}, + on2: () => {}, + on: () => {}, + }; + + const result = omitEventHandlers(obj); + + expect(result).to.haveOwnProperty('foo'); + expect(result).to.haveOwnProperty('bar'); + expect(result).to.haveOwnProperty('onion'); + expect(result).to.haveOwnProperty('once'); + expect(result).to.haveOwnProperty('on2'); + expect(result).to.haveOwnProperty('on'); + + expect(result).to.not.haveOwnProperty('onClick'); + expect(result).to.not.haveOwnProperty('onKeyDown'); + }); +}); diff --git a/packages/mui-base/src/utils/omitEventHandlers.ts b/packages/mui-base/src/utils/omitEventHandlers.ts new file mode 100644 index 00000000000000..5a3f6b1bb3fec6 --- /dev/null +++ b/packages/mui-base/src/utils/omitEventHandlers.ts @@ -0,0 +1,43 @@ +import { Simplify } from '@mui/types'; + +/** + * Creates a type that is T with removed properties that are functions with names beginning with `on`. + * Note that it does not exactly follow the logic of `omitEventHandlers` as it also removes fields where + * `on` is followed by a non-letter character, + */ +export type OmitEventHandlers = { + [Key in keyof T as Key extends `on${infer EventName}` + ? T[Key] extends Function + ? EventName extends '' + ? Key + : EventName extends Capitalize + ? never + : Key + : Key + : Key]: T[Key]; +}; + +/** + * Removes event handlers from the given object. + * A field is considered an event handler if it is a function with a name beginning with `on`. + * + * @param object Object to remove event handlers from. + * @returns Object with event handlers removed. + */ +export default function omitEventHandlers>( + object: T | undefined, +): Simplify> { + if (object === undefined) { + return {} as Simplify>; + } + + const result = {} as Simplify>; + + Object.keys(object) + .filter((prop) => !(prop.match(/^on[A-Z]/) && typeof object[prop] === 'function')) + .forEach((prop) => { + (result[prop] as any) = object[prop]; + }); + + return result; +} diff --git a/packages/mui-base/src/utils/useSlotProps.ts b/packages/mui-base/src/utils/useSlotProps.ts new file mode 100644 index 00000000000000..186a1ea6018898 --- /dev/null +++ b/packages/mui-base/src/utils/useSlotProps.ts @@ -0,0 +1,85 @@ +import * as React from 'react'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import appendOwnerState from './appendOwnerState'; +import mergeSlotProps, { MergeSlotPropsParameters, WithRef } from './mergeSlotProps'; +import resolveComponentProps from './resolveComponentProps'; + +export type UseSlotPropsParameters< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState, +> = Omit< + MergeSlotPropsParameters, + 'externalSlotProps' +> & { + /** + * The type of the component used in the slot. + */ + elementType: React.ElementType; + /** + * The `componentsProps.*` of the unstyled component. + */ + externalSlotProps: + | ExternalSlotProps + | ((ownerState: OwnerState) => ExternalSlotProps) + | undefined; + /** + * The ownerState of the unstyled component. + */ + ownerState: OwnerState; +}; + +export type UseSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState, +> = SlotProps & + ExternalSlotProps & + ExternalForwardedProps & + AdditionalProps & { + className?: string | undefined; + } & { + ownerState?: OwnerState | undefined; + }; + +/** + * Builds the props to be passed into the slot of an unstyled component. + * It merges the internal props of the component with the ones supplied by the user, allowing to customize the behavior. + * If the slot component is not a host component, it also merges in the `ownerState`. + * + * @param parameters.getSlotProps - A function that returns the props to be passed to the slot component. + */ +export default function useSlotProps< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState, +>( + parameters: UseSlotPropsParameters< + SlotProps, + ExternalForwardedProps, + WithRef, + AdditionalProps, + OwnerState + >, +) { + const { elementType, externalSlotProps, ownerState, ...rest } = parameters; + const resolvedComponentsProps = resolveComponentProps(externalSlotProps, ownerState); + const merged = mergeSlotProps({ + ...rest, + externalSlotProps: resolvedComponentsProps, + }); + + const props = appendOwnerState( + elementType, + { ...merged.props, ref: useForkRef(merged.internalRef, resolvedComponentsProps?.ref) }, + ownerState, + ); + + return props; +} From 3efae41278c2e4b07fd1b19bac59458eadc49259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Dudak?= Date: Thu, 9 Jun 2022 11:46:16 +0200 Subject: [PATCH 4/4] Add useSlotProps tests --- .../src/MenuUnstyled/MenuUnstyled.tsx | 5 +- packages/mui-base/src/utils/mergeSlotProps.ts | 14 +- .../mui-base/src/utils/useSlotProps.test.tsx | 273 ++++++++++++++++++ packages/mui-base/src/utils/useSlotProps.ts | 23 +- 4 files changed, 295 insertions(+), 20 deletions(-) create mode 100644 packages/mui-base/src/utils/useSlotProps.test.tsx diff --git a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx index 971036637c461a..fa76f470892809 100644 --- a/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx +++ b/packages/mui-base/src/MenuUnstyled/MenuUnstyled.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { HTMLElementType, refType, unstable_useForkRef as useForkRef } from '@mui/utils'; +import { HTMLElementType, refType } from '@mui/utils'; import MenuUnstyledContext, { MenuUnstyledContextType } from './MenuUnstyledContext'; import { MenuUnstyledOwnerState, @@ -92,13 +92,12 @@ const MenuUnstyled = React.forwardRef(function MenuUnstyled( open, keepMounted, role: undefined, + ref: forwardedRef, }, className: clsx(classes.root, className), ownerState, }) as MenuUnstyledRootSlotProps; - rootProps.ref = useForkRef(rootProps.ref, forwardedRef); - const Listbox = components.Listbox ?? 'ul'; const listboxProps = useSlotProps({ elementType: Listbox, diff --git a/packages/mui-base/src/utils/mergeSlotProps.ts b/packages/mui-base/src/utils/mergeSlotProps.ts index 805af56233d7da..8c3ea96b2d43cf 100644 --- a/packages/mui-base/src/utils/mergeSlotProps.ts +++ b/packages/mui-base/src/utils/mergeSlotProps.ts @@ -27,12 +27,12 @@ export interface MergeSlotPropsParameters< /** * Props provided to the `componentsProps.*` of the unstyled component. */ - externalSlotProps?: WithClassName; + externalSlotProps?: WithClassName; /** * Extra props placed on the unstyled component that should be forwarded to the slot. * This should usually be used only for the root slot. */ - externalForwardedProps?: WithClassName; + externalForwardedProps?: WithClassName; /** * Additional props to be placed on the slot. */ @@ -91,10 +91,10 @@ export default function mergeSlotProps< // The simpler case - getSlotProps is not defined, so no internal event handlers are defined, // so we can simply merge all the props without having to worry about extracting event handlers. const joinedClasses = clsx( - className, - additionalProps?.className, externalForwardedProps?.className, externalSlotProps?.className, + className, + additionalProps?.className, ); const props = { @@ -128,11 +128,11 @@ export default function mergeSlotProps< const internalSlotProps = getSlotProps(eventHandlers); const joinedClasses = clsx( - className, - internalSlotProps?.className, - additionalProps?.className, externalForwardedProps?.className, externalSlotProps?.className, + className, + additionalProps?.className, + internalSlotProps?.className, ); const props = { diff --git a/packages/mui-base/src/utils/useSlotProps.test.tsx b/packages/mui-base/src/utils/useSlotProps.test.tsx new file mode 100644 index 00000000000000..b4487d5325f509 --- /dev/null +++ b/packages/mui-base/src/utils/useSlotProps.test.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import { EventHandlers } from '@mui/base'; +import { createRenderer } from 'test/utils'; +import useSlotProps, { UseSlotPropsParameters, UseSlotPropsResult } from './useSlotProps'; + +const { render } = createRenderer(); + +function callUseSlotProps< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState, +>( + parameters: UseSlotPropsParameters< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState + >, +) { + const TestComponent = React.forwardRef( + ( + _: unknown, + ref: React.Ref< + UseSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState + > + >, + ) => { + const slotProps = useSlotProps(parameters); + React.useImperativeHandle(ref, () => slotProps as any); + return null; + }, + ); + + const ref = + React.createRef< + UseSlotPropsResult< + SlotProps, + ExternalForwardedProps, + ExternalSlotProps, + AdditionalProps, + OwnerState + > + >(); + render(); + + return ref.current!; +} + +describe('useSlotProps', () => { + it('returns the provided slot props if no overrides are present', () => { + const clickHandler = () => {}; + const getSlotProps = (otherHandlers: EventHandlers) => { + expect(otherHandlers).to.deep.equal({}); + + return { + id: 'test', + onClick: clickHandler, + }; + }; + + const result = callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps: undefined, + ownerState: undefined, + }); + + expect(result).to.deep.equal({ + id: 'test', + onClick: clickHandler, + ref: null, + }); + }); + + it('calls getSlotProps with the external event handlers', () => { + const externalClickHandler = () => {}; + const internalClickHandler = () => {}; + + const getSlotProps = (otherHandlers: EventHandlers) => { + expect(otherHandlers).to.deep.equal({ + onClick: externalClickHandler, + }); + + return { + id: 'internalId', + onClick: internalClickHandler, + }; + }; + + const result = callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps: { + className: 'externalClassName', + id: 'externalId', + onClick: externalClickHandler, + }, + ownerState: undefined, + }); + + expect(result).to.deep.equal({ + className: 'externalClassName', + id: 'externalId', + onClick: internalClickHandler, + ref: null, + }); + }); + + it('adds ownerState to props if the elementType is a component', () => { + const getSlotProps = () => ({ + id: 'test', + }); + + const TestComponent = (props: any) =>
; + + const result = callUseSlotProps({ + elementType: TestComponent, + getSlotProps, + externalSlotProps: undefined, + ownerState: { + foo: 'bar', + }, + }); + + expect(result).to.deep.equal({ + id: 'test', + ref: null, + ownerState: { + foo: 'bar', + }, + }); + }); + + it('synchronizes refs provided by internal and external props', () => { + const internalRef = React.createRef(); + const externalRef = React.createRef(); + + const getSlotProps = () => ({ + ref: internalRef, + }); + + const result = callUseSlotProps({ + elementType: 'div', + getSlotProps, + externalSlotProps: { + ref: externalRef, + }, + ownerState: undefined, + }); + + result.ref('test'); + + expect(internalRef.current).to.equal('test'); + expect(externalRef.current).to.equal('test'); + }); + + // The "everything but the kitchen sink" test + it('constructs props from complex parameters', () => { + const internalRef = React.createRef(); + const externalRef = React.createRef(); + const additionalRef = React.createRef(); + + const internalClickHandler = spy(); + const externalClickHandler = spy(); + const externalForwardedClickHandler = spy(); + + const createInternalClickHandler = (otherHandlers: EventHandlers) => (e: React.MouseEvent) => { + expect(otherHandlers).to.deep.equal({ + onClick: externalClickHandler, + }); + + otherHandlers.onClick(e); + internalClickHandler(e); + }; + + // usually provided by the hook: + const getSlotProps = (otherHandlers: EventHandlers) => ({ + id: 'internalId', + onClick: createInternalClickHandler(otherHandlers), + ref: internalRef, + className: 'internal', + }); + + const ownerState = { + test: true, + }; + + // provided by the user by appending additonal props on the unstyled component: + const forwardedProps = { + 'data-test': 'externalForwarded', + className: 'externalForwarded', + onClick: externalForwardedClickHandler, + }; + + // provided by the user via componentsProps.*: + const componentProps = (os: typeof ownerState) => ({ + 'data-fromownerstate': os.test, + 'data-test': 'externalComponentsProps', + className: 'externalComponentsProps', + onClick: externalClickHandler, + ref: externalRef, + id: 'external', + ownerState: { + foo: 'bar', + }, + }); + + // set in the unstyled component: + const additionalProps = { + className: 'additional', + ref: additionalRef, + }; + + const TestComponent = (props: any) =>
; + + const result = callUseSlotProps({ + elementType: TestComponent, + getSlotProps, + externalForwardedProps: forwardedProps, + externalSlotProps: componentProps, + additionalProps, + ownerState, + className: ['another-class', 'yet-another-class'], + }); + + // `id` from componentProps overrides the one from getSlotProps + expect(result).to.haveOwnProperty('id', 'external'); + + // `componentsProps` is called with the ownerState + expect(result).to.haveOwnProperty('data-fromownerstate', true); + + // class names are concatenated + expect(result).to.haveOwnProperty( + 'className', + 'externalForwarded externalComponentsProps another-class yet-another-class additional internal', + ); + + // `data-test` from componentProps overrides the one from forwardedProps + expect(result).to.haveOwnProperty('data-test', 'externalComponentsProps'); + + // all refs should be synced + result.ref('test'); + expect(internalRef.current).to.equal('test'); + expect(externalRef.current).to.equal('test'); + expect(additionalRef.current).to.equal('test'); + + // event handler provided in componentsProps is called + result.onClick({}); + expect(externalClickHandler.calledOnce).to.equal(true); + + // event handler provided in forwardedProps is not called (was overridden by componentsProps) + expect(externalForwardedClickHandler.notCalled).to.equal(true); + + // internal event handler is called + expect(internalClickHandler.calledOnce).to.equal(true); + + // internal ownerState is merged with the one provided by componentsProps + expect(result.ownerState).to.deep.equal({ + test: true, + foo: 'bar', + }); + }); +}); diff --git a/packages/mui-base/src/utils/useSlotProps.ts b/packages/mui-base/src/utils/useSlotProps.ts index 186a1ea6018898..6c93acbc493126 100644 --- a/packages/mui-base/src/utils/useSlotProps.ts +++ b/packages/mui-base/src/utils/useSlotProps.ts @@ -37,14 +37,11 @@ export type UseSlotPropsResult< ExternalSlotProps, AdditionalProps, OwnerState, -> = SlotProps & - ExternalSlotProps & - ExternalForwardedProps & - AdditionalProps & { - className?: string | undefined; - } & { - ownerState?: OwnerState | undefined; - }; +> = Omit & { + className?: string | undefined; + ownerState?: OwnerState | undefined; + ref: (instance: any | null) => void; +}; /** * Builds the props to be passed into the slot of an unstyled component. @@ -64,7 +61,7 @@ export default function useSlotProps< SlotProps, ExternalForwardedProps, WithRef, - AdditionalProps, + WithRef, OwnerState >, ) { @@ -77,7 +74,13 @@ export default function useSlotProps< const props = appendOwnerState( elementType, - { ...merged.props, ref: useForkRef(merged.internalRef, resolvedComponentsProps?.ref) }, + { + ...merged.props, + ref: useForkRef( + merged.internalRef, + useForkRef(resolvedComponentsProps?.ref, parameters.additionalProps?.ref), + ), + }, ownerState, );