Skip to content

Commit 99f3a74

Browse files
committed
fix(nav): Use capture-phase listener so Cmd+K works when inputs have focus
Components like the search query builder call stopPropagation() on keydown events, preventing them from reaching the document-level listener in useHotkeys. This adds a useCapture option to useHotkeys that registers the listener on the capture phase, firing before any child component can stop propagation.
1 parent 15779fe commit 99f3a74

File tree

3 files changed

+62
-1
lines changed

3 files changed

+62
-1
lines changed

static/app/utils/useHotkeys.spec.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,50 @@ describe('useHotkeys', () => {
157157
expect(callback).toHaveBeenCalled();
158158
});
159159

160+
it('registers on capture phase with useCapture', () => {
161+
const callback = jest.fn();
162+
163+
renderHook(p => useHotkeys(p), {
164+
initialProps: [{match: 'command+k', callback, useCapture: true}],
165+
});
166+
167+
expect(document.addEventListener).toHaveBeenCalledWith(
168+
'keydown',
169+
expect.any(Function),
170+
true
171+
);
172+
173+
const captureCall = (document.addEventListener as jest.Mock).mock.calls.find(
174+
call => call[0] === 'keydown' && call[2] === true
175+
);
176+
expect(captureCall).toBeDefined();
177+
178+
const captureHandler = captureCall[1];
179+
const evt = makeKeyEventFixture('k', {metaKey: true});
180+
captureHandler(evt);
181+
182+
expect(callback).toHaveBeenCalled();
183+
});
184+
185+
it('does not fire capture hotkeys on bubble phase', () => {
186+
const callback = jest.fn();
187+
188+
renderHook(p => useHotkeys(p), {
189+
initialProps: [{match: 'command+k', callback, useCapture: true}],
190+
});
191+
192+
const bubbleCall = (document.addEventListener as jest.Mock).mock.calls.find(
193+
call => call[0] === 'keydown' && call[2] !== true
194+
);
195+
expect(bubbleCall).toBeDefined();
196+
197+
const bubbleHandler = bubbleCall[1];
198+
const evt = makeKeyEventFixture('k', {metaKey: true});
199+
bubbleHandler(evt);
200+
201+
expect(callback).not.toHaveBeenCalled();
202+
});
203+
160204
it('skips preventDefault', () => {
161205
const callback = jest.fn();
162206

static/app/utils/useHotkeys.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ type Hotkey = {
4444
* Do not call preventDefault on the keydown event
4545
*/
4646
skipPreventDefault?: boolean;
47+
/**
48+
* Register the listener on the capture phase instead of the bubble phase.
49+
* Use this when the shortcut must fire even if a child component calls
50+
* `stopPropagation()` on the keydown event (e.g., the search query builder).
51+
*/
52+
useCapture?: boolean;
4753
};
4854

4955
/**
@@ -63,8 +69,12 @@ export function useHotkeys(hotkeys: Hotkey[]): void {
6369
});
6470

6571
useEffect(() => {
66-
const onKeyDown = (evt: KeyboardEvent) => {
72+
const makeHandler = (capture: boolean) => (evt: KeyboardEvent) => {
6773
for (const hotkey of hotkeysRef.current) {
74+
if (!!hotkey.useCapture !== capture) {
75+
continue;
76+
}
77+
6878
const preventDefault = !hotkey.skipPreventDefault;
6979
const keysets = toArray(hotkey.match).map(keys => keys.toLowerCase());
7080

@@ -92,10 +102,15 @@ export function useHotkeys(hotkeys: Hotkey[]): void {
92102
}
93103
};
94104

105+
const onKeyDown = makeHandler(false);
106+
const onKeyDownCapture = makeHandler(true);
107+
95108
document.addEventListener('keydown', onKeyDown);
109+
document.addEventListener('keydown', onKeyDownCapture, true);
96110

97111
return () => {
98112
document.removeEventListener('keydown', onKeyDown);
113+
document.removeEventListener('keydown', onKeyDownCapture, true);
99114
};
100115
}, []);
101116
}

static/app/views/navigation/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ function UserAndOrganizationNavigation() {
4242
: [
4343
{
4444
match: ['command+shift+p', 'command+k', 'ctrl+shift+p', 'ctrl+k'],
45+
includeInputs: true,
46+
useCapture: true,
4547
callback: () => {
4648
if (organization.features.includes('cmd-k-supercharged')) {
4749
openCommandPalette();

0 commit comments

Comments
 (0)