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 11d7b7f56..f14134b20 100644 --- a/packages/react/slot/src/__snapshots__/slot.test.tsx.snap +++ b/packages/react/slot/src/__snapshots__/slot.test.tsx.snap @@ -36,6 +36,74 @@ 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`] = ` +
+ +
+`; + exports[`given a Slot with React lazy components > with a lazy component in Button with Slottable > should render a lazy link with icon on the left/right 1`] = `
{ }); }); +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(); + }); + }); +}); + // TODO: Unskip when underlying issue is resolved // Reverted in https://github.com/radix-ui/primitives/pull/3554 describe.skip('given an Input', () => { @@ -253,6 +304,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 a862f2d2a..ded7d70f7 100644 --- a/packages/react/slot/src/slot.tsx +++ b/packages/react/slot/src/slot.tsx @@ -7,138 +7,94 @@ declare module 'react' { } } -const REACT_LAZY_TYPE = Symbol.for('react.lazy'); - -interface LazyReactElement extends React.ReactElement { - $$typeof: typeof REACT_LAZY_TYPE; - _payload: PromiseLike>>; -} - /* ------------------------------------------------------------------------------------------------- * Slot * -----------------------------------------------------------------------------------------------*/ export type Usable = PromiseLike | React.Context; -const use: typeof React.use | undefined = (React as any)[' use '.trim().toString()]; interface SlotProps extends React.HTMLAttributes { children?: React.ReactNode; } -function isPromiseLike(value: unknown): value is PromiseLike { - return typeof value === 'object' && value !== null && 'then' in value; -} - -function isLazyComponent(element: React.ReactNode): element is LazyReactElement { - return ( - element != null && - typeof element === 'object' && - '$$typeof' in element && - element.$$typeof === REACT_LAZY_TYPE && - '_payload' in element && - isPromiseLike(element._payload) - ); -} - /* @__NO_SIDE_EFFECTS__ */ export function createSlot(ownerName: string) { - const SlotClone = createSlotClone(ownerName); const Slot = React.forwardRef((props, forwardedRef) => { let { children, ...slotProps } = props; + let slottableElement: React.ReactElement | null = null; + const newChildren: React.ReactNode[] = []; + if (isLazyComponent(children) && typeof use === 'function') { children = use(children._payload); } - 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} - - ); - } - - return ( - - {children} - - ); - }); - - Slot.displayName = `${ownerName}.Slot`; - return Slot; -} -const Slot = createSlot('Slot'); + React.Children.forEach(children, (maybeSlottable) => { + if (isSlottable(maybeSlottable)) { + const slottable = maybeSlottable; + let child = 'child' in slottable.props ? slottable.props.child : slottable.props.children; -/* ------------------------------------------------------------------------------------------------- - * SlotClone - * -----------------------------------------------------------------------------------------------*/ + if (isLazyComponent(child) && typeof use === 'function') { + child = use(child._payload); + } -interface SlotCloneProps { - children: React.ReactNode; -} + slottableElement = getSlottableElementFromSlottable(slottable, child); + newChildren.push((slottableElement?.props as any)?.children); + } else { + newChildren.push(maybeSlottable); + } + }); -/* @__NO_SIDE_EFFECTS__ */ function createSlotClone(ownerName: string) { - const SlotClone = React.forwardRef((props, forwardedRef) => { - let { children, ...slotProps } = props; - if (isLazyComponent(children) && typeof use === 'function') { - children = use(children._payload); + if (slottableElement) { + slottableElement = React.cloneElement(slottableElement, undefined, newChildren); + } else if (React.Children.count(children) === 1 && React.isValidElement(children)) { + slottableElement = children; } - 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; + if (!slottableElement) { + if ((children || children === 0) && process.env.NODE_ENV !== 'production') { + console.warn(createSlotWarning(ownerName)); } - return React.cloneElement(children, props); + return children; + } + + const slottableElementRef = getElementRef(slottableElement); + const composedRefs = composeRefs(forwardedRef, slottableElementRef); + 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.Children.count(children) > 1 ? React.Children.only(null) : null; + return React.cloneElement(slottableElement, mergedProps); }); - SlotClone.displayName = `${ownerName}.SlotClone`; - return SlotClone; + Slot.displayName = `${ownerName}.Slot`; + return Slot; } +const Slot = createSlot('Slot'); + /* ------------------------------------------------------------------------------------------------- * 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; @@ -146,20 +102,25 @@ interface SlottableComponent extends React.FC { const Slottable = createSlottable('Slottable'); -/* ---------------------------------------------------------------------------------------------- */ +/* ------------------------------------------------------------------------------------------------- + * getSlottableElementFromSlottable + * -----------------------------------------------------------------------------------------------*/ -type AnyProps = Record; +const getSlottableElementFromSlottable = (slottable: SlottableElement, child: React.ReactNode) => { + 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)); + } -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 - ); -} + return React.isValidElement(child) ? child : null; +}; + +/* ------------------------------------------------------------------------------------------------- + * mergeProps + * -----------------------------------------------------------------------------------------------*/ + +type AnyProps = Record; function mergeProps(slotProps: AnyProps, childProps: AnyProps) { // all child props should override @@ -195,6 +156,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 @@ -219,6 +184,49 @@ 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 React.ReactElement { + return ( + React.isValidElement(child) && + typeof child.type === 'function' && + '__radixId' in child.type && + child.type.__radixId === SLOTTABLE_IDENTIFIER + ); +} + +const REACT_LAZY_TYPE = Symbol.for('react.lazy'); + +interface LazyReactElement extends React.ReactElement { + $$typeof: typeof REACT_LAZY_TYPE; + _payload: PromiseLike>>; +} + +function isLazyComponent(element: React.ReactNode): element is LazyReactElement { + return ( + element != null && + typeof element === 'object' && + '$$typeof' in element && + element.$$typeof === REACT_LAZY_TYPE && + '_payload' in element && + isPromiseLike(element._payload) + ); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof value === 'object' && value !== null && 'then' in value; +} + +const createSlotWarning = (ownerName: string) => { + return `${ownerName} failed to slot onto its children. Expected a single React element child or \`Slottable\`.`; +}; + +const use: typeof React.use | undefined = (React as any)[' use '.trim().toString()]; + export { Slot, Slottable,