~bigbes/lethe

ref: 538e632d05ea9cd425e52f87d4877822bd721a75 lethe/web/src/lib/keyboard.ts -rw-r--r-- 2.9 KiB
538e632d — Eugene Blikh collector: skip rejected rows after partial ingest 24 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// Keyboard controller — side-effect-free at import time.
// The consumer wires document.addEventListener('keydown', controller.onKeyDown)
// and pairs the cleanup with teardown().

export type RouteName = 'home' | 'projects' | 'stats' | 'health'

export interface KeyboardOpts {
  go: (r: RouteName) => void
  openPalette: () => void
  closePalette: () => void
  isPaletteOpen: () => boolean
  cursor: { move(d: 1 | -1): void; activate(): void }
}

const G_TIMEOUT_MS = 800

function isEditableTarget(target: EventTarget | null): boolean {
  if (!(target instanceof Element)) return false
  const tag = target.tagName.toLowerCase()
  if (tag === 'input' || tag === 'textarea') return true
  if (target.hasAttribute('contenteditable')) return true
  return false
}

export function createKeyboardController(opts: KeyboardOpts): {
  onKeyDown(e: KeyboardEvent): void
  teardown(): void
} {
  let gPending = false
  let gTimer: ReturnType<typeof setTimeout> | null = null

  function clearG(): void {
    gPending = false
    if (gTimer !== null) {
      clearTimeout(gTimer)
      gTimer = null
    }
  }

  function onKeyDown(e: KeyboardEvent): void {
    // ⌘K / Ctrl+K → open palette
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
      e.preventDefault()
      opts.openPalette()
      return
    }

    // Esc → close palette if open
    if (e.key === 'Escape') {
      if (opts.isPaletteOpen()) {
        opts.closePalette()
      }
      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
      if (e.key === 'h') route = 'home'
      else if (e.key === 'p') route = 'projects'
      else if (e.key === 's') route = 'stats'
      else if (e.key === 'i') route = 'health'

      if (route !== null) {
        clearG()
        opts.go(route)
        return
      }
      // unrecognised key after g — clear pending and fall through
      clearG()
    }

    if (e.key === 'g') {
      gPending = true
      gTimer = setTimeout(clearG, G_TIMEOUT_MS)
      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 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
    }
  }

  function teardown(): void {
    clearG()
  }

  return { onKeyDown, teardown }
}