Skip to content

Commit 91109f4

Browse files
committed
feat(a11y): generalize ContextMenu keyboard navigation
1 parent 0cb5513 commit 91109f4

7 files changed

Lines changed: 395 additions & 29 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import React, { useState } from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { DialogManagerProvider } from '../../../context';
4+
import { ContextMenu, ContextMenuButton, useContextMenuContext } from '../components';
5+
import { useDialogOnNearestManager } from '../hooks';
6+
7+
const dialogId = 'context-menu-dialog';
8+
9+
const ContextMenuTestSubmenu = () => {
10+
const { returnToParentMenu } = useContextMenuContext();
11+
12+
return (
13+
<ContextMenuButton onClick={returnToParentMenu}>Back from submenu</ContextMenuButton>
14+
);
15+
};
16+
17+
const ContextMenuOpenSubmenuButton = () => {
18+
const { openSubmenu } = useContextMenuContext();
19+
20+
return (
21+
<ContextMenuButton
22+
onClick={(event) => {
23+
openSubmenu({
24+
focusReturnTarget: event.currentTarget,
25+
Submenu: ContextMenuTestSubmenu,
26+
});
27+
}}
28+
>
29+
Open submenu
30+
</ContextMenuButton>
31+
);
32+
};
33+
34+
const ContextMenuFixture = ({
35+
hiddenItemBeforeSubmenuTrigger,
36+
hiddenItemIndex,
37+
includeSubmenuTrigger,
38+
}: {
39+
hiddenItemBeforeSubmenuTrigger?: boolean;
40+
hiddenItemIndex?: number;
41+
includeSubmenuTrigger?: boolean;
42+
}) => {
43+
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(
44+
null,
45+
);
46+
const { dialog, dialogManager } = useDialogOnNearestManager({ id: dialogId });
47+
48+
return (
49+
<>
50+
<button
51+
data-testid='open-context-menu'
52+
onClick={() => dialog.open()}
53+
ref={setReferenceElement}
54+
>
55+
Open
56+
</button>
57+
<ContextMenu
58+
aria-label='Fixture menu'
59+
dialogManagerId={dialogManager?.id}
60+
id={dialogId}
61+
onClose={dialog.close}
62+
placement='bottom-start'
63+
referenceElement={referenceElement}
64+
tabIndex={-1}
65+
trapFocus
66+
>
67+
{hiddenItemBeforeSubmenuTrigger && (
68+
<ContextMenuButton style={{ display: 'none' }}>
69+
Hidden before submenu trigger
70+
</ContextMenuButton>
71+
)}
72+
{includeSubmenuTrigger && <ContextMenuOpenSubmenuButton />}
73+
<ContextMenuButton
74+
style={hiddenItemIndex === 0 ? { display: 'none' } : undefined}
75+
>
76+
First item
77+
</ContextMenuButton>
78+
<ContextMenuButton
79+
style={hiddenItemIndex === 1 ? { display: 'none' } : undefined}
80+
>
81+
Second item
82+
</ContextMenuButton>
83+
<ContextMenuButton
84+
style={hiddenItemIndex === 2 ? { display: 'none' } : undefined}
85+
>
86+
Third item
87+
</ContextMenuButton>
88+
</ContextMenu>
89+
</>
90+
);
91+
};
92+
93+
describe('ContextMenu keyboard navigation', () => {
94+
const openMenu = async () => {
95+
fireEvent.click(screen.getByTestId('open-context-menu'));
96+
await screen.findByRole('menu', { name: 'Fixture menu' });
97+
98+
return screen.getAllByRole('menuitem') as HTMLButtonElement[];
99+
};
100+
101+
it('supports ArrowUp/ArrowDown/Home/End', async () => {
102+
render(
103+
<DialogManagerProvider>
104+
<ContextMenuFixture />
105+
</DialogManagerProvider>,
106+
);
107+
108+
const [firstItem, secondItem, thirdItem] = await openMenu();
109+
110+
firstItem.focus();
111+
fireEvent.keyDown(firstItem, { key: 'ArrowDown' });
112+
expect(secondItem).toHaveFocus();
113+
114+
fireEvent.keyDown(secondItem, { key: 'ArrowUp' });
115+
expect(firstItem).toHaveFocus();
116+
117+
fireEvent.keyDown(firstItem, { key: 'End' });
118+
expect(thirdItem).toHaveFocus();
119+
120+
fireEvent.keyDown(thirdItem, { key: 'Home' });
121+
expect(firstItem).toHaveFocus();
122+
});
123+
124+
it('wraps around from last to first and first to last', async () => {
125+
render(
126+
<DialogManagerProvider>
127+
<ContextMenuFixture />
128+
</DialogManagerProvider>,
129+
);
130+
131+
const items = await openMenu();
132+
const firstItem = items[0];
133+
const lastItem = items[items.length - 1];
134+
135+
lastItem.focus();
136+
fireEvent.keyDown(lastItem, { key: 'ArrowDown' });
137+
expect(firstItem).toHaveFocus();
138+
139+
fireEvent.keyDown(firstItem, { key: 'ArrowUp' });
140+
expect(lastItem).toHaveFocus();
141+
});
142+
143+
it('skips hidden menuitems when wrapping focus', async () => {
144+
render(
145+
<DialogManagerProvider>
146+
<ContextMenuFixture hiddenItemIndex={0} />
147+
</DialogManagerProvider>,
148+
);
149+
150+
const [firstVisibleItem, lastVisibleItem] = await openMenu();
151+
firstVisibleItem.focus();
152+
153+
fireEvent.keyDown(firstVisibleItem, { key: 'ArrowUp' });
154+
expect(lastVisibleItem).toHaveFocus();
155+
156+
fireEvent.keyDown(lastVisibleItem, { key: 'ArrowDown' });
157+
expect(firstVisibleItem).toHaveFocus();
158+
});
159+
160+
it('includes the back button in keyboard navigation within a submenu', async () => {
161+
render(
162+
<DialogManagerProvider>
163+
<ContextMenuFixture includeSubmenuTrigger />
164+
</DialogManagerProvider>,
165+
);
166+
167+
await openMenu();
168+
const parentItem = screen.getByRole('menuitem', { name: 'Open submenu' });
169+
parentItem.focus();
170+
171+
fireEvent.click(parentItem);
172+
173+
const submenuItems = screen.getAllByRole('menuitem') as HTMLButtonElement[];
174+
// The back button (rendered by default ContextMenuBackButton) should be
175+
// included alongside the submenu item.
176+
const backButton = submenuItems.find((item) =>
177+
item.classList.contains('str-chat__context-menu__back-button'),
178+
);
179+
expect(backButton).toBeDefined();
180+
181+
const submenuItem = screen.getByRole('menuitem', { name: 'Back from submenu' });
182+
submenuItem.focus();
183+
184+
fireEvent.keyDown(submenuItem, { key: 'ArrowUp' });
185+
expect(backButton).toHaveFocus();
186+
});
187+
188+
it('restores focus to parent item when returning from submenu', async () => {
189+
render(
190+
<DialogManagerProvider>
191+
<ContextMenuFixture includeSubmenuTrigger />
192+
</DialogManagerProvider>,
193+
);
194+
195+
await openMenu();
196+
const parentItem = screen.getByRole('menuitem', { name: 'Open submenu' });
197+
parentItem.focus();
198+
199+
fireEvent.click(parentItem);
200+
fireEvent.click(screen.getByRole('menuitem', { name: 'Back from submenu' }));
201+
202+
await waitFor(() => {
203+
expect(screen.getByRole('menuitem', { name: 'Open submenu' })).toHaveFocus();
204+
});
205+
});
206+
207+
it('restores focus to parent item when hidden items precede submenu parent', async () => {
208+
render(
209+
<DialogManagerProvider>
210+
<ContextMenuFixture hiddenItemBeforeSubmenuTrigger includeSubmenuTrigger />
211+
</DialogManagerProvider>,
212+
);
213+
214+
await openMenu();
215+
const parentItem = screen.getByRole('menuitem', { name: 'Open submenu' });
216+
parentItem.focus();
217+
218+
fireEvent.click(parentItem);
219+
fireEvent.click(screen.getByRole('menuitem', { name: 'Back from submenu' }));
220+
221+
await waitFor(() => {
222+
expect(screen.getByRole('menuitem', { name: 'Open submenu' })).toHaveFocus();
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)