One entry per command — stable id, plain-language label, key chord, run() — replacing the ad-hoc keydown listeners in main.ts, catalog.ts, debug.ts and attach.ts and the parallel SHORTCUTS constant in help.ts. The `?` overlay now renders the registry, so a binding and its documentation cannot disagree. Keys are canonicalised the way the TUI's `InputKey::canonical` spells them, and chords resolve like its `ChordState`: hold while the sequence is a strict prefix, otherwise dispatch. That gives the web a leader, `ctrl-a` by default like `[tui].leader`, held as a partial chord and `preventDefault`ed so it stops being select-all — the escape hatch in docs/COMMAND-BAR.md needs somewhere to land (#149). The leader stays out of real form fields, where select-all is still what the operator wants. Same keys, same effects: plain single-key bindings remain deaf inside form fields and the terminal's hidden textarea, chorded ones do not. Refs #148 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
4.1 KiB
TypeScript
135 lines
4.1 KiB
TypeScript
/** Keyboard-shortcut overlay (`?`), the web counterpart of the TUI's help
|
|
* overlay. It renders the command registry (`commands.ts`) — the same list
|
|
* that dispatches the keys — so a binding can never be documented as one
|
|
* thing and do another. */
|
|
|
|
import {
|
|
CommandSet,
|
|
defineCommands,
|
|
keyCaps,
|
|
publishedCommands,
|
|
type Command,
|
|
} from "./commands.js";
|
|
|
|
let open: { el: HTMLElement; dispose: () => void } | null = null;
|
|
|
|
export function isHelpOpen(): boolean {
|
|
return open !== null;
|
|
}
|
|
|
|
export function closeHelp(): void {
|
|
open?.dispose();
|
|
}
|
|
|
|
export function toggleHelp(): void {
|
|
if (open) closeHelp();
|
|
else openHelp();
|
|
}
|
|
|
|
/** Mount the overlay. Opening while already open is a no-op re-render, so a
|
|
* stray second call can never stack two dialogs. */
|
|
export function openHelp(): HTMLElement {
|
|
closeHelp();
|
|
|
|
const el = document.createElement("div");
|
|
el.className = "help-overlay";
|
|
el.setAttribute("role", "dialog");
|
|
el.setAttribute("aria-modal", "true");
|
|
el.setAttribute("aria-label", "Keyboard shortcuts");
|
|
|
|
const panel = document.createElement("div");
|
|
panel.className = "help-panel";
|
|
|
|
const head = document.createElement("header");
|
|
head.className = "help-head";
|
|
const title = document.createElement("h2");
|
|
title.className = "help-title";
|
|
title.textContent = "Keyboard shortcuts";
|
|
const close = document.createElement("button");
|
|
close.type = "button";
|
|
close.className = "help-close";
|
|
close.setAttribute("aria-label", "Close");
|
|
close.textContent = "✕";
|
|
close.addEventListener("click", closeHelp);
|
|
head.append(title, close);
|
|
panel.appendChild(head);
|
|
|
|
for (const group of groupedCommands()) {
|
|
const section = document.createElement("section");
|
|
section.className = "help-group";
|
|
const heading = document.createElement("h3");
|
|
heading.className = "help-group-title";
|
|
heading.textContent = group.title;
|
|
const list = document.createElement("dl");
|
|
list.className = "help-list";
|
|
for (const cmd of group.items) {
|
|
const row = document.createElement("div");
|
|
row.className = "help-row";
|
|
row.dataset.commandId = cmd.id;
|
|
const keys = document.createElement("dt");
|
|
keys.className = "help-keys";
|
|
for (const key of keyCaps(cmd.keys[0])) {
|
|
const cap = document.createElement("kbd");
|
|
cap.textContent = key;
|
|
keys.appendChild(cap);
|
|
}
|
|
const desc = document.createElement("dd");
|
|
desc.className = "help-desc";
|
|
desc.textContent = cmd.label;
|
|
row.append(keys, desc);
|
|
list.appendChild(row);
|
|
}
|
|
section.append(heading, list);
|
|
panel.appendChild(section);
|
|
}
|
|
|
|
// A press on the scrim closes; one inside the panel is left alone.
|
|
el.addEventListener("click", (ev) => {
|
|
if (!panel.contains(ev.target as Node)) closeHelp();
|
|
});
|
|
el.appendChild(panel);
|
|
document.body.appendChild(el);
|
|
|
|
const dispose = () => {
|
|
if (open?.el !== el) return;
|
|
open = null;
|
|
el.remove();
|
|
};
|
|
open = { el, dispose };
|
|
close.focus();
|
|
return el;
|
|
}
|
|
|
|
/** The registry's commands, in registry order, bucketed into the help
|
|
* overlay's sections. Groups appear in the order their first command does. */
|
|
export function groupedCommands(): { title: string; items: Command[] }[] {
|
|
const groups: { title: string; items: Command[] }[] = [];
|
|
for (const cmd of publishedCommands()) {
|
|
let group = groups.find((g) => g.title === cmd.group);
|
|
if (!group) {
|
|
group = { title: cmd.group, items: [] };
|
|
groups.push(group);
|
|
}
|
|
group.items.push(cmd);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
// The overlay's own two commands. `help.close` is `overlay`-scoped: it is only
|
|
// fed keys while the dialog is up.
|
|
const helpKeys = new CommandSet(defineCommands({ "help.toggle": () => toggleHelp() }));
|
|
const overlayKeys = new CommandSet(defineCommands({ "help.close": () => closeHelp() }));
|
|
|
|
let installed = false;
|
|
|
|
/** Global keybind: `?` toggles the overlay, Escape closes it. Idempotent, so a
|
|
* second call during a hot reload doesn't double-toggle. */
|
|
export function installHelpKeybind(): void {
|
|
if (installed) return;
|
|
installed = true;
|
|
window.addEventListener("keydown", (e) => {
|
|
if (open && overlayKeys.handle(e)) return;
|
|
helpKeys.handle(e);
|
|
});
|
|
}
|