+`;
+
+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,