HextorUI
ui

Keyboard shortcuts

A shortcut registry with scoping and a searchable `?` cheat sheet generated from what is actually registered.

npx shadcn@latest add @hextor/keyboard-shortcuts

Source

"use client";

import * as React from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";

/**
 * The keyboard-shortcut layer: a registry components can plug into instead
 * of every one of them wiring its own `document.addEventListener("keydown")`
 * and re-solving the same three problems badly — not firing while someone is
 * typing, not firing twice for one keypress, and not firing a background
 * shortcut while a modal with its own binding for the same key is open.
 *
 * MVP scope, stated up front: a shortcut is one chord (optional modifiers +
 * one key), evaluated on `keydown`. There is no chorded-sequence support
 * (Gmail's "g" then "i") — that needs its own small state machine for the
 * inter-key timeout, and it did not earn its place in a first pass where
 * every one of the six primitives needed building.
 */

export interface ShortcutDefinition {
  id: string;
  /**
   * Normalized combo string: modifiers (any of `mod`, `shift`, `alt`, in
   * that order) joined with `+`, then the key. `mod` is Cmd on macOS/iOS and
   * Ctrl everywhere else — the one piece of platform branching every app
   * with a command palette needs, so it lives here instead of six times.
   * Examples: `"mod+k"`, `"shift+?"`, `"g"`.
   */
  combo: string;
  /** What it does, in words a person searching the `?` overlay would type. */
  description: string;
  /** Groups the overlay's listing. Free text — callers own the taxonomy. */
  group: string;
  /** Registered and live, but left out of the `?` overlay's listing. */
  hidden?: boolean;
}

interface RegisteredShortcut extends ShortcutDefinition {
  scope: string;
  handler: (event: KeyboardEvent) => void;
}

interface ShortcutContextValue {
  registerShortcut: (shortcut: RegisteredShortcut) => () => void;
  pushScope: (scope: string) => () => void;
  shortcuts: ShortcutDefinition[];
}

const ShortcutContext = createContext<ShortcutContextValue | null>(null);

function isMac(): boolean {
  if (typeof navigator === "undefined") return false;
  return /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
}

function isTextInput(el: Element | null): boolean {
  if (!el) return false;
  const tag = el.tagName;
  if (tag === "INPUT" || tag === "TEXTAREA") return true;
  return (el as HTMLElement).isContentEditable === true;
}

const MODIFIER_KEYS = new Set(["control", "meta", "shift", "alt"]);

function comboFromEvent(event: KeyboardEvent): string {
  const parts: string[] = [];
  const mod = isMac() ? event.metaKey : event.ctrlKey;
  if (mod) parts.push("mod");
  if (event.shiftKey) parts.push("shift");
  if (event.altKey) parts.push("alt");
  const key = event.key === " " ? "space" : event.key.toLowerCase();
  if (!MODIFIER_KEYS.has(key)) parts.push(key);
  return parts.join("+");
}

const DISPLAY_KEY: Record<string, string> = {
  mod: "⌘",
  shift: "⇧",
  alt: "⌥",
  arrowup: "↑",
  arrowdown: "↓",
  arrowleft: "←",
  arrowright: "→",
  space: "Space",
  escape: "Esc",
  enter: "↵",
  backspace: "⌫",
};

/** Splits a combo string into the parts a `<kbd>` row should render, Mac-aware. */
export function formatShortcut(combo: string): string[] {
  const mac = isMac();
  return combo.split("+").map((part) => {
    if (part === "mod") return mac ? "⌘" : "Ctrl";
    if (part === "alt" && !mac) return "Alt";
    return DISPLAY_KEY[part] ?? (part.length === 1 ? part.toUpperCase() : part);
  });
}

export function ShortcutProvider({ children }: { children: React.ReactNode }) {
  // The registry lives in real state, not a ref, because `shortcuts` below
  // is read during render (the `?` overlay renders straight off it) — a
  // ref mutated outside render and read inside it is exactly the tear a
  // ref exists to avoid. `registryRef` is a same-tick mirror of that state,
  // kept only so the keydown handler — which runs in an event callback, a
  // context refs are meant for — always dispatches off the latest map
  // without re-subscribing `addEventListener` on every register/unregister.
  const [registry, setRegistry] = useState<Map<string, RegisteredShortcut>>(() => new Map());
  const registryRef = useRef(registry);
  useEffect(() => {
    registryRef.current = registry;
  }, [registry]);

  // The scope stack never renders anything itself — it's read only inside
  // the keydown handler and written only from other components' effects
  // (`useShortcutScope`) — so a plain ref is the right tool here with no
  // state mirror needed.
  const scopeStackRef = useRef<string[]>(["global"]);

  const registerShortcut = useCallback((shortcut: RegisteredShortcut) => {
    setRegistry((prev) => {
      const next = new Map(prev);
      next.set(shortcut.id, shortcut);
      return next;
    });
    return () => {
      setRegistry((prev) => {
        if (!prev.has(shortcut.id)) return prev;
        const next = new Map(prev);
        next.delete(shortcut.id);
        return next;
      });
    };
  }, []);

  const pushScope = useCallback((scope: string) => {
    scopeStackRef.current = [...scopeStackRef.current, scope];
    return () => {
      const idx = scopeStackRef.current.lastIndexOf(scope);
      if (idx === -1) return;
      scopeStackRef.current = [
        ...scopeStackRef.current.slice(0, idx),
        ...scopeStackRef.current.slice(idx + 1),
      ];
    };
  }, []);

  useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      // The hard rule from the brief: a shortcut never fires out from under
      // someone who is typing. No opt-out — a registry item that could
      // silently re-enable this per shortcut is one bad call away from
      // hijacking every "s" keystroke in a text field somewhere.
      if (isTextInput(document.activeElement)) return;

      const combo = comboFromEvent(event);
      const activeScope = scopeStackRef.current[scopeStackRef.current.length - 1] ?? "global";

      for (const shortcut of registryRef.current.values()) {
        if (shortcut.combo !== combo) continue;
        // A shortcut only fires in the scope that is currently on top of the
        // stack. Opening a modal that pushes its own scope shadows every
        // `global`-scoped shortcut underneath it for exactly as long as the
        // modal is open — that's the "does not fire the global one" rule.
        if (shortcut.scope !== activeScope) continue;
        event.preventDefault();
        shortcut.handler(event);
        return;
      }
    }
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  const shortcuts = useMemo(
    () =>
      Array.from(registry.values()).map(
        ({ id, combo, description, group, hidden }): ShortcutDefinition => ({
          id,
          combo,
          description,
          group,
          hidden,
        }),
      ),
    [registry],
  );

  const value = useMemo<ShortcutContextValue>(
    () => ({ registerShortcut, pushScope, shortcuts }),
    [registerShortcut, pushScope, shortcuts],
  );

  return <ShortcutContext.Provider value={value}>{children}</ShortcutContext.Provider>;
}

function useShortcutContext(hookName: string): ShortcutContextValue {
  const ctx = useContext(ShortcutContext);
  if (!ctx) throw new Error(`${hookName} must be used within a <ShortcutProvider>.`);
  return ctx;
}

/**
 * Registers a shortcut for as long as the calling component is mounted (and
 * `enabled` is not `false`). `handler` may change every render without
 * re-registering — it is read through a ref, so redefining an inline
 * arrow function on every render (the normal case) never thrashes the
 * registry or drops a keystroke between two re-renders.
 */
export function useRegisterShortcut(
  definition: ShortcutDefinition,
  handler: (event: KeyboardEvent) => void,
  options?: { scope?: string; enabled?: boolean },
): void {
  const ctx = useShortcutContext("useRegisterShortcut");
  const handlerRef = useRef(handler);
  useEffect(() => {
    handlerRef.current = handler;
  });
  const scope = options?.scope ?? "global";
  const enabled = options?.enabled ?? true;

  useEffect(() => {
    if (!enabled) return;
    return ctx.registerShortcut({
      ...definition,
      scope,
      handler: (event) => handlerRef.current(event),
    });
    // `definition` is taken apart into primitives so a caller passing a
    // fresh object literal every render (the normal case) doesn't
    // re-register on every keystroke elsewhere in the app.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ctx, definition.id, definition.combo, definition.description, definition.group, definition.hidden, scope, enabled]);
}

/**
 * Pushes `scope` onto the active-scope stack while `active` is true — the
 * half of the scoping story a modal owns. Call it from the peek panel, the
 * command palette, any dialog that has its own shortcuts and should shadow
 * the app's global ones while it's open.
 */
export function useShortcutScope(scope: string, active = true): void {
  const ctx = useShortcutContext("useShortcutScope");
  useEffect(() => {
    if (!active) return;
    return ctx.pushScope(scope);
  }, [ctx, scope, active]);
}

/** The live shortcut registry, for a `?` overlay (or any other listing) to render. */
export function useShortcuts(): ShortcutDefinition[] {
  return useShortcutContext("useShortcuts").shortcuts;
}

Docs

The overlay lists the live registry, not a hand-kept document. A cheat sheet maintained separately from the bindings is wrong within a month.

Two things are enforced rather than offered: nothing fires while a text field has focus, and a scoped shortcut inside an open modal wins over the global one binding the same key. Both are the failures that make people stop using shortcuts at all.

Dependencies

lucide-reactdialoginput