Skip to content

Commit 09d39cd

Browse files
authored
test: cover Primitive asChild and forwarding (#1521)
1 parent 142c0cb commit 09d39cd

File tree

2 files changed

+151
-0
lines changed

2 files changed

+151
-0
lines changed

src/core/primitives/Primitive/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ const createPrimitiveComponent = (elementType: SupportedElement) => {
1515
const { asChild = false, children, ...elementProps } = props;
1616

1717
if (asChild) {
18+
if (React.isValidElement(children) && children.type === React.Fragment) {
19+
console.warn(
20+
`Primitive.${elementType}: asChild prop does not support React.Fragment. Please provide a single element.`
21+
);
22+
return React.createElement(elementType, { ...elementProps, ref }, children);
23+
}
24+
1825
// Check if there's exactly one child and it's a valid element
1926
const childrenArray = React.Children.toArray(children);
2027
if (childrenArray.length !== 1 || !React.isValidElement(childrenArray[0])) {
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import React from 'react';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import * as axe from 'axe-core';
5+
import Primitive from '..';
6+
import { ACCESSIBILITY_TEST_TAGS } from '~/setupTests';
7+
8+
// Helper custom component for asChild tests
9+
const CustomLink = React.forwardRef<HTMLAnchorElement, React.ComponentProps<'a'>>(
10+
(props, ref) => <a ref={ref} {...props} />
11+
);
12+
CustomLink.displayName = 'CustomLink';
13+
14+
describe('Primitive asChild', () => {
15+
test('forwards props, className, data attributes, and ref to child', async () => {
16+
const user = userEvent.setup();
17+
const handleClick = jest.fn();
18+
const ref = React.createRef<HTMLButtonElement>();
19+
20+
render(
21+
<Primitive.button
22+
asChild
23+
className="parent-class"
24+
data-test="passed"
25+
onClick={handleClick}
26+
ref={ref}
27+
>
28+
<button>Trigger</button>
29+
</Primitive.button>
30+
);
31+
32+
const button = screen.getByRole('button');
33+
await user.click(button);
34+
35+
expect(button).toHaveClass('parent-class');
36+
expect(button).toHaveAttribute('data-test', 'passed');
37+
expect(handleClick).toHaveBeenCalledTimes(1);
38+
expect(ref.current).toBe(button);
39+
});
40+
41+
test('supports custom child elements without warnings', () => {
42+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
43+
const ref = React.createRef<HTMLAnchorElement>();
44+
45+
render(
46+
<Primitive.div asChild className="link-class" data-id="123" ref={ref}>
47+
<CustomLink href="#">Link</CustomLink>
48+
</Primitive.div>
49+
);
50+
51+
const link = screen.getByText('Link');
52+
expect(link).toHaveClass('link-class');
53+
expect(link).toHaveAttribute('data-id', '123');
54+
expect(ref.current).toBe(link);
55+
expect(warnSpy).not.toHaveBeenCalled();
56+
warnSpy.mockRestore();
57+
});
58+
59+
test('warns and renders host element when child is null', () => {
60+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
61+
const { container } = render(
62+
<Primitive.div asChild>{null}</Primitive.div>
63+
);
64+
expect(warnSpy).toHaveBeenCalledTimes(1);
65+
expect(container.firstElementChild?.tagName).toBe('DIV');
66+
warnSpy.mockRestore();
67+
});
68+
69+
test('warns and renders host element when multiple children provided', () => {
70+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
71+
const { container } = render(
72+
<Primitive.div asChild>
73+
<>
74+
<span />
75+
<span />
76+
</>
77+
</Primitive.div>
78+
);
79+
expect(warnSpy).toHaveBeenCalledTimes(1);
80+
expect(container.firstElementChild?.tagName).toBe('DIV');
81+
warnSpy.mockRestore();
82+
});
83+
84+
test('warns and renders host element when child is a top-level React.Fragment', () => {
85+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
86+
const { container } = render(
87+
<Primitive.div asChild>
88+
<React.Fragment>
89+
<span />
90+
</React.Fragment>
91+
</Primitive.div>
92+
);
93+
expect(warnSpy).toHaveBeenCalledTimes(1);
94+
expect(container.firstElementChild?.tagName).toBe('DIV');
95+
warnSpy.mockRestore();
96+
});
97+
98+
test('forwards controlled and uncontrolled value attributes', async () => {
99+
const user = userEvent.setup();
100+
101+
const Controlled = () => {
102+
const [value, setValue] = React.useState('hello');
103+
return (
104+
<Primitive.input
105+
asChild
106+
value={value}
107+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
108+
data-testid="controlled"
109+
>
110+
<input />
111+
</Primitive.input>
112+
);
113+
};
114+
115+
render(
116+
<>
117+
<Controlled />
118+
<Primitive.input asChild defaultValue="foo" data-testid="uncontrolled">
119+
<input />
120+
</Primitive.input>
121+
</>
122+
);
123+
124+
const controlled = screen.getByTestId('controlled') as HTMLInputElement;
125+
const uncontrolled = screen.getByTestId('uncontrolled') as HTMLInputElement;
126+
127+
expect(controlled.value).toBe('hello');
128+
expect(uncontrolled.value).toBe('foo');
129+
130+
await user.type(controlled, ' world');
131+
await user.type(uncontrolled, 'bar');
132+
133+
expect(controlled.value).toBe('hello world');
134+
expect(uncontrolled.value).toBe('foobar');
135+
});
136+
137+
test('axe: no violations for standard elements', async () => {
138+
const { container } = render(<Primitive.button>Accessible</Primitive.button>);
139+
const results = await axe.run(container, {
140+
runOnly: { type: 'tag', values: ACCESSIBILITY_TEST_TAGS }
141+
});
142+
expect(results.violations.length).toBe(0);
143+
});
144+
});

0 commit comments

Comments
 (0)