typescript 53 lines · 7 steps

Building a keyboard shortcut manager in TypeScript

A class that maps normalized key combos to handlers and dispatches them on keydown.

Explained by highlit
1type Handler = (event: KeyboardEvent) => void;
2 
3interface Binding {
4 combo: string;
5 handler: Handler;
6 preventDefault: boolean;
7}
8 
9const MODIFIER_ORDER = ["ctrl", "alt", "shift", "meta"] as const;
10 
11function normalizeCombo(combo: string): string {
12 const parts = combo.toLowerCase().split("+").map((p) => p.trim());
13 const mods = new Set(parts.filter((p) => MODIFIER_ORDER.includes(p as never)));
14 const key = parts.find((p) => !MODIFIER_ORDER.includes(p as never)) ?? "";
15 const ordered = MODIFIER_ORDER.filter((m) => mods.has(m));
16 return [...ordered, key].join("+");
17}
18 
19function eventToCombo(event: KeyboardEvent): string {
20 const parts: string[] = [];
21 if (event.ctrlKey) parts.push("ctrl");
22 if (event.altKey) parts.push("alt");
23 if (event.shiftKey) parts.push("shift");
24 if (event.metaKey) parts.push("meta");
25 parts.push(event.key.toLowerCase());
26 return parts.join("+");
27}
28 
29export class ShortcutManager {
30 private bindings = new Map<string, Binding>();
31 
32 constructor(private target: EventTarget = window) {
33 this.target.addEventListener("keydown", this.onKeyDown as EventListener);
34 }
35 
36 register(combo: string, handler: Handler, options: { preventDefault?: boolean } = {}): () => void {
37 const key = normalizeCombo(combo);
38 this.bindings.set(key, { combo: key, handler, preventDefault: options.preventDefault ?? true });
39 return () => this.bindings.delete(key);
40 }
41 
42 private onKeyDown = (event: KeyboardEvent): void => {
43 const binding = this.bindings.get(eventToCombo(event));
44 if (!binding) return;
45 if (binding.preventDefault) event.preventDefault();
46 binding.handler(event);
47 };
48 
49 destroy(): void {
50 this.target.removeEventListener("keydown", this.onKeyDown as EventListener);
51 this.bindings.clear();
52 }
53}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Normalizing user input and runtime events into one canonical form makes lookups exact and order-independent.
  2. 2Returning an unregister closure lets each registration clean up after itself without exposing internal state.
  3. 3Binding a listener as an arrow-function field keeps `this` stable so the same reference can be added and later removed.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Building a keyboard shortcut manager in TypeScript — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code