Skip to the content

Capsid menu

components/shortcuts/shortcuts.ts

412 lines and 45 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of shortcuts.ts
FunctionLinesComplexityCoveredCRAPMutation
sheetOpener31 to 311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
isMac41 to 432No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
parseStep45 to 587No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:50>50 to 501No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
specsOf60 to 602No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
stepsOf61 to 611No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
isSingleKey65 to 701No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:66>66 to 694No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
matchesStep72 to 8213No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
singleKeysOn84 to 872No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
setSingleKeys91 to 964No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
isTyping101 to 1065No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
modalOpen108 to 1142No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
fire116 to 1213No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onKeyDown123 to 16633No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
start169 to 1743No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
run173 to 1731No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
modifiedShortcutFor180 to 1888No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
stop191 to 1962No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
register199 to 2061No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:202>202 to 2052No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
list208 to 2111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
grouped214 to 2253No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:217>217 to 2171No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
keyCaps229 to 2411No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:231>231 to 24012No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
keysFragment245 to 2582No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:248>248 to 2562No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:250>250 to 2552No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
setSheetOpener264 to 2662No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:265>265 to 2651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
renderSheetList269 to 2974No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
syncSwitch299 to 3064No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
buildSheet310 to 3821No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
wireSheet387 to 4121No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onChange390 to 3922No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onSingle393 to 3931No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onKey395 to 4014No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:406>406 to 4111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
openShortcutSheet415 to 4295No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
enhance433 to 4493No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
onClick441 to 4411No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:443>443 to 4461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:448>448 to 4481No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:448>448 to 4481No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/dialog/dialog.ts, components/shell/shell.ts. Imported by components/command-menu/command-menu.react.tsx, components/command-menu/command-menu.ts, components/moderation-queue/moderation-queue.ts, components/shortcuts/shortcuts.react.tsx.

Source

// The keyboard shortcut registry and the "?" sheet that lists it (the shared dialog,
// components/dialog). One document keydown listener for the whole app. Single-key shortcuts (a letter, "?", a sequence like "g o")
// can be switched off and the choice is remembered (WCAG 2.1.4); shortcuts with Ctrl, Cmd
// or Alt always work. No framework; the React wrapper uses the same registry.
import { readPref, writePref } from "../shell/shell.ts";
import { openDialog, uid, wireDialog } from "../dialog/dialog.ts";

export interface Shortcut {
  // "r", "?", "g o" (a sequence: g, then o within 1200 ms), "Mod+k" (Ctrl, or Cmd on a
  // Mac), "Ctrl+Enter", "Alt+n". An array gives one command several keys.
  key: string | string[];
  label: string;
  // One line under the label in the sheet, saying what the command is for: what a view is
  // for lives here, not in an intro sentence under every heading.
  about?: string;
  group: string;
  run: (e: KeyboardEvent) => void;
  // Runs only while this returns true.
  when?: () => boolean;
}

export const SINGLE_KEYS_PREF = "cap-single-keys";
// The line under the switch, shared by the built sheet and the React one.
export const SINGLE_KEYS_NOTE = "Turn these off if they clash with a screen reader or speech input. It applies at once. Shortcuts with Ctrl or ⌘ keep working.";
export const SEQUENCE_MS = 1200;

const entries: Shortcut[] = [];
let installed = false;
let singleOverride: boolean | null = null;
let pending: { step: string; at: number } | null = null;
let sheetOpener: () => void = () => openShortcutSheet();

interface Combo {
  key: string;
  ctrl: boolean;
  meta: boolean;
  alt: boolean;
  mod: boolean;
}

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

function parseStep(step: string): Combo {
  if (step.length === 1) return { key: step, ctrl: false, meta: false, alt: false, mod: false };
  const parts = step.split("+");
  let key = parts.pop() ?? "";
  if (key === "") key = "+";
  const mods = parts.map((m) => m.toLowerCase());
  return {
    key,
    ctrl: mods.includes("ctrl") || mods.includes("control"),
    meta: mods.includes("meta") || mods.includes("cmd"),
    alt: mods.includes("alt") || mods.includes("option"),
    mod: mods.includes("mod"),
  };
}

const specsOf = (s: Shortcut): string[] => (Array.isArray(s.key) ? s.key : [s.key]);
const stepsOf = (spec: string): string[] => spec.trim().split(/\s+/);

// A single-key shortcut is one a person can press by accident while typing or speaking:
// no Ctrl, Cmd or Alt in any step.
export function isSingleKey(spec: string): boolean {
  return stepsOf(spec).every((step) => {
    const c = parseStep(step);
    return !c.ctrl && !c.meta && !c.alt && !c.mod;
  });
}

function matchesStep(step: string, e: KeyboardEvent): boolean {
  const c = parseStep(step);
  const mac = isMac();
  const ctrl = c.ctrl || (c.mod && !mac);
  const meta = c.meta || (c.mod && mac);
  if (e.ctrlKey !== ctrl || e.metaKey !== meta || e.altKey !== c.alt) return false;
  if (!ctrl && !meta && !c.alt) return e.key === c.key;
  // With a modifier, case does not matter, and Alt's altered characters are read by code.
  if (e.key.toLowerCase() === c.key.toLowerCase()) return true;
  return /^[a-z]$/i.test(c.key) && e.code === `Key${c.key.toUpperCase()}`;
}

export function singleKeysOn(): boolean {
  if (singleOverride !== null) return singleOverride;
  return readPref(SINGLE_KEYS_PREF) !== "off";
}

// Turns single-key shortcuts on or off. Remembered in this browser unless remember is
// false (a specimen, a test).
export function setSingleKeys(on: boolean, opts: { remember?: boolean } = {}): void {
  singleOverride = on;
  if (opts.remember !== false) writePref(SINGLE_KEYS_PREF, on ? "on" : "off");
  pending = null;
  if (typeof document !== "undefined") document.dispatchEvent(new CustomEvent("cap-single-keys", { detail: { on } }));
}

// Keys typed into a field belong to the field. Checkboxes, radios and buttons are not
// typing, so shortcuts still work while one has focus.
const NOT_TYPING = new Set(["checkbox", "radio", "button", "submit", "reset", "range", "color", "file", "image"]);
export function isTyping(target: EventTarget | null): boolean {
  if (!(target instanceof Element)) return false;
  if (target instanceof HTMLInputElement) return !NOT_TYPING.has(target.type);
  if (target.closest("textarea, select")) return true;
  return target instanceof HTMLElement && target.isContentEditable;
}

function modalOpen(): boolean {
  try {
    return !!document.querySelector("dialog:modal");
  } catch {
    return !!document.querySelector("dialog[open]");
  }
}

function fire(s: Shortcut, e: KeyboardEvent): boolean {
  if (s.when && !s.when()) return false;
  e.preventDefault();
  s.run(e);
  return true;
}

function onKeyDown(e: KeyboardEvent): void {
  if (e.defaultPrevented || e.isComposing) return;
  if (e.key === "Shift" || e.key === "Control" || e.key === "Alt" || e.key === "Meta") return;
  // A modal dialog owns the keyboard while it is open.
  if (modalOpen()) {
    pending = null;
    return;
  }
  const singles = singleKeysOn() && !isTyping(e.target);
  const now = Date.now();
  const before = pending;
  pending = null;

  // The second key of a sequence.
  if (before && singles && now - before.at <= SEQUENCE_MS) {
    for (const s of entries) {
      for (const spec of specsOf(s)) {
        const steps = stepsOf(spec);
        if (steps.length === 2 && steps[0] === before.step && matchesStep(steps[1] ?? "", e) && fire(s, e)) return;
      }
    }
  }
  // A one-step shortcut.
  for (const s of entries) {
    for (const spec of specsOf(s)) {
      const steps = stepsOf(spec);
      if (steps.length !== 1) continue;
      if (isSingleKey(spec) && !singles) continue;
      if (matchesStep(spec, e) && fire(s, e)) return;
    }
  }
  // The first key of a sequence: wait for the second.
  if (!singles) return;
  for (const s of entries) {
    for (const spec of specsOf(s)) {
      const steps = stepsOf(spec);
      const first = steps[0] ?? "";
      if (steps.length === 2 && isSingleKey(spec) && matchesStep(first, e)) {
        pending = { step: first, at: now };
        return;
      }
    }
  }
}

// Installs the one listener and the "?" shortcut. Called by register(); safe to call again.
export function start(): void {
  if (installed || typeof document === "undefined") return;
  installed = true;
  document.addEventListener("keydown", onKeyDown);
  entries.unshift({ key: "?", label: "Show keyboard shortcuts", group: "General", run: () => sheetOpener() });
}

// The registered shortcut with Ctrl, Cmd or Alt that this key event matches, if any, and
// whether its `when` allows it now. For a surface open over the page (the sheet) that lets
// such a key through: the Portal's Ctrl K pressed in the help sheet closes the sheet and
// opens the command menu. Keys that match nothing (Ctrl C) are left alone.
export function modifiedShortcutFor(e: KeyboardEvent): Shortcut | undefined {
  for (const s of entries) {
    if (s.when && !s.when()) continue;
    for (const spec of specsOf(s)) {
      if (stepsOf(spec).length === 1 && !isSingleKey(spec) && matchesStep(spec, e)) return s;
    }
  }
  return undefined;
}

// Removes the listener and every shortcut.
export function stop(): void {
  if (typeof document !== "undefined") document.removeEventListener("keydown", onKeyDown);
  installed = false;
  entries.length = 0;
  pending = null;
}

// Adds a shortcut. Returns a function that removes it.
export function register(s: Shortcut): () => void {
  start();
  entries.push(s);
  return () => {
    const i = entries.indexOf(s);
    if (i >= 0) entries.splice(i, 1);
  };
}

export function list(): Shortcut[] {
  start();
  return entries.slice();
}

// The registry by group, in the order each group first appears.
export function grouped(): Array<{ group: string; items: Shortcut[] }> {
  const out: Array<{ group: string; items: Shortcut[] }> = [];
  for (const s of list()) {
    let g = out.find((x) => x.group === s.group);
    if (!g) {
      g = { group: s.group, items: [] };
      out.push(g);
    }
    g.items.push(s);
  }
  return out;
}

// What a key spec looks like on a key cap, one string per step: "Mod+k" is ["Ctrl K"]
// (["⌘ K"] on a Mac), "g o" is ["g", "o"].
export function keyCaps(spec: string): string[] {
  const mac = isMac();
  return stepsOf(spec).map((step) => {
    const c = parseStep(step);
    const names: string[] = [];
    if (c.ctrl || (c.mod && !mac)) names.push("Ctrl");
    if (c.meta || (c.mod && mac)) names.push(mac ? "⌘" : "Meta");
    if (c.alt) names.push(mac ? "⌥" : "Alt");
    const k = c.key.length === 1 && names.length ? c.key.toUpperCase() : c.key;
    names.push(k);
    return names.join(" ");
  });
}

// The key caps of a shortcut as <kbd> elements: "g o" is two caps, several keys are joined
// with "or".
export function keysFragment(key: string | string[]): DocumentFragment {
  const frag = document.createDocumentFragment();
  const specs = Array.isArray(key) ? key : [key];
  specs.forEach((spec, i) => {
    if (i > 0) frag.append(" or ");
    keyCaps(spec).forEach((cap, j) => {
      if (j > 0) frag.append(" ");
      const k = document.createElement("kbd");
      k.textContent = cap;
      frag.append(k);
    });
  });
  return frag;
}

// ---------------------------------------------------------------------------------------
// The sheet.

// Lets an app (the React wrapper) open its own sheet when "?" is pressed.
export function setSheetOpener(fn: (() => void) | null): void {
  sheetOpener = fn ?? (() => openShortcutSheet());
}

// Fills a sheet's list from the registry: one group heading and one description list each.
export function renderSheetList(container: HTMLElement): void {
  container.replaceChildren();
  for (const g of grouped()) {
    const wrap = document.createElement("div");
    wrap.className = "cap-keys-group";
    const h = document.createElement("h3");
    h.className = "cap-keys-group-title";
    h.textContent = g.group;
    const dl = document.createElement("dl");
    dl.className = "cap-keys-list";
    for (const s of g.items) {
      const row = document.createElement("div");
      const dt = document.createElement("dt");
      dt.append(keysFragment(s.key));
      const dd = document.createElement("dd");
      dd.textContent = s.label;
      if (s.about) {
        const about = document.createElement("span");
        about.className = "cap-keys-about";
        about.textContent = s.about;
        dd.append(about);
      }
      row.append(dt, dd);
      dl.append(row);
    }
    wrap.append(h, dl);
    container.append(wrap);
  }
}

function syncSwitch(sheet: HTMLElement): void {
  const box = sheet.querySelector<HTMLInputElement>("[data-cap-part='single-keys']");
  if (!box) return;
  const on = singleKeysOn();
  box.checked = on;
  const word = box.closest(".cap-switch")?.querySelector(".cap-switch-state");
  if (word) word.textContent = on ? "On" : "Off";
}

// Builds the sheet's markup, exactly the contract on the doc page: the shared dialog with a
// header, the list as its body, the switch in its footer, and the corner Close button.
export function buildSheet(): HTMLDialogElement {
  const id = uid("cap-keys");
  const d = document.createElement("dialog");
  d.className = "cap-dialog cap-keys";
  d.dataset.cap = "shortcuts";
  d.dataset.placement = "center";
  d.dataset.size = "md";
  d.setAttribute("aria-labelledby", `${id}-title`);

  const head = document.createElement("div");
  head.className = "cap-dialog-header";
  head.dataset.divider = "";
  const h = document.createElement("h2");
  h.className = "cap-dialog-title";
  h.id = `${id}-title`;
  h.textContent = "Keyboard shortcuts";
  head.append(h);

  const body = document.createElement("div");
  body.className = "cap-dialog-body cap-keys-body";
  body.dataset.capPart = "keys-list";

  const footer = document.createElement("div");
  footer.className = "cap-dialog-footer";
  footer.dataset.align = "start";
  const foot = document.createElement("div");
  foot.className = "cap-keys-foot";
  const label = document.createElement("label");
  label.className = "cap-switch";
  const box = document.createElement("input");
  box.type = "checkbox";
  box.setAttribute("role", "switch");
  box.dataset.capPart = "single-keys";
  box.setAttribute("aria-describedby", `${id}-note`);
  const name = document.createElement("span");
  name.textContent = "Single-key shortcuts";
  const state = document.createElement("span");
  state.className = "cap-switch-state";
  state.setAttribute("aria-hidden", "true");
  label.append(box, name, state);
  const note = document.createElement("p");
  note.className = "cap-keys-note";
  note.id = `${id}-note`;
  note.textContent = SINGLE_KEYS_NOTE;
  foot.append(label, note);
  footer.append(foot);

  const close = document.createElement("button");
  close.type = "button";
  close.className = "cap-btn cap-dialog-close";
  close.dataset.variant = "quiet";
  close.dataset.iconOnly = "";
  close.dataset.capPart = "close";
  close.setAttribute("aria-label", "Close");
  const ns = "http://www.w3.org/2000/svg";
  const svg = document.createElementNS(ns, "svg");
  svg.setAttribute("viewBox", "0 0 16 16");
  svg.setAttribute("width", "16");
  svg.setAttribute("height", "16");
  svg.setAttribute("aria-hidden", "true");
  svg.setAttribute("focusable", "false");
  const path = document.createElementNS(ns, "path");
  path.setAttribute("d", "M4 4l8 8M12 4l-8 8");
  path.setAttribute("fill", "none");
  path.setAttribute("stroke", "currentColor");
  path.setAttribute("stroke-width", "1.5");
  path.setAttribute("stroke-linecap", "round");
  svg.append(path);
  close.append(svg);

  d.append(head, body, footer, close);
  return d;
}

// Wires one sheet: the shared dialog's rules (Close, the backdrop, Esc, focus returned on
// close), its list, the switch, and the Ctrl or Cmd shortcuts that still work over it. Returns
// a function that unwires it.
export function wireSheet(sheet: HTMLDialogElement): () => void {
  const unwireBase = wireDialog(sheet);
  const box = sheet.querySelector<HTMLInputElement>("[data-cap-part='single-keys']");
  const onChange = () => {
    if (box) setSingleKeys(box.checked);
  };
  const onSingle = () => syncSwitch(sheet);
  // A shortcut with Ctrl or Cmd still works over the sheet: the sheet closes, then it runs.
  const onKey = (e: KeyboardEvent) => {
    if (e.defaultPrevented || e.isComposing) return;
    const hit = modifiedShortcutFor(e);
    if (!hit) return;
    sheet.close();
    fire(hit, e);
  };
  box?.addEventListener("change", onChange);
  document.addEventListener("cap-single-keys", onSingle);
  sheet.addEventListener("keydown", onKey);
  syncSwitch(sheet);
  return () => {
    box?.removeEventListener("change", onChange);
    document.removeEventListener("cap-single-keys", onSingle);
    sheet.removeEventListener("keydown", onKey);
    unwireBase();
  };
}

// Opens the sheet: the one in the page's markup, or one built on first use.
export function openShortcutSheet(opener: Element | null = document.activeElement): HTMLDialogElement {
  let sheet = document.querySelector<HTMLDialogElement>("dialog.cap-keys[data-cap='shortcuts']");
  if (!sheet) {
    sheet = buildSheet();
    document.body.append(sheet);
  }
  if (!sheet.dataset.capReady) wireSheet(sheet);
  const list = sheet.querySelector<HTMLElement>("[data-cap-part='keys-list']");
  if (list) renderSheetList(list);
  syncSwitch(sheet);
  // Focus starts on Close, so Esc and Enter are one key from leaving and nothing is changed by
  // arriving.
  if (!sheet.open) openDialog(sheet, opener, { focus: sheet.querySelector<HTMLElement>("[data-cap-part='close']") });
  return sheet;
}

// Wires a sheet written in markup and every [data-cap-keys-open] button under root, and
// starts the registry. Returns a function that detaches them.
export function enhance(root: ParentNode = document): () => void {
  start();
  const undo: Array<() => void> = [];
  for (const sheet of root.querySelectorAll<HTMLDialogElement>("dialog.cap-keys[data-cap='shortcuts']:not([data-cap-ready])")) {
    undo.push(wireSheet(sheet));
  }
  for (const btn of root.querySelectorAll<HTMLElement>("[data-cap-keys-open]:not([data-cap-ready])")) {
    btn.dataset.capReady = "";
    const onClick = () => openShortcutSheet(btn);
    btn.addEventListener("click", onClick);
    undo.push(() => {
      btn.removeEventListener("click", onClick);
      delete btn.dataset.capReady;
    });
  }
  return () => undo.forEach((f) => f());
}