From b22af69306b82d60e64c6f513956564d019fe781 Mon Sep 17 00:00:00 2001 From: Ferit Date: Thu, 22 Oct 2026 06:33:06 +0300 Subject: [PATCH 1/3] fix: use capture phase for keydown listener in useAccessibility --- src/hooks/useAccessibility.ts | 4 ++-- tests/useAccessibility.test.tsx | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index e9571ee..c7a8fa5 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -75,13 +75,13 @@ export default function useAccessibility({ React.useEffect(() => { if (open) { - window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown, true); if (autoFocus) { // FIXME: hack with raf raf(() => focusMenu({ preventScroll: true }), 3); } return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', handleKeyDown, true); focusMenuRef.current = false; }; } diff --git a/tests/useAccessibility.test.tsx b/tests/useAccessibility.test.tsx index 931d508..ab51716 100644 --- a/tests/useAccessibility.test.tsx +++ b/tests/useAccessibility.test.tsx @@ -28,3 +28,31 @@ it('closes without consuming Tab when the overlay ref is omitted', () => { trigger.remove(); } }); + +it('closes and returns focus to trigger on Escape key', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + const onOpenChange = jest.fn(); + const { unmount } = renderHook(() => + useAccessibility({ + open: true, + triggerRef: { current: trigger }, + onOpenChange, + }), + ); + try { + const event = new KeyboardEvent('keydown', { + keyCode: 27, + bubbles: true, + cancelable: true, + }); + act(() => { + window.dispatchEvent(event); + }); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(document.activeElement).toBe(trigger); + } finally { + unmount(); + trigger.remove(); + } +}); From feab3221cd8a6bc1a70285dade4919c5e8169c95 Mon Sep 17 00:00:00 2001 From: Ferit Date: Tue, 27 Oct 2026 01:27:41 +0200 Subject: [PATCH 2/3] fix: preserve bubbling Tab handling while capturing Escape --- src/hooks/useAccessibility.ts | 12 ++++- tests/useAccessibility.test.tsx | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index c7a8fa5..11c062c 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -75,13 +75,21 @@ export default function useAccessibility({ React.useEffect(() => { if (open) { - window.addEventListener('keydown', handleKeyDown, true); + const handleCapture = (event: KeyboardEvent) => { + if (event.keyCode === ESC) handleKeyDown(event); + }; + const handleBubble = (event: KeyboardEvent) => { + if (event.keyCode !== ESC) handleKeyDown(event); + }; + window.addEventListener('keydown', handleCapture, true); + window.addEventListener('keydown', handleBubble); if (autoFocus) { // FIXME: hack with raf raf(() => focusMenu({ preventScroll: true }), 3); } return () => { - window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('keydown', handleCapture, true); + window.removeEventListener('keydown', handleBubble); focusMenuRef.current = false; }; } diff --git a/tests/useAccessibility.test.tsx b/tests/useAccessibility.test.tsx index ab51716..7d72713 100644 --- a/tests/useAccessibility.test.tsx +++ b/tests/useAccessibility.test.tsx @@ -56,3 +56,80 @@ it('closes and returns focus to trigger on Escape key', () => { trigger.remove(); } }); + +it('lets an overlay handle Tab before the window listener', () => { + const trigger = document.createElement('button'); + const overlay = document.createElement('div'); + const input = document.createElement('input'); + overlay.tabIndex = -1; + overlay.appendChild(input); + document.body.append(trigger, overlay); + const onOpenChange = jest.fn(); + input.addEventListener('keydown', (event) => event.stopPropagation()); + const { unmount } = renderHook(() => + useAccessibility({ + open: true, + triggerRef: { current: trigger }, + overlayRef: { current: overlay }, + onOpenChange, + }), + ); + try { + input.focus(); + const event = new KeyboardEvent('keydown', { + key: 'Tab', + keyCode: 9, + bubbles: true, + cancelable: true, + }); + act(() => { + input.dispatchEvent(event); + }); + expect(document.activeElement).toBe(input); + expect(event.defaultPrevented).toBe(false); + expect(onOpenChange).not.toHaveBeenCalled(); + } finally { + unmount(); + trigger.remove(); + overlay.remove(); + } +}); + +it('restores focus before an earlier window bubble listener unmounts the hook', () => { + const trigger = document.createElement('button'); + const item = document.createElement('button'); + document.body.append(trigger, item); + item.focus(); + const onOpenChange = jest.fn(); + let teardown = () => {}; + const portalListener = () => teardown(); + window.addEventListener('keydown', portalListener); + const { unmount } = renderHook(() => + useAccessibility({ + open: true, + triggerRef: { current: trigger }, + onOpenChange, + }), + ); + teardown = unmount; + try { + act(() => { + item.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + keyCode: 27, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(document.activeElement).toBe(trigger); + } finally { + window.removeEventListener('keydown', portalListener); + unmount(); + trigger.remove(); + item.remove(); + } +}); From 2c82c0cd805092660a78a769b3f3d3484794d9c1 Mon Sep 17 00:00:00 2001 From: Ferit Date: Tue, 27 Oct 2026 10:11:20 +0200 Subject: [PATCH 3/3] style: retain braces around keyboard listener guards --- src/hooks/useAccessibility.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index 11c062c..6562920 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -76,10 +76,14 @@ export default function useAccessibility({ React.useEffect(() => { if (open) { const handleCapture = (event: KeyboardEvent) => { - if (event.keyCode === ESC) handleKeyDown(event); + if (event.keyCode === ESC) { + handleKeyDown(event); + } }; const handleBubble = (event: KeyboardEvent) => { - if (event.keyCode !== ESC) handleKeyDown(event); + if (event.keyCode !== ESC) { + handleKeyDown(event); + } }; window.addEventListener('keydown', handleCapture, true); window.addEventListener('keydown', handleBubble);