Skip to content

Commit e27623e

Browse files
committed
test: add portal focus utilities
1 parent 142c0cb commit e27623e

File tree

4 files changed

+117
-1
lines changed

4 files changed

+117
-1
lines changed

src/components/ui/Dialog/tests/Dialog.test.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React, { createRef } from 'react';
2-
import { render } from '@testing-library/react';
2+
import { render, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { renderWithPortal, assertFocusTrap, assertFocusReturn, assertScrollLock, assertScrollUnlock } from '~/test-utils/portal';
35
import Dialog from '../Dialog';
46

57
describe('Dialog', () => {
@@ -85,5 +87,28 @@ describe('Dialog', () => {
8587
warn.mockRestore();
8688
error.mockRestore();
8789
});
90+
91+
test('mounts in portal, traps focus, returns focus and locks scroll', async () => {
92+
const user = userEvent.setup();
93+
const { getByText, portalRoot, cleanup } = renderWithPortal(
94+
<Dialog.Root>
95+
<Dialog.Trigger>Trigger</Dialog.Trigger>
96+
<Dialog.Portal>
97+
<Dialog.Overlay />
98+
<Dialog.Content>
99+
<Dialog.Close>Close</Dialog.Close>
100+
</Dialog.Content>
101+
</Dialog.Portal>
102+
</Dialog.Root>
103+
);
104+
105+
await user.click(getByText('Trigger'));
106+
await waitFor(() => assertScrollLock());
107+
await assertFocusTrap(portalRoot);
108+
await user.click(getByText('Close'));
109+
assertFocusReturn(getByText('Trigger'));
110+
await waitFor(() => assertScrollUnlock());
111+
cleanup();
112+
});
88113
});
89114

src/components/ui/DropdownMenu/tests/DropdownMenu.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React from 'react';
22
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { renderWithPortal, assertFocusReturn } from '~/test-utils/portal';
35
import '@testing-library/jest-dom';
46
import DropdownMenu from '../DropdownMenu';
57

@@ -67,4 +69,24 @@ describe('DropdownMenu', () => {
6769
const { container } = render(<DropdownMenu.Trigger>Open</DropdownMenu.Trigger>);
6870
expect(container.firstChild).toBeNull();
6971
});
72+
73+
it('renders in portal and returns focus when closed', async () => {
74+
const user = userEvent.setup();
75+
const { getByText, portalRoot, cleanup } = renderWithPortal(
76+
<DropdownMenu.Root>
77+
<DropdownMenu.Trigger>Menu</DropdownMenu.Trigger>
78+
<DropdownMenu.Portal>
79+
<DropdownMenu.Content>
80+
<DropdownMenu.Item label="Profile">Profile</DropdownMenu.Item>
81+
</DropdownMenu.Content>
82+
</DropdownMenu.Portal>
83+
</DropdownMenu.Root>
84+
);
85+
86+
await user.click(getByText('Menu'));
87+
expect(portalRoot).toContainElement(getByText('Profile'));
88+
await user.keyboard('{Escape}');
89+
assertFocusReturn(getByText('Menu'));
90+
cleanup();
91+
});
7092
});

src/components/ui/Tooltip/tests/Tooltip.behavior.test.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { render, screen, waitFor } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
4+
import { renderWithPortal, assertFocusReturn } from '~/test-utils/portal';
45
import Tooltip from '../Tooltip';
56
import axe from 'axe-core';
67

@@ -95,4 +96,21 @@ describe('Tooltip interactions', () => {
9596
await userEvent.hover(trigger);
9697
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
9798
});
99+
100+
test('portal renders tooltip content and focus is restored on escape', async () => {
101+
const user = userEvent.setup();
102+
const { getByText, cleanup } = renderWithPortal(
103+
<Tooltip.Root>
104+
<Tooltip.Trigger>Tip</Tooltip.Trigger>
105+
<Tooltip.Content>content</Tooltip.Content>
106+
</Tooltip.Root>
107+
);
108+
109+
const trigger = getByText('Tip');
110+
trigger.focus();
111+
await screen.findByRole('tooltip');
112+
await user.keyboard('{Escape}');
113+
assertFocusReturn(trigger);
114+
cleanup();
115+
});
98116
});

src/test-utils/portal.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { render } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
4+
export function renderWithPortal(ui: React.ReactElement) {
5+
const portalRoot = document.createElement('div');
6+
portalRoot.setAttribute('id', 'rad-ui-theme-container');
7+
document.body.appendChild(portalRoot);
8+
const result = render(ui);
9+
return {
10+
...result,
11+
portalRoot,
12+
cleanup: () => {
13+
result.unmount();
14+
portalRoot.remove();
15+
}
16+
};
17+
}
18+
19+
export async function assertFocusTrap(container: HTMLElement) {
20+
const user = userEvent.setup();
21+
const focusable = Array.from(
22+
container.querySelectorAll<HTMLElement>(
23+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
24+
)
25+
);
26+
if (focusable.length === 0) {
27+
throw new Error('No focusable elements found in container');
28+
}
29+
const first = focusable[0];
30+
const last = focusable[focusable.length - 1];
31+
32+
first.focus();
33+
await user.tab({ shift: true });
34+
expect(document.activeElement).toBe(last);
35+
36+
last.focus();
37+
await user.tab();
38+
expect(document.activeElement).toBe(first);
39+
}
40+
41+
export function assertFocusReturn(element: HTMLElement) {
42+
expect(element).toHaveFocus();
43+
}
44+
45+
export function assertScrollLock() {
46+
expect(document.body.getAttribute('data-scroll-locked')).toBe('1');
47+
}
48+
49+
export function assertScrollUnlock() {
50+
expect(document.body.getAttribute('data-scroll-locked')).toBeNull();
51+
}

0 commit comments

Comments
 (0)