Esc close

    Appearance

    Color Palette

    Switches to Atkinson Hyperlegible and increases spacing.

    Overengineering keybinds

    Reading time: 10 minutes Published:
    TypeScript
    Frontend

    Let me begin this article with a note. Overengineering is, in my mind, a relative term. It completely depends on the project you’re working on. Some may say the final API of this feature isn’t overengineered. But keep in mind, this implementation is for this site.

    Did you know this site has keybinds? Try pressing ?.

    Neat?

    The starting point

    We don’t need to dive too much into the “business” part of keybinds. I just wanted them here for fun.

    The most obvious implementation would be just to have one big keydown listener, hold some state outside of the listener for sequences of keys, or just do an equality check on a key if it’s a simpler keybind, and then run the appropriate logic (either inline or call the right function).

    And for a site that isn’t planned to change much, especially around this feature, that probably would have been fine.

    But instead, our starting point is going to be… dynamic dispatch.

    keybind-manager.ts
    type KeybindHandler = () => void;
    const registry = new Map<string, KeybindHandler>();
    export function register(key: string, handler: KeybindHandler): void {
    registry.set(key, handler);
    }
    document.addEventListener("keydown", (e: KeyboardEvent): void => {
    const callback = registry.get(e.key);
    if (callback) {
    e.preventDefault();
    callback();
    }
    });

    The idea is for the user to be able to register individual keybinds as a string and a callback that gets run when the keybind is activated. Not a bad start.

    A little weird that calling register on the same key will overwrite the previous handler. We probably should do something about that. Either expand into an array of handlers or throw on duplicate key registration. Both make complete sense for a small site like this.

    Going deeper with the first draft

    We need to decide what kind of keybinds we actually want. Most desktop applications use various modifier keys in addition to one key. But we’re on web, lots of those belong to the browser.

    Safest would be to do something similar to what GitHub does. Have some single key shortcuts and our more complicated ones would use a leader key. Think nvim/vim style dd or dw, the leader for those commands would be d. This completely drops modifier keys and limits our possible keys to ASCII characters.

    keybind-manager.ts
    const singles = new Map<string, KeybindHandler>();
    const pairs = new Map<string, Map<string, KeybindHandler>>();
    const VALID_SEQUENCE = /^[\x21-\x7E]{1,2}$/;
    const LEADER_TIMEOUT = 2000;

    Since our single keybinds can’t be leader keys, and vice versa, splitting the registry makes complete sense. We also probably want to limit our max keybinds to 2 keys, we won’t need more.

    Now our register function looks something like this:

    keybind-manager.ts
    export function register(
    sequence: string,
    handler: KeybindHandler,
    ): void {
    if (!VALID_SEQUENCE.test(sequence)) throw new Error();
    switch (sequence.length) {
    case 1:
    registerSingle(sequence, handler);
    break;
    case 2:
    // we already checked the length,
    // and we know it's ASCII due to the regex
    const leader = sequence[0]!;
    const key = sequence[1]!;
    registerPair(leader, key, handler);
    break;
    default: // unreachable due to regex check
    break;
    }
    }

    The registerSingle and registerPair functions just put the data in the single and pair data structures. The latter has another indirection since it’s a Map of Maps. Don’t forget to make sure that leaders and singles don’t intersect.

    Note

    Some code was omitted to keep these code blocks smaller. Also I’m omitting the error message for brevity. Don’t throw empty Error objects unless you enjoy a bit of extra chaos.

    As for our listener, we end up with something like this:

    keybind-manager.ts
    let activeLeader = "";
    let timeoutId = window.setTimeout(() => {}, 0);
    // ^ a way to get the type right on the timout variable
    document.addEventListener("keydown", (e: KeyboardEvent) => {
    if (activeLeader) {
    handleLeaderEnd(e);
    return;
    }
    const isSingle = singles.has(e.key);
    if (isSingle) {
    handleSingleKey(e);
    return;
    }
    const isLeader = pairs.has(e.key);
    if (isLeader) handleLeaderStart(e);
    });
    function handleLeaderEnd(e: KeyboardEvent) {
    const callback = pairs.get(activeLeader)?.get(e.key);
    // clean up state
    window.clearTimeout(timeoutId);
    activeLeader = "";
    // handle keybind if it exists
    if (callback) {
    e.preventDefault();
    callback();
    }
    }
    function handleSingleKey(e: KeyboardEvent) {
    const callback = singles.get(e.key);
    if (callback) {
    e.preventDefault();
    callback();
    }
    }
    function handleLeaderStart(e: KeyboardEvent) {
    const leader = pairs.get(e.key);
    if (leader) {
    activeLeader = e.key;
    timeoutId = window.setTimeout(() => {
    // clean up
    activeLeader = "";
    }, LEADER_TIMEOUT);
    }
    }

    Not too terrible. The leader timing out instead of being a permanent toggle complicates things a little, but it’s not too bad.

    We also do need a check for input element interactions, or things could get ugly. It’s just a small piece of code at the top of our keydown listener.

    keybind-manager.ts
    document.addEventListener("keydown", (e: KeyboardEvent): void => {
    const active = document.activeElement;
    if (active instanceof HTMLElement) {
    switch (active.tagName) {
    case "INPUT":
    case "TEXTAREA":
    case "SELECT":
    return;
    }
    if (active.isContentEditable) return;
    }
    // the rest of the handler...
    });

    Usage

    So far so good. Let’s see how a user would use this piece of code.

    keybinds.ts
    import { register } from "./keybind-manager";
    register("gh", () => window.location.assign("/"));
    register("gb", () => window.location.assign("/blog"));
    register("gp", () => window.location.assign("/projects"));
    register("ga", () => window.location.assign("/about"));
    register("gc", () => window.location.assign("/contact"));

    Hey, that’s pretty decent. I wonder if we could get it any cleaner?

    The realisation

    Why does my keybind manager need to know about my handlers?

    Isn’t the keybind manager in charge of registering keybinds and the logic behind figuring out if a registered keybind was triggered? So why are the handlers there?

    Are we really going to split this and implement even more logic? There’s no way there isn’t some built-in to help with this.

    Turns out, there is.

    EventTarget

    How many times have we used this as e.target or similar? Now we get to use it in a much cooler way.

    example.ts
    const target = new EventTarget();
    // instead of calling a callback we do
    target.dispatchEvent(new CustomEvent("keybind", { detail: e.key }));
    // if we expose target or a way to interact with target
    target.addEventListener("keybind", (e: CustomEvent) => {
    if (e.detail === "?") {
    // do something in response to keybind
    }
    });

    Now would you look at that. We get to completely split the handlers away. We don’t need to manage the handlers ourselves and we even get added benefits of letting the user add and remove event listeners as they please and as many as they want.

    Refactoring

    With this in mind, we can turn our entire logic into a single class that holds all the state. The reason for the class is purely stylistic. You can also wrap all the logic in a function that returns the interface.

    keybind-manager.ts
    const VALID_SEQUENCE = /^[\x21-\x7E]{1,2}$/;
    export class KeybindManager {
    private target = new EventTarget();
    private singles = new Set<string>();
    private pairs = new Map<string, Set<string>>();
    private activeLeader = "";
    private timeoutId = window.setTimeout(() => {}, 0);
    constructor() {
    document.addEventListener("keydown", (e: KeyboardEvent) => {
    // code to check document.activeElement
    if (this.activeLeader) {
    this.handleLeaderEnd(e);
    return;
    }
    const isSingle = this.singles.has(e.key);
    if (isSingle) {
    this.handleSingleKey(e);
    return;
    }
    const isLeader = this.pairs.has(e.key);
    if (isLeader) this.handleLeaderStart(e);
    });
    }
    private handleLeaderEnd(e: KeyboardEvent) {
    const isExistingKeybind = !!this.pairs
    .get(this.activeLeader)
    ?.has(e.key);
    this.activeLeader = "";
    window.clearTimeout(this.timeoutId);
    if (isExistingKeybind) {
    e.preventDefault();
    this.target.dispatchEvent(new CustomEvent(leader + e.key));
    return;
    }
    }
    private handleSingleKey(e: KeyboardEvent) {
    e.preventDefault();
    this.target.dispatchEvent(new CustomEvent(e.key));
    }
    private handleLeaderStart(e: KeyboardEvent) {
    this.activeLeader = e.key;
    this.timeoutId = window.setTimeout(() => {
    this.activeLeader = "";
    }, LEADER_TIMEOUT);
    }
    register(sequence: string): void {
    // our registration remains the same
    // just stores less data and in class fields
    // instead of more data and in module variables
    }
    on(event: string, handler: () => void) {
    this.target.addEventListener(event, handler);
    }
    }

    New Usage

    Now our registration and our handlers are decoupled entirely.

    For our purposes, we use one file to register all our keybinds. Though technically they can be scoped to different pages or components, we just don’t have a need for that here.

    keybinds.ts
    import { KeybindManager } from "./keybind-manager";
    export const keybinds = new KeybindManger();
    keybinds.register("gh");
    keybinds.register("gb");
    keybinds.register("gp");
    keybinds.register("ga");
    keybinds.register("gc");

    And now we can import keybinds from this file anywhere in our project and simply attach a listener.

    some-component.ts
    import { keybinds } from "./keybinds";
    keybinds.on("gh", () => window.location.assign("/"));
    keybinds.on("gb", () => window.location.assign("/blog"));
    keybinds.on("gp", () => window.location.assign("/projects"));
    keybinds.on("ga", () => window.location.assign("/about"));
    keybinds.on("gc", () => window.location.assign("/contact"));

    We can have as many listeners as we want, any part of our code that needs to know when a keybind happens can listen for it and run some code in response. A real win for locality of behaviour.

    Bonus: Leader Events, DX, Type safety

    Now some may have noticed that the DX on this implementation could use some work.

    What if we want to react when a leader is active or when it stops being active? Also why don’t we have our own Event type?

    keybind-manager.ts
    type PayloadType = "leader_start" | "leader_end";
    type EventArgs<T extends string> = T extends PayloadType
    ? [key: string]
    : [];
    type AllEvents<T extends string> = T | PayloadType;
    class KeybindEvent<T extends string> extends Event {
    public readonly key: EventArgs<T>[0];
    constructor(event: T, ...args: EventArgs<T>) {
    super(event);
    this.key = args[0];
    }
    }

    Anywhere where you reset this.activeLeader, you’d also want to dispatch a "leader_end". And the moment you assign e.key to this.activeLeader, you’d want to dispatch a "leader_start".

    Leader events carry the leader key, while our regular keybind events don’t carry any data.

    Now, ideally we’d want our listeners to autocomplete from the registration. Is that possible?

    keybind-manager.ts
    export class KeybindManager<TReg extends string = never> {
    constructor() {
    // stays the same
    }
    register<NewSeq extends string>(
    sequence: NewSeq,
    // [!code highlight]
    ): KeybindManager<TReg | NewSeq> {
    // stays the same
    // return the new type containing the registration
    return this; // [!code highlight]
    }
    on<T extends AllEvents<TReg>>(
    event: T,
    handler: KeybindHandler<T>,
    options?: AddEventListenerOptions,
    ) {
    this.target.addEventListener(
    event,
    handler as EventListener,
    options,
    );
    }
    }

    We also added the default event listener options. Why not allow users to send things like abort signals or define listeners as once?

    Usage with improved DX

    Let’s take a look at our slightly modified usage.

    keybinds.ts
    import { KeybindManager } from "./keybind-manager";
    export const keybinds = new KeybindManager()
    .register("gh")
    .register("gb")
    .register("gp")
    .register("ga")
    .register("gc");

    Since chaining returns the class with updated types our exported type should contain all our registrations.

    some-component.ts
    import { keybinds } from "./keybinds";
    /**
    * keybinds: KeybindManager<"gh" | "gb" | "gp" | "ga" | "gc">
    */
    keybinds.on("gh", () => window.location.assign("/"));

    And there we go. When we try to attach a listener, all known event types (registered and the leader events) will show up in the autocomplete list. Even our KeybindEvent will show if key is string or undefined based on which event we’re listening to.

    Closing Words

    If you’re wondering if it can be improved even further. Of course it can. And I have made some changes to the types. But it would be overkill to list every little change.

    I hope you enjoyed this journey as much as I did. It’s always fun to discover the right interface.