diff --git a/.changeset/sixty-pets-clap.md b/.changeset/sixty-pets-clap.md new file mode 100644 index 000000000..3beb6bd8c --- /dev/null +++ b/.changeset/sixty-pets-clap.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-slot': patch +--- + +Support slotting onto nested children diff --git a/apps/ssr-testing/app/slot/client.tsx b/apps/ssr-testing/app/slot/client.tsx new file mode 100644 index 000000000..586a2a2f2 --- /dev/null +++ b/apps/ssr-testing/app/slot/client.tsx @@ -0,0 +1,99 @@ +'use client'; + +import * as React from 'react'; +import { Slot } from 'radix-ui'; + +export const Link = React.forwardRef< + React.ComponentRef<'a'>, + React.ComponentProps<'a'> & { asChild?: boolean } +>(({ asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'a'; + return ; +}); + +export const LinkSlottable = React.forwardRef< + React.ComponentRef<'a'>, + React.ComponentProps<'a'> & { asChild?: boolean } +>(({ asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'a'; + return ( + + left + {props.children} + right + + ); +}); + +export const LinkButton = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>((props, forwardedRef) => ( + +)); + +export const Button = React.forwardRef< + React.ComponentRef<'button'>, + React.ComponentProps<'button'> & { asChild?: boolean } +>(({ asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'button'; + return ; +}); + +export const ButtonSlottable = React.forwardRef< + React.ComponentRef<'button'>, + React.ComponentProps<'button'> & { asChild?: boolean } +>(({ children, asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'button'; + return ( + + left + {children} + right + + ); +}); + +export const ButtonNestedSlottable = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ children, asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'button'; + return ( + + + {(slottable) => ( + <> + left + bold {slottable} + right + + )} + + + ); +}); + +export const IconButtonNestedSlottable = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ children, ...props }, forwardedRef) => { + return ( + + ); +}); diff --git a/apps/ssr-testing/app/slot/page.tsx b/apps/ssr-testing/app/slot/page.tsx index 347298259..8746d984a 100644 --- a/apps/ssr-testing/app/slot/page.tsx +++ b/apps/ssr-testing/app/slot/page.tsx @@ -1,13 +1,141 @@ import * as React from 'react'; -import { Slot } from 'radix-ui'; +import * as Client from './client'; +import * as Server from './server'; export default function Page() { return ( - - I'm in a - - Slot!? - - + <> +

All components should be rendered as links

+ +

Client.LinkButton

+ + children + +

Client.Button as Client.Link

+ + + children + + +

Client.Button as Server.Link

+ + + children + + +

Client.Button as Client.LinkSlottable

+ + + children + + +

Client.Button as Server.LinkSlottable

+ + + children + + +

Client.ButtonSlottable as Server.Link

+ + + children + + +

Client.ButtonSlottable as Client.Link

+ + + children + + +

Client.ButtonNestedSlottable as Server.Link

+ + + children + + +

Client.ButtonNestedSlottable as Client.Link

+ + + children + + +

Client.IconButtonNestedSlottable as Server.Link

+ + + children + + +

Client.IconButtonNestedSlottable as Client.Link

+ + + children + + +
+ +

Server.LinkButton

+ + children + +

Server.Button as Server.Link

+ + + children + + +

Server.Button as Client.Link

+ + + children + + +

Server.Button as Server.LinkSlottable

+ + + children + + +

Server.Button as Client.LinkSlottable

+ + + children + + +

Server.ButtonSlottable as Client.Link

+ + + children + + +

Server.ButtonSlottable as Server.Link

+ + + children + + +

Server.ButtonNestedSlottable as Client.Link

+ + + children + + +

Server.ButtonNestedSlottable as Server.Link

+ + + children + + +

Server.IconButtonNestedSlottable as Server.Link

+ + + children + + +

Server.IconButtonNestedSlottable as Client.Link

+ + + children + + ); } diff --git a/apps/ssr-testing/app/slot/server.tsx b/apps/ssr-testing/app/slot/server.tsx new file mode 100644 index 000000000..f77a09a36 --- /dev/null +++ b/apps/ssr-testing/app/slot/server.tsx @@ -0,0 +1,98 @@ +import * as React from 'react'; +import { Slot } from 'radix-ui'; +import * as Client from './client'; + +export const Link = React.forwardRef< + React.ComponentRef<'a'>, + React.ComponentProps<'a'> & { asChild?: boolean } +>(({ asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'a'; + return ; +}); + +export const LinkSlottable = React.forwardRef< + React.ComponentRef<'a'>, + React.ComponentProps<'a'> & { asChild?: boolean } +>(({ asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'a'; + return ( + + left + {props.children} + right + + ); +}); + +export const LinkButton = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>((props, forwardedRef) => ( + +)); + +export const Button = React.forwardRef< + React.ComponentRef<'button'>, + React.ComponentProps<'button'> & { asChild?: boolean } +>(({ asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'button'; + return ; +}); + +export const ButtonSlottable = React.forwardRef< + React.ComponentRef<'button'>, + React.ComponentProps<'button'> & { asChild?: boolean } +>(({ children, asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'button'; + return ( + + left + {children} + right + + ); +}); + +export const ButtonNestedSlottable = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ children, asChild = false, ...props }, forwardedRef) => { + const Comp = asChild ? Slot.Root : 'button'; + return ( + + + {(slottable) => ( + <> + left + bold {slottable} + right + + )} + + + ); +}); + +export const IconButtonNestedSlottable = React.forwardRef< + React.ComponentRef, + React.ComponentProps +>(({ children, ...props }, forwardedRef) => { + return ( + + + + {(slottable) => ( + <> + ICON + bold {slottable} + + )} + + + + ); +}); diff --git a/packages/react/slot/src/__snapshots__/slot.test.tsx.snap b/packages/react/slot/src/__snapshots__/slot.test.tsx.snap index 4f7692486..80c9783c3 100644 --- a/packages/react/slot/src/__snapshots__/slot.test.tsx.snap +++ b/packages/react/slot/src/__snapshots__/slot.test.tsx.snap @@ -35,3 +35,71 @@ exports[`given a Button with Slottable > without asChild > should render a butto `; + +exports[`given a Button with Slottable nesting > with asChild > should render a link with a span around its children 1`] = ` + +`; + +exports[`given a Button with Slottable nesting > with asChild > should render a link with icon on the left/right and a span around its children 1`] = ` + +`; + +exports[`given a Button with Slottable nesting > without asChild > should render a button with a span around its children 1`] = ` +
+ +
+`; + +exports[`given a Button with Slottable nesting > without asChild > should render a button with icon on the left/right and a span around its children 1`] = ` +
+ +
+`; diff --git a/packages/react/slot/src/slot.test.tsx b/packages/react/slot/src/slot.test.tsx index b7fae1389..3f9990b3e 100644 --- a/packages/react/slot/src/slot.test.tsx +++ b/packages/react/slot/src/slot.test.tsx @@ -140,9 +140,58 @@ describe('given a Button with Slottable', () => { }); }); -// TODO: Unskip when underlying issue is resolved -// Reverted in https://github.com/radix-ui/primitives/pull/3554 -describe.skip('given an Input', () => { +describe('given a Button with Slottable nesting', () => { + afterEach(cleanup); + describe('without asChild', () => { + it('should render a button with a span around its children', async () => { + const tree = render( + + Button text + + ); + + expect(tree.container).toMatchSnapshot(); + }); + + it('should render a button with icon on the left/right and a span around its children', async () => { + const tree = render( + left} iconRight={right}> + Button text + + ); + + expect(tree.container).toMatchSnapshot(); + }); + }); + + describe('with asChild', () => { + it('should render a link with a span around its children', async () => { + const tree = render( + + + Button text + + + ); + + expect(tree.container).toMatchSnapshot(); + }); + + it('should render a link with icon on the left/right and a span around its children', async () => { + const tree = render( + left} iconRight={right}> + + Button text + + + ); + + expect(tree.container).toMatchSnapshot(); + }); + }); +}); + +describe('given an Input', () => { const handleRef = vi.fn(); beforeEach(() => { @@ -194,6 +243,24 @@ const Button = React.forwardRef< ); }); +const ButtonNested = React.forwardRef< + React.ComponentRef<'button'>, + React.ComponentProps<'button'> & { + asChild?: boolean; + iconLeft?: React.ReactNode; + iconRight?: React.ReactNode; + } +>(({ children, asChild = false, iconLeft, iconRight, ...props }, forwardedRef) => { + const Comp = asChild ? Slot : 'button'; + return ( + + {iconLeft} + {(slottable) => {slottable}} + {iconRight} + + ); +}); + const Input = React.forwardRef< React.ComponentRef<'input'>, React.ComponentProps<'input'> & { diff --git a/packages/react/slot/src/slot.tsx b/packages/react/slot/src/slot.tsx index 7e31d6654..a9c310ce2 100644 --- a/packages/react/slot/src/slot.tsx +++ b/packages/react/slot/src/slot.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { composeRefs } from '@radix-ui/react-compose-refs'; +import { useComposedRefs } from '@radix-ui/react-compose-refs'; /* ------------------------------------------------------------------------------------------------- * Slot @@ -10,43 +10,26 @@ interface SlotProps extends React.HTMLAttributes { } /* @__NO_SIDE_EFFECTS__ */ export function createSlot(ownerName: string) { - const SlotClone = createSlotClone(ownerName); const Slot = React.forwardRef((props, forwardedRef) => { const { children, ...slotProps } = props; - const childrenArray = React.Children.toArray(children); - const slottable = childrenArray.find(isSlottable); - - if (slottable) { - // the new element to render is the one passed as a child of `Slottable` - const newElement = slottable.props.children; - - const newChildren = childrenArray.map((child) => { - if (child === slottable) { - // because the new element will be the one rendered, we are only interested - // in grabbing its children (`newElement.props.children`) - if (React.Children.count(newElement) > 1) return React.Children.only(null); - return React.isValidElement(newElement) - ? (newElement.props as { children: React.ReactNode }).children - : null; - } else { - return child; - } - }); - - return ( - - {React.isValidElement(newElement) - ? React.cloneElement(newElement, undefined, newChildren) - : null} - - ); + const slottableElement = findSlottableElement(children); + const slottableElementRef = slottableElement ? getElementRef(slottableElement) : undefined; + // fine in RSC as it's a React.useCallback under the hood + const composedRefs = useComposedRefs(forwardedRef, slottableElementRef); + + if (!slottableElement) { + if (process.env.NODE_ENV === 'development') console.warn(createSlotWarning(ownerName)); + return children; } - return ( - - {children} - - ); + const mergedProps = mergeProps(slotProps, slottableElement.props ?? {}); + + // do not pass ref to React.Fragment for React 19 compatibility + if (slottableElement.type !== React.Fragment) { + mergedProps.ref = forwardedRef ? composedRefs : slottableElementRef; + } + + return React.cloneElement(slottableElement, mergedProps); }); Slot.displayName = `${ownerName}.Slot`; @@ -55,53 +38,27 @@ interface SlotProps extends React.HTMLAttributes { const Slot = createSlot('Slot'); -/* ------------------------------------------------------------------------------------------------- - * SlotClone - * -----------------------------------------------------------------------------------------------*/ - -interface SlotCloneProps { - children: React.ReactNode; -} - -/* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) { - const SlotClone = React.forwardRef((props, forwardedRef) => { - const { children, ...slotProps } = props; - - if (React.isValidElement(children)) { - const childrenRef = getElementRef(children); - const props = mergeProps(slotProps, children.props as AnyProps); - // do not pass ref to React.Fragment for React 19 compatibility - if (children.type !== React.Fragment) { - props.ref = forwardedRef ? composeRefs(forwardedRef, childrenRef) : childrenRef; - } - return React.cloneElement(children, props); - } - - return React.Children.count(children) > 1 ? React.Children.only(null) : null; - }); - - SlotClone.displayName = `${ownerName}.SlotClone`; - return SlotClone; -} - /* ------------------------------------------------------------------------------------------------- * Slottable * -----------------------------------------------------------------------------------------------*/ -const SLOTTABLE_IDENTIFIER = Symbol('radix.slottable'); +const SLOTTABLE_IDENTIFIER = Symbol.for('radix.slottable'); -interface SlottableProps { - children: React.ReactNode; -} +type SlottableChildrenProps = { children: React.ReactNode }; +type SlottableRenderFnProps = { + child: React.ReactNode; + children: (slottable: React.ReactNode) => React.ReactNode; +}; +type SlottableProps = SlottableRenderFnProps | SlottableChildrenProps; interface SlottableComponent extends React.FC { __radixId: symbol; } /* @__NO_SIDE_EFFECTS__ */ export function createSlottable(ownerName: string) { - const Slottable: SlottableComponent = ({ children }) => { - return <>{children}; - }; + const Slottable: SlottableComponent = (props) => + 'child' in props ? props.children(props.child) : props.children; + Slottable.displayName = `${ownerName}.Slottable`; Slottable.__radixId = SLOTTABLE_IDENTIFIER; return Slottable; @@ -111,18 +68,51 @@ const Slottable = createSlottable('Slottable'); /* ---------------------------------------------------------------------------------------------- */ -type AnyProps = Record; +/* ------------------------------------------------------------------------------------------------- + * findSlottableElement + * -----------------------------------------------------------------------------------------------*/ -function isSlottable( - child: React.ReactNode -): child is React.ReactElement { - return ( - React.isValidElement(child) && - typeof child.type === 'function' && - '__radixId' in child.type && - child.type.__radixId === SLOTTABLE_IDENTIFIER - ); -} +const findSlottableElement = (children: React.ReactNode): React.ReactElement | null => { + if (React.Children.count(children) === 1) { + if (isSlottable(children)) return getSlottableElementFromSlottable(children); + return React.isValidElement(children) ? children : null; + } + + let slottableElement: React.ReactElement | null = null; + const newChildren: React.ReactNode[] = []; + + React.Children.forEach(children, (child) => { + if (isSlottable(child)) { + slottableElement = getSlottableElementFromSlottable(child); + newChildren.push((slottableElement?.props as any)?.children); + } else { + newChildren.push(child); + } + }); + + return slottableElement ? React.cloneElement(slottableElement, undefined, newChildren) : null; +}; + +/* ------------------------------------------------------------------------------------------------- + * getSlottableElementFromSlottable + * -----------------------------------------------------------------------------------------------*/ + +const getSlottableElementFromSlottable = (slottable: SlottableElement) => { + if ('child' in slottable.props) { + const child = slottable.props.child; + if (!React.isValidElement(child)) return null; + return React.cloneElement(child, undefined, slottable.props.children(child.props.children)); + } + + const child = slottable.props.children; + return React.isValidElement(child) ? child : null; +}; + +/* ------------------------------------------------------------------------------------------------- + * mergeProps + * -----------------------------------------------------------------------------------------------*/ + +type AnyProps = Record; function mergeProps(slotProps: AnyProps, childProps: AnyProps) { // all child props should override @@ -158,6 +148,10 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps) { return { ...slotProps, ...overrideProps }; } +/* ------------------------------------------------------------------------------------------------- + * getElementRef + * -----------------------------------------------------------------------------------------------*/ + // Before React 19 accessing `element.props.ref` will throw a warning and suggest using `element.ref` // After React 19 accessing `element.ref` does the opposite. // https://github.com/facebook/react/pull/28348 @@ -182,6 +176,23 @@ function getElementRef(element: React.ReactElement) { return (element.props as { ref?: React.Ref }).ref || (element as any).ref; } +/* ---------------------------------------------------------------------------------------------- */ + +type SlottableElement = React.ReactElement; + +function isSlottable(child: React.ReactNode): child is SlottableElement { + return ( + React.isValidElement(child) && + typeof child.type === 'function' && + '__radixId' in child.type && + child.type.__radixId === SLOTTABLE_IDENTIFIER + ); +} + +const createSlotWarning = (ownerName: string) => { + return `${ownerName} failed to slot onto its children. Expected a single React element child or \`Slottable\`.`; +}; + export { Slot, Slottable,