diff --git a/.yarn/versions/63dd769c.yml b/.yarn/versions/63dd769c.yml new file mode 100644 index 000000000..d4dcb2365 --- /dev/null +++ b/.yarn/versions/63dd769c.yml @@ -0,0 +1,2 @@ +releases: + "@radix-ui/react-slot": minor diff --git a/packages/react/slot/src/Slot.test.tsx b/packages/react/slot/src/Slot.test.tsx index c7912b46f..487b4249b 100644 --- a/packages/react/slot/src/Slot.test.tsx +++ b/packages/react/slot/src/Slot.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import { Slot, Slottable } from '@radix-ui/react-slot'; +import { PropMergers, Slot, Slottable } from '@radix-ui/react-slot'; describe('given a slotted Trigger', () => { describe('with onClick on itself', () => { @@ -108,6 +108,80 @@ describe('given a slotted Trigger', () => { }); }); +describe('given propMergers', () => { + describe("with propMergers' onClick defined to only call slot's onClick AND className not defined", () => { + let handleSlotClick: jest.Mock; + let handleChildClick: jest.Mock; + + beforeEach(() => { + handleSlotClick = jest.fn(); + handleChildClick = jest.fn(); + + const propMergers: PropMergers = { + onClick: (slotPropValue, childPropValue) => (event) => { + if (slotPropValue) { + slotPropValue(event); + return; + } + if (childPropValue) childPropValue(event); + }, + }; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + }); + + it('should use the custom merging behavior for onClick', () => { + expect(handleSlotClick).toHaveBeenCalledTimes(1); + expect(handleChildClick).not.toHaveBeenCalled(); + }); + + it('should join the classNames from child and slot', () => { + expect(screen.getByRole('button')).toHaveAttribute('class', 'slot-class child-class'); + }); + }); + + describe("with propMergers' className defined in propMergers to only use slot's className AND onClick not defined", () => { + let handleSlotClick: jest.Mock; + let handleChildClick: jest.Mock; + + beforeEach(() => { + handleSlotClick = jest.fn(); + handleChildClick = jest.fn(); + + const propMergers: PropMergers = { + className: (slotPropValue) => slotPropValue, + }; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button')); + }); + + it("should only use slot's className", () => { + expect(screen.getByRole('button')).toHaveAttribute('class', 'slot-class'); + }); + + it('should call the both onClick handlers from child and slot', () => { + expect(handleSlotClick).toHaveBeenCalledTimes(1); + expect(handleChildClick).toHaveBeenCalledTimes(1); + }); + }); +}); + describe('given a Button with Slottable', () => { describe('without asChild', () => { it('should render a button with icon on the left/right', async () => { diff --git a/packages/react/slot/src/Slot.tsx b/packages/react/slot/src/Slot.tsx index c222b1d7f..0770d8f76 100644 --- a/packages/react/slot/src/Slot.tsx +++ b/packages/react/slot/src/Slot.tsx @@ -7,6 +7,7 @@ import { composeRefs } from '@radix-ui/react-compose-refs'; interface SlotProps extends React.HTMLAttributes { children?: React.ReactNode; + propMergers?: PropMergers; } const Slot = React.forwardRef((props, forwardedRef) => { @@ -55,15 +56,16 @@ Slot.displayName = 'Slot'; interface SlotCloneProps { children: React.ReactNode; + propMergers?: PropMergers; } const SlotClone = React.forwardRef((props, forwardedRef) => { - const { children, ...slotProps } = props; + const { children, propMergers, ...slotProps } = props; if (React.isValidElement(children)) { const childrenRef = getElementRef(children); return React.cloneElement(children, { - ...mergeProps(slotProps, children.props), + ...mergeProps(slotProps, children.props, { ...defaultPropMergers, ...propMergers }), // @ts-ignore ref: forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef, }); @@ -84,13 +86,33 @@ const Slottable = ({ children }: { children: React.ReactNode }) => { /* ---------------------------------------------------------------------------------------------- */ +type CustomizableMergingPropName = Exclude, 'children'>; + +type PropMerger = ( + slotPropValue: React.HTMLAttributes[PropName], + childPropValue: React.HTMLAttributes[PropName] +) => React.HTMLAttributes[PropName]; + +type PropMergers = { + [propName in CustomizableMergingPropName]?: PropMerger; +}; + type AnyProps = Record; +const defaultPropMergers = { + style: (slotPropValue, childPropValue) => ({ + ...slotPropValue, + ...childPropValue, + }), + className: (slotPropValue, childPropValue) => + [slotPropValue, childPropValue].filter(Boolean).join(' '), +} as const satisfies PropMergers; + function isSlottable(child: React.ReactNode): child is React.ReactElement { return React.isValidElement(child) && child.type === Slottable; } -function mergeProps(slotProps: AnyProps, childProps: AnyProps) { +function mergeProps(slotProps: AnyProps, childProps: AnyProps, propMergers: PropMergers) { // all child props should override const overrideProps = { ...childProps }; @@ -98,25 +120,26 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps) { const slotPropValue = slotProps[propName]; const childPropValue = childProps[propName]; - const isHandler = /^on[A-Z]/.test(propName); - if (isHandler) { - // if the handler exists on both, we compose them - if (slotPropValue && childPropValue) { - overrideProps[propName] = (...args: unknown[]) => { - childPropValue(...args); - slotPropValue(...args); - }; + if (propName in propMergers) { + overrideProps[propName] = propMergers[propName as CustomizableMergingPropName]!( + slotPropValue, + childPropValue + ); + } else { + const isHandler = /^on[A-Z]/.test(propName); + if (isHandler) { + // if the handler exists on both, we compose them + if (slotPropValue && childPropValue) { + overrideProps[propName] = (...args: unknown[]) => { + childPropValue(...args); + slotPropValue(...args); + }; + } + // but if it exists only on the slot, we use only this one + else if (slotPropValue) { + overrideProps[propName] = slotPropValue; + } } - // but if it exists only on the slot, we use only this one - else if (slotPropValue) { - overrideProps[propName] = slotPropValue; - } - } - // if it's `style`, we merge them - else if (propName === 'style') { - overrideProps[propName] = { ...slotPropValue, ...childPropValue }; - } else if (propName === 'className') { - overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' '); } } @@ -155,4 +178,4 @@ export { // Root, }; -export type { SlotProps }; +export type { SlotProps, PropMergers, PropMerger, CustomizableMergingPropName }; diff --git a/packages/react/slot/src/index.ts b/packages/react/slot/src/index.ts index 13a40843a..1d34b5885 100644 --- a/packages/react/slot/src/index.ts +++ b/packages/react/slot/src/index.ts @@ -4,4 +4,4 @@ export { // Root, } from './Slot'; -export type { SlotProps } from './Slot'; +export type { SlotProps, PropMergers, PropMerger, CustomizableMergingPropName } from './Slot';