Skip to content

Commit feab322

Browse files
fix: preserve bubbling Tab handling while capturing Escape
1 parent b22af69 commit feab322

2 files changed

Lines changed: 87 additions & 2 deletions

File tree

‎src/hooks/useAccessibility.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,21 @@ export default function useAccessibility({
7575

7676
React.useEffect(() => {
7777
if (open) {
78-
window.addEventListener('keydown', handleKeyDown, true);
78+
const handleCapture = (event: KeyboardEvent) => {
79+
if (event.keyCode === ESC) handleKeyDown(event);
80+
};
81+
const handleBubble = (event: KeyboardEvent) => {
82+
if (event.keyCode !== ESC) handleKeyDown(event);
83+
};
84+
window.addEventListener('keydown', handleCapture, true);
85+
window.addEventListener('keydown', handleBubble);
7986
if (autoFocus) {
8087
// FIXME: hack with raf
8188
raf(() => focusMenu({ preventScroll: true }), 3);
8289
}
8390
return () => {
84-
window.removeEventListener('keydown', handleKeyDown, true);
91+
window.removeEventListener('keydown', handleCapture, true);
92+
window.removeEventListener('keydown', handleBubble);
8593
focusMenuRef.current = false;
8694
};
8795
}

‎tests/useAccessibility.test.tsx‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,80 @@ it('closes and returns focus to trigger on Escape key', () => {
5656
trigger.remove();
5757
}
5858
});
59+
60+
it('lets an overlay handle Tab before the window listener', () => {
61+
const trigger = document.createElement('button');
62+
const overlay = document.createElement('div');
63+
const input = document.createElement('input');
64+
overlay.tabIndex = -1;
65+
overlay.appendChild(input);
66+
document.body.append(trigger, overlay);
67+
const onOpenChange = jest.fn();
68+
input.addEventListener('keydown', (event) => event.stopPropagation());
69+
const { unmount } = renderHook(() =>
70+
useAccessibility({
71+
open: true,
72+
triggerRef: { current: trigger },
73+
overlayRef: { current: overlay },
74+
onOpenChange,
75+
}),
76+
);
77+
try {
78+
input.focus();
79+
const event = new KeyboardEvent('keydown', {
80+
key: 'Tab',
81+
keyCode: 9,
82+
bubbles: true,
83+
cancelable: true,
84+
});
85+
act(() => {
86+
input.dispatchEvent(event);
87+
});
88+
expect(document.activeElement).toBe(input);
89+
expect(event.defaultPrevented).toBe(false);
90+
expect(onOpenChange).not.toHaveBeenCalled();
91+
} finally {
92+
unmount();
93+
trigger.remove();
94+
overlay.remove();
95+
}
96+
});
97+
98+
it('restores focus before an earlier window bubble listener unmounts the hook', () => {
99+
const trigger = document.createElement('button');
100+
const item = document.createElement('button');
101+
document.body.append(trigger, item);
102+
item.focus();
103+
const onOpenChange = jest.fn();
104+
let teardown = () => {};
105+
const portalListener = () => teardown();
106+
window.addEventListener('keydown', portalListener);
107+
const { unmount } = renderHook(() =>
108+
useAccessibility({
109+
open: true,
110+
triggerRef: { current: trigger },
111+
onOpenChange,
112+
}),
113+
);
114+
teardown = unmount;
115+
try {
116+
act(() => {
117+
item.dispatchEvent(
118+
new KeyboardEvent('keydown', {
119+
key: 'Escape',
120+
keyCode: 27,
121+
bubbles: true,
122+
cancelable: true,
123+
}),
124+
);
125+
});
126+
expect(onOpenChange).toHaveBeenCalledTimes(1);
127+
expect(onOpenChange).toHaveBeenCalledWith(false);
128+
expect(document.activeElement).toBe(trigger);
129+
} finally {
130+
window.removeEventListener('keydown', portalListener);
131+
unmount();
132+
trigger.remove();
133+
item.remove();
134+
}
135+
});

0 commit comments

Comments
 (0)