~bigbes/lethe

4ef7a02ff17af9542424106860b8ab98b62dae94 — Eugene Blikh a month ago 136ae1f
keyboard: guard g-leader and j/k against palette and editable targets

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.
2 files changed, 37 insertions(+), 7 deletions(-)

M web/src/lib/keyboard.test.ts
M web/src/lib/keyboard.ts
M web/src/lib/keyboard.test.ts => web/src/lib/keyboard.test.ts +22 -0
@@ 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 <input> → 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)

M web/src/lib/keyboard.ts => web/src/lib/keyboard.ts +15 -7
@@ 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
    }