11/**
22 * Simple keyboard shortcut system
3- * Finds elements with data-key attributes and sets up global key bindings
3+ * Finds elements with data-key attributes and sets up global key bindings.
4+ *
5+ * Also provides vim-style movement keys:
6+ * h / ArrowLeft - previous nav tab
7+ * l / ArrowRight - next nav tab
8+ * j / ArrowDown - next list entry (from a tab, jumps to the first entry)
9+ * k / ArrowUp - previous list entry (from the first entry, back to the tab)
410 */
11+ declare global {
12+ interface Window {
13+ refreshKeyboardShortcuts : ( ) => void ;
14+ }
15+ }
16+
517const keyMap = new Map ( ) ;
618
19+ const VERTICAL_NEXT = [ "arrowdown" , "j" ] ;
20+ const VERTICAL_PREV = [ "arrowup" , "k" ] ;
21+ const HORIZONTAL_NEXT = [ "arrowright" , "l" ] ;
22+ const HORIZONTAL_PREV = [ "arrowleft" , "h" ] ;
23+
724function initKeyboardShortcuts ( ) {
825 // Clear existing mappings
926 keyMap . clear ( ) ;
@@ -22,14 +39,89 @@ function initKeyboardShortcuts() {
2239 } ) ;
2340}
2441
25- function handleKeyPress ( event ) {
42+ // An element is considered visible if it participates in layout. This filters
43+ // out the duplicate nav (top bar on desktop, bottom bar on mobile) so movement
44+ // only ever targets the nav that's currently on screen.
45+ function isVisible ( element : Element ) {
46+ return ( element as HTMLElement ) . offsetParent !== null || element . getClientRects ( ) . length > 0 ;
47+ }
48+
49+ function getTabs ( ) {
50+ return Array . from ( document . querySelectorAll < HTMLElement > ( ".nav-link" ) ) . filter ( isVisible ) ;
51+ }
52+
53+ function getEntries ( ) {
54+ return Array . from ( document . querySelectorAll < HTMLElement > ( "[data-nav-item]" ) ) . filter ( isVisible ) ;
55+ }
56+
57+ // Returns true when the event was handled as a movement key.
58+ function handleMovement ( event : KeyboardEvent ) {
59+ const key = event . key . toLowerCase ( ) ;
60+ const active = document . activeElement as HTMLElement | null ;
61+
62+ const isEntry = ! ! active && active . hasAttribute ( "data-nav-item" ) ;
63+ const isTab = ! ! active && active . classList . contains ( "nav-link" ) ;
64+
65+ if ( VERTICAL_NEXT . includes ( key ) ) {
66+ const entries = getEntries ( ) ;
67+ if ( ! entries . length ) return false ;
68+ if ( isEntry ) {
69+ const i = entries . indexOf ( active ) ;
70+ if ( i < entries . length - 1 ) entries [ i + 1 ] . focus ( ) ;
71+ else return false ; // already at the last entry, allow default scroll
72+ } else {
73+ // From a tab (or anywhere else) drop into the first list entry.
74+ entries [ 0 ] . focus ( ) ;
75+ }
76+ event . preventDefault ( ) ;
77+ return true ;
78+ }
79+
80+ if ( VERTICAL_PREV . includes ( key ) ) {
81+ if ( ! isEntry ) return false ;
82+ const entries = getEntries ( ) ;
83+ const i = entries . indexOf ( active ) ;
84+ if ( i > 0 ) {
85+ entries [ i - 1 ] . focus ( ) ;
86+ } else {
87+ // From the first entry, go back up to the current/first tab.
88+ const tabs = getTabs ( ) ;
89+ const target = tabs . find ( ( t ) => t . classList . contains ( "current" ) ) || tabs [ 0 ] ;
90+ if ( ! target ) return false ;
91+ target . focus ( ) ;
92+ }
93+ event . preventDefault ( ) ;
94+ return true ;
95+ }
96+
97+ if ( HORIZONTAL_NEXT . includes ( key ) || HORIZONTAL_PREV . includes ( key ) ) {
98+ const tabs = getTabs ( ) ;
99+ if ( ! tabs . length ) return false ;
100+ let idx = isTab ? tabs . indexOf ( active ) : tabs . findIndex ( ( t ) => t . classList . contains ( "current" ) ) ;
101+ if ( idx === - 1 ) idx = 0 ;
102+ const next = idx + ( HORIZONTAL_NEXT . includes ( key ) ? 1 : - 1 ) ;
103+ if ( next < 0 || next >= tabs . length ) return false ;
104+ tabs [ next ] . focus ( ) ;
105+ event . preventDefault ( ) ;
106+ return true ;
107+ }
108+
109+ return false ;
110+ }
111+
112+ function handleKeyPress ( event : KeyboardEvent ) {
26113 // Don't trigger shortcuts when typing in input fields
27- if ( event . target . matches ( "input, textarea, select" ) ) {
114+ if ( ( event . target as HTMLElement ) . matches ( "input, textarea, select" ) ) {
115+ return ;
116+ }
117+
118+ // Don't trigger shortcuts when a modifier key is pressed
119+ if ( event . metaKey || event . ctrlKey || event . altKey ) {
28120 return ;
29121 }
30122
31- // Don't trigger shortcuts when meta key is pressed
32- if ( event . metaKey ) {
123+ // Movement keys take precedence over click shortcuts.
124+ if ( handleMovement ( event ) ) {
33125 return ;
34126 }
35127
0 commit comments