Skip to content

Commit 4f13157

Browse files
authored
Add vim-style keyboard movement keys (#277)
Make hjkl (and arrow keys) movement keys: h/l move between nav tabs, j/k move through list entries. Pressing j/down from a tab jumps to the first list entry on the page, and k/up from the first entry returns to the nav tab. The home tab shortcut moves from h to o to free up h for left movement, and LinkList entries gain a data-nav-item marker so movement targets work on every list page.
1 parent b838a99 commit 4f13157

3 files changed

Lines changed: 100 additions & 6 deletions

File tree

src/client/keyboard-shortcuts.ts

Lines changed: 97 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
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+
517
const 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+
724
function 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

src/components/LinkList.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const HeadingTag = headingLevel;
5656
<li class="contents before:content-none">
5757
<a
5858
href={item.slug}
59+
data-nav-item
5960
class="bg-0 hocus:invert group col-span-3 md:-mx-[1ch] grid grid-cols-subgrid md:px-[1ch] outline-none"
6061
>
6162
{columns.map((col) => {
@@ -122,6 +123,7 @@ const HeadingTag = headingLevel;
122123
<li>
123124
<a
124125
href={item.slug}
126+
data-nav-item
125127
class="group flex items-center gap-[1ch] bg-0 hocus:invert outline-none md:-mx-[1ch] md:px-[1ch]"
126128
>
127129
{columns.map((col) => {

src/components/Nav.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interface Tab {
88
}
99
1010
const tabs: Tab[] = [
11-
{ href: "/", keyChar: "h", label: "home" },
11+
{ href: "/", keyChar: "o", label: "home" },
1212
{ href: "/blog", keyChar: "b", label: "blog" },
1313
{ href: "/projects", keyChar: "p", label: "projects" },
1414
{ href: "/research", keyChar: "r", label: "research" },

0 commit comments

Comments
 (0)