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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Normalizing user input and runtime events into one canonical form makes lookups exact and order-independent.
- 2Returning an unregister closure lets each registration clean up after itself without exposing internal state.
- 3Binding a listener as an arrow-function field keeps `this` stable so the same reference can be added and later removed.
Related explainers
typescript
import { Component } from '@angular/core'; import { trigger, transition,
Staggered list animations in Angular
animations
stagger
enter-leave
Intermediate
10 steps
go
package email import ( "errors"
Normalizing and deduping email addresses in Go
validation
normalization
deduplication
Intermediate
8 steps
typescript
type MatchSegment = { text: string; matched: boolean; };
Splitting text into highlighted match segments
regex
string-matching
text-highlighting
Intermediate
8 steps
typescript
import { Component, input, computed } from '@angular/core'; function toNumber(value: number | string): number { return typeof value === 'number' ? value : parseFloat(value);
Signal inputs and computed in Angular
signals
reactivity
derived-state
Intermediate
5 steps
typescript
import sanitizeHtml from "sanitize-html"; interface RichTextOptions { allowImages?: boolean;
Building a configurable HTML sanitizer allowlist
sanitization
xss-prevention
allowlist
Intermediate
7 steps
javascript
class StarRating { constructor(container, { max = 5, value = 0, onChange } = {}) { this.container = container; this.max = max;
Building an accessible star-rating widget
dom
event-handling
accessibility
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-keyboard-shortcut-manager-in-typescript-explained-typescript-dfa6/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.