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