From 4ef7a02ff17af9542424106860b8ab98b62dae94 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 07:38:23 +0300 Subject: [PATCH] keyboard: guard g-leader and j/k against palette and editable targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an early return after the ⌘K and Esc handlers so the g-prefix dispatch, g-start, j/k cursor, and Enter activation skip when the palette is open or the focused element is an input/textarea/contenteditable. Without it, typing "gh" into the palette search input fires go("home") mid-query, navigating away while the palette stays open. Two regression tests added: g+h with palette open → no navigation; g on a focused input → no pending state, follow-up h does nothing. --- web/src/lib/keyboard.test.ts | 22 ++++++++++++++++++++++ web/src/lib/keyboard.ts | 22 +++++++++++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/web/src/lib/keyboard.test.ts b/web/src/lib/keyboard.test.ts index a4811327ba87905ead4335e18cd67d40e4e2bedb..f738143b9345c23bda11ba187dd983f6a4ed04f2 100644 --- a/web/src/lib/keyboard.test.ts +++ b/web/src/lib/keyboard.test.ts @@ -105,6 +105,28 @@ describe('g + letter navigation', () => { expect(opts.go).toHaveBeenCalledWith('health' satisfies RouteName) }) + it('g then h while palette is open → no go() (palette guards prevent navigation mid-query)', () => { + const opts = makeOpts() + opts.isPaletteOpen = vi.fn().mockReturnValue(true) + const ctrl = makeCtrl(opts) + + ctrl.onKeyDown(makeKey('g')) + ctrl.onKeyDown(makeKey('h')) + + expect(opts.go).not.toHaveBeenCalled() + }) + + it('g while target is → no gPending set, follow-up h does nothing', () => { + const opts = makeOpts() + const ctrl = makeCtrl(opts) + + const input = document.createElement('input') + ctrl.onKeyDown(makeKey('g', { target: input })) + ctrl.onKeyDown(makeKey('h', { target: input })) + + expect(opts.go).not.toHaveBeenCalled() + }) + it('teardown clears pending timer', () => { const opts = makeOpts() const ctrl = makeCtrl(opts) diff --git a/web/src/lib/keyboard.ts b/web/src/lib/keyboard.ts index 6e17c5499a65c0ef41bdd7e2e325f15fa0c45056..60e2a12274e5b99b5f5f4c7a62f9b78132ef5728 100644 --- a/web/src/lib/keyboard.ts +++ b/web/src/lib/keyboard.ts @@ -53,6 +53,15 @@ export function createKeyboardController(opts: KeyboardOpts): { return } + // After ⌘K and Esc, all remaining shortcuts (g-leader, j/k, Enter) + // are reserved for the global navigation surface. They must not fire + // while the palette has focus or while the user is typing into any + // editable element — typing "gh" into a search input would otherwise + // navigate home mid-query. + if (opts.isPaletteOpen() || isEditableTarget(e.target)) { + return + } + // g-prefix navigation: consume 'g' then route letter if (gPending) { let route: RouteName | null = null @@ -76,14 +85,13 @@ export function createKeyboardController(opts: KeyboardOpts): { return } - // j / k — cursor movement (only when palette closed and target not editable) - if (!opts.isPaletteOpen() && !isEditableTarget(e.target)) { - if (e.key === 'j') { opts.cursor.move(1); return } - if (e.key === 'k') { opts.cursor.move(-1); return } - } + // j / k — cursor movement + if (e.key === 'j') { opts.cursor.move(1); return } + if (e.key === 'k') { opts.cursor.move(-1); return } - // Enter — activate cursor item when palette closed and target is body - if (e.key === 'Enter' && !opts.isPaletteOpen() && e.target === document.body) { + // Enter — activate cursor item only when target is the document body + // (not, e.g., a tab button that handles its own Enter). + if (e.key === 'Enter' && e.target === document.body) { opts.cursor.activate() return }