Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@
"npm": "11.5.2"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
20 changes: 20 additions & 0 deletions spec/components/Badge/Badge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ describe('Badge component', () => {
expect(renderedBadge.classList.contains('cio-badge')).toBeTruthy();
});

test('renders the child without any wrapping <span>', () => {
const { container } = render(
<Badge asChild variant='outline'>
<a href='/x'>linked badge</a>
</Badge>,
);
expect(container.querySelector('span')).toBeNull();
expect(container.querySelector('a')).not.toBeNull();
});

test('sets data-slot="badge" on the rendered child', () => {
render(
<Badge asChild>
<a href='/x'>linked badge</a>
</Badge>,
);
const a = screen.getByText('linked badge');
expect(a.getAttribute('data-slot')).toBe('badge');
});

test('renders componentOverride if passed', () => {
render(
<Badge
Expand Down
31 changes: 31 additions & 0 deletions spec/components/Button/Button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ describe('Button component', () => {
expect(renderedButton.classList.contains('cio-button')).toBeTruthy();
});

test('renders the child without any wrapping <button>', () => {
const { container } = render(
<Button asChild>
<a href='/x'>linked button</a>
</Button>,
);
expect(container.querySelector('button')).toBeNull();
expect(container.querySelector('a')).not.toBeNull();
});

test('sets data-slot="button" on the rendered child', () => {
render(
<Button asChild>
<a href='/x'>linked button</a>
</Button>,
);
const a = screen.getByText('linked button');
expect(a.getAttribute('data-slot')).toBe('button');
});

test('composes onClick from Button and the child (child first, then Button)', () => {
const order: string[] = [];
render(
<Button asChild onClick={() => order.push('button')}>
<a href='/x' onClick={() => order.push('child')}>linked button</a>
</Button>,
);
fireEvent.click(screen.getByText('linked button'));
expect(order).toEqual(['child', 'button']);
});

test('renders componentOverride if passed', () => {
render(
<Button
Expand Down
182 changes: 182 additions & 0 deletions spec/utils/slot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import React from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { describe, test, expect, vi, afterEach } from 'vitest';
import { Slot } from '@/utils';

describe('Slot', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

// 1. Renders the single child element with no wrapper
test('renders the single child element with no extra wrapper DOM', () => {
const { container } = render(
<Slot>
<a href='/'>link</a>
</Slot>,
);
expect(container.firstChild).not.toBeNull();
expect((container.firstChild as HTMLElement).tagName).toBe('A');
expect(container.firstChild?.parentElement).toBe(container);
});

// 2. Forwards an object ref to the child DOM node
test('forwards an object ref to the child DOM node', () => {
const ref = React.createRef<HTMLAnchorElement>();
render(
<Slot ref={ref as React.Ref<HTMLElement>}>
<a href='/'>link</a>
</Slot>,
);
expect(ref.current).toBeInstanceOf(HTMLAnchorElement);
});

// 3. Forwards a callback ref to the child DOM node
test('forwards a callback ref to the child DOM node', () => {
let received: HTMLElement | null = null;
render(
<Slot ref={(node) => { received = node; }}>
<a href='/'>link</a>
</Slot>,
);
expect(received).toBeInstanceOf(HTMLAnchorElement);
});

// 4. Composes slot's ref with child element's own ref
test('composes slot ref with child own ref so both resolve to the same DOM node', () => {
const slotRef = React.createRef<HTMLAnchorElement>();
const childRef = React.createRef<HTMLAnchorElement>();
render(
<Slot ref={slotRef as React.Ref<HTMLElement>}>
<a href='/' ref={childRef}>
link
</a>
</Slot>,
);
expect(slotRef.current).toBeInstanceOf(HTMLAnchorElement);
expect(childRef.current).toBeInstanceOf(HTMLAnchorElement);
expect(slotRef.current).toBe(childRef.current);
});

// 5. Concatenates className from slot and child
test('concatenates className from slot and child', () => {
const { container } = render(
<Slot className='slot-class'>
<a href='/' className='child-class'>
link
</a>
</Slot>,
);
const el = container.firstChild as HTMLElement;
expect(el.className).toContain('slot-class');
expect(el.className).toContain('child-class');
});

// 6. Shallow-merges style; child overrides slot on conflict
test('shallow-merges style with child winning on conflict', () => {
const { container } = render(
<Slot style={{ color: 'red', margin: '4px' }}>
<a href='/' style={{ color: 'blue', padding: '2px' }}>
link
</a>
</Slot>,
);
const el = container.firstChild as HTMLElement;
expect(el.style.color).toBe('blue');
expect(el.style.margin).toBe('4px');
expect(el.style.padding).toBe('2px');
});

// 7. Composes event handlers: child runs first, then slot
test('composes event handlers: child handler runs before slot handler', () => {
const order: string[] = [];
const { container } = render(
<Slot onClick={() => { order.push('slot'); }}>
<button onClick={() => { order.push('child'); }} type='button'>
click me
</button>
</Slot>,
);
const button = container.firstChild as HTMLButtonElement;
fireEvent.click(button);
expect(order).toEqual(['child', 'slot']);
});

// 7b. Slot handler is skipped when child handler calls preventDefault
test('slot handler is skipped when child handler calls preventDefault', () => {
const slotClick = vi.fn();
const { container } = render(
<Slot onClick={slotClick}>
<button onClick={(e) => e.preventDefault()} type='button'>
click me
</button>
</Slot>,
);
const button = container.firstChild as HTMLButtonElement;
fireEvent.click(button);
expect(slotClick).not.toHaveBeenCalled();
});

// 8. Slot's handler fires when child has no handler of the same name
test("slot's handler fires when child has no handler for that event", () => {
const slotClick = vi.fn();
const { container } = render(
<Slot onClick={slotClick}>
<button type='button'>click me</button>
</Slot>,
);
const button = container.firstChild as HTMLButtonElement;
fireEvent.click(button);
expect(slotClick).toHaveBeenCalledTimes(1);
});

// 9. Child non-handler prop wins over slot's
test('child non-handler props win over slot props', () => {
const { container } = render(
<Slot id='slot-id' aria-label='slot label'>
<a href='/' id='child-id' aria-label='child label'>
link
</a>
</Slot>,
);
const el = container.firstChild as HTMLElement;
expect(el.id).toBe('child-id');
expect(el.getAttribute('aria-label')).toBe('child label');
});

// 10. Throws when given multiple children
test('throws when given multiple children', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() =>
render(
<Slot>
<span>one</span>
<span>two</span>
</Slot>,
),
).toThrow(/React.Children.only/);
});

// 11. Returns null when given no valid React element child
test('returns null when given a non-element child (plain string)', () => {
const { container } = render(<Slot>{'plain string'}</Slot>);
expect(container.firstChild).toBeNull();
});

// 12. Forwards ref correctly when child is a forwardRef component
test('forwards ref when child is a forwardRef component', () => {
const Inner = React.forwardRef<HTMLAnchorElement, React.ComponentProps<'a'>>((props, ref) => (
<a ref={ref} {...props} />
));
Inner.displayName = 'Inner';

const slotRef = React.createRef<HTMLAnchorElement>();
render(
<Slot ref={slotRef as React.Ref<HTMLElement>}>
<Inner href='/x'>link</Inner>
</Slot>,
);
expect(slotRef.current).toBeInstanceOf(HTMLAnchorElement);
});
});
3 changes: 1 addition & 2 deletions src/components/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { ReactNode } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn, RenderPropsWrapper } from '@/utils';
import { cn, RenderPropsWrapper, Slot } from '@/utils';
import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types';
import { cva, VariantProps } from 'class-variance-authority';

Expand Down
3 changes: 1 addition & 2 deletions src/components/button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { ReactNode } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn, RenderPropsWrapper } from '@/utils';
import { cn, RenderPropsWrapper, Slot } from '@/utils';
import { ComponentOverrideProps, IncludeComponentOverrides } from '@/types';
import { cva, VariantProps } from 'class-variance-authority';

Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
export * from './styleHelpers';
export * from './events';
export { default as RenderPropsWrapper } from './RenderPropsWrapper';
export { Slot } from './slot';
89 changes: 89 additions & 0 deletions src/utils/slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';

function setRef<T>(ref: React.Ref<T> | undefined | null, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref != null) {
Comment thread
esezen marked this conversation as resolved.
(ref as React.RefObject<T | null>).current = value;
}
}

function composeRefs<T>(...refs: (React.Ref<T> | undefined | null)[]): React.RefCallback<T> {
return (node: T | null) => {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
refs.forEach((ref) => setRef(ref, node));
};
}

function mergeProps(slotProps: Record<string, unknown>, childProps: Record<string, unknown>) {
const overrideProps = { ...childProps };

for (const propName in childProps) {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
const slotPropValue = slotProps[propName];
const childPropValue = childProps[propName];
const isHandler = /^on[A-Z]/.test(propName);

if (isHandler) {
if (typeof slotPropValue === 'function' && typeof childPropValue === 'function') {
overrideProps[propName] = (...args: unknown[]) => {
const result = (childPropValue as (...a: unknown[]) => unknown)(...args);
const event = args[0] as { defaultPrevented?: boolean } | undefined;
if (!event?.defaultPrevented) {
(slotPropValue as (...a: unknown[]) => unknown)(...args);
}
return result;
};
} else if (typeof slotPropValue === 'function') {
Comment thread
esezen marked this conversation as resolved.
overrideProps[propName] = slotPropValue;
}
} else if (propName === 'style') {
overrideProps[propName] = {
...(slotPropValue as object),
...(childPropValue as object),
Comment on lines +39 to +41
};
} else if (propName === 'className') {
overrideProps[propName] = [slotPropValue, childPropValue].filter(Boolean).join(' ');
}
}

return { ...slotProps, ...overrideProps };
}

function getElementRef(element: React.ReactElement): React.Ref<unknown> | undefined {
Comment thread
esezen marked this conversation as resolved.
const props = element.props as Record<string, unknown>;
const getter = Object.getOwnPropertyDescriptor(props, 'ref')?.get;
const mayWarn =
getter && 'isReactWarning' in getter && (getter as { isReactWarning?: boolean }).isReactWarning;
if (mayWarn) {
return (element as unknown as { ref?: React.Ref<unknown> }).ref;
}

return (
(props.ref as React.Ref<unknown>) || (element as unknown as { ref?: React.Ref<unknown> }).ref
);
}

export interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children?: React.ReactNode;
}

const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;

if (React.isValidElement(children)) {
const childRef = getElementRef(children);
const mergedProps = mergeProps(slotProps, children.props as Record<string, unknown>);
mergedProps.ref = composeRefs(forwardedRef, childRef);

return React.cloneElement(children, mergedProps);
}

if (React.Children.count(children) > 1) {
Comment thread
esezen marked this conversation as resolved.
React.Children.only(null);
Comment thread
esezen marked this conversation as resolved.
Comment thread
esezen marked this conversation as resolved.
}

return null;
});

Slot.displayName = 'Slot';

export { Slot };
Loading