[]previous and next page

Keyboard shortcuts

Register global keyboard shortcuts with a hook that owns one document listener, matches modifiers exactly and skips text fields.

For agentsUpdated

This recipe builds useShortcuts, a hook that installs a single keydown listener on the document for as long as its component lives, and a registry that components can add shortcuts to and remove them from while the app runs. It demonstrates onMount and onCleanup for a global listener, a signal as the registry, and an effect that registers a shortcut only while a condition holds.

The hook#

A combo is a string: modifiers, then a key, joined by +. mod means Ctrl or Meta, so one combo covers Windows, Linux and macOS. Modifiers must match exactly: mod+k does not fire for Ctrl+Shift+K, and k does not fire for Ctrl+K. A character that needs Shift on the keyboard is written with it (shift+?), because event.key is "?" and shiftKey is true at the same time. add returns the function that removes the shortcut.

src/shortcuts.tsts
import { onCleanup, onMount, useSignal } from "@shadow-js/core";

export interface Shortcut {
  readonly combo: string;
  readonly description: string;
  readonly run: (event: KeyboardEvent) => void;
}

export interface Shortcuts {
  readonly add: (shortcut: Shortcut) => () => void;
  readonly list: () => readonly Shortcut[];
}

interface Parsed {
  readonly key: string;
  readonly mod: boolean;
  readonly shift: boolean;
  readonly alt: boolean;
}

interface Entry {
  readonly shortcut: Shortcut;
  readonly parsed: Parsed;
}

function parse(combo: string): Parsed {
  const parts = combo.toLowerCase().split("+");
  const key = parts.pop() ?? "";
  return {
    key,
    mod: parts.includes("mod"),
    shift: parts.includes("shift"),
    alt: parts.includes("alt"),
  };
}

function matches(parsed: Parsed, event: KeyboardEvent): boolean {
  return (
    event.key.toLowerCase() === parsed.key &&
    (event.ctrlKey || event.metaKey) === parsed.mod &&
    event.shiftKey === parsed.shift &&
    event.altKey === parsed.alt
  );
}

const EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable="false"])';

function isEditable(target: EventTarget | null): boolean {
  return target instanceof Element && target.closest(EDITABLE) !== null;
}

export function useShortcuts(): Shortcuts {
  // useSignal: the update builds a new array from the previous one, which a store draft does not allow.
  const [entries, setEntries] = useSignal<readonly Entry[]>([]);

  const add = (shortcut: Shortcut): (() => void) => {
    const entry: Entry = { shortcut, parsed: parse(shortcut.combo) };
    setEntries((current) => [...current, entry]);
    return () => setEntries((current) => current.filter((item) => item !== entry));
  };

  const onKeyDown = (event: KeyboardEvent): void => {
    if (event.defaultPrevented || isEditable(event.target)) return;
    const hit = entries().find((entry) => matches(entry.parsed, event));
    if (hit === undefined) return;
    event.preventDefault();
    hit.shortcut.run(event);
  };

  onMount(() => {
    document.addEventListener("keydown", onKeyDown);
  });
  onCleanup(() => {
    document.removeEventListener("keydown", onKeyDown);
  });

  return { add, list: () => entries().map((entry) => entry.shortcut) };
}

Using it#

ShortcutDemo registers two shortcuts for its whole life and a third, j, only while the checkbox is checked. The effect adds the shortcut and hands the remover to onCleanup, so unchecking the box (which re-runs the effect) or unmounting the component removes it. The help list reads shortcuts.list() and follows the registry.

src/shortcut-demo.tsxtsx
import { onCleanup, useEffect, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";
import { useShortcuts } from "./shortcuts";

export function ShortcutDemo() {
  const shortcuts = useShortcuts();
  const [count, setCount] = useStore(0);
  const [last, setLast] = useStore("");
  const [counterOn, setCounterOn] = useStore(false);

  shortcuts.add({ combo: "mod+k", description: "Open search", run: () => setLast("search") });
  shortcuts.add({ combo: "shift+?", description: "Show help", run: () => setLast("help") });

  useEffect(() => {
    if (!counterOn()) return;
    const remove = shortcuts.add({
      combo: "j",
      description: "Add one",
      run: () => setCount((n) => n + 1),
    });
    onCleanup(remove);
  });

  return (
    <div>
      <label>
        Enable the j shortcut
        <input
          type="checkbox"
          checked={counterOn()}
          onChange={(event) => setCounterOn(event.currentTarget.checked)}
        />
      </label>
      <input type="text" aria-label="Notes" />
      <p>Count: {count()}</p>
      <p role="status">{last()}</p>
      <ul aria-label="Shortcuts">
        <For each={shortcuts.list()}>
          {(shortcut) => (
            <li>
              {shortcut.combo}: {shortcut.description}
            </li>
          )}
        </For>
      </ul>
    </div>
  );
}

Decisions#

One listener, many shortcuts. Each shortcut does not add its own keydown listener. The hook adds one in onMount (it never runs on the server, and document exists) and removes it in onCleanup. The registry is a signal holding an array, so adding and removing shortcuts re-renders only what reads list(), and the listener reads the current array at the moment a key is pressed.

Text fields are skipped. Typing j into the notes field must not run the shortcut. The check uses target.closest(...) with input, textarea, select and contenteditable, so it also covers an element inside a rich-text region. The cost: a shortcut such as mod+k is also ignored while a field has focus. If a shortcut must work inside fields, change isEditable to let combos that use mod through.

First match wins, and the browser default is canceled. preventDefault() runs before the handler, so mod+k does not also focus the browser's search box. Events that another handler already canceled (event.defaultPrevented) are ignored, which lets a dialog take Escape before a global shortcut sees it.

Cleanup in an effect. onCleanup(remove) inside useEffect runs before the effect re-runs and when the component is disposed. That makes registration conditional on a signal without a manual unsubscribe.

Related: onMount, onCleanup, useEffect, useStore, For, fireEvent, userEvent.