feat(keys): j/k walk the feed, s finds the search box, g jumps to a module

A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the
only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass.
First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c.

- `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k`
  step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter
  clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is
  never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next
  item takes it, so the player's focus restore lands back on the card. The first `j` takes the first
  card you can see, not the top of the list.
- `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in);
  `VirtualFeed` turns it on, so the feed, the library and the channel page all have it.
- Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the
  page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open,
  enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the
  Hungarian layout.
- `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box
  and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends
  its first Escape on its panel).
- Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences
  the page keys.
- Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/
  link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the
  video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome).
- Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable
  module; opened over a player or dialog it leaves out the page-level keys and the context-menu key,
  which nothing can reach there (the latter pre-existing since E10).
- CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` +
  `data-nav-open`, not a private arrow-key handler).
This commit is contained in:
2026-09-13 04:16:20 +02:00
parent 78a9324381
commit 8fb276aa90
25 changed files with 1400 additions and 49 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ function Host({
onState: (s: string) => void;
onHome?: () => void;
}) {
useGlobalKeymap(enabled, onHome);
useGlobalKeymap(enabled, (page) => page === "home" && onHome(), ["home"]);
const help = useKeyboardHelpOpen();
const palette = usePaletteOpen();
useEffect(() => {
+166
View File
@@ -0,0 +1,166 @@
// @vitest-environment jsdom
//
// Two-step sequences (`g h`) and the page-level quiet rule, in their own file: the pending first step
// is module state, and a test that left one pending would turn the NEXT test's first key into its
// second step. Every test here starts a fake clock far past the previous test's, so nothing carries.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SHORTCUTS, dispatch, formatChord, helpLines, type ShortcutId } from "./keymap";
import { pushLayer, removeLayer } from "./layerStack";
let clock = Date.UTC(2030, 0, 1);
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
clock += 3_600_000;
vi.setSystemTime(clock);
});
afterEach(() => vi.useRealTimers());
function press(init: KeyboardEventInit, on?: HTMLElement): KeyboardEvent {
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
(on ?? document.body).dispatchEvent(e);
return e;
}
/** Every page row's handler, recording which one ran. */
function pageHandlers(calls: string[]) {
return Object.fromEntries(
SHORTCUTS.filter((s) => s.scope === "page").map((s) => [s.id, () => void calls.push(s.id)]),
) as { readonly [K in ShortcutId<"page">]: () => void };
}
const listHandlers = (calls: string[]) =>
({
next: () => calls.push("next"),
prev: () => calls.push("prev"),
left: () => {},
right: () => {},
up: () => {},
down: () => {},
open: () => {},
}) as const;
describe("two-step sequences", () => {
it("runs the row once both steps arrive, and not on the first alone", () => {
const calls: string[] = [];
expect(dispatch(press({ key: "g" }), "page", pageHandlers(calls))).toBe(true);
expect(calls).toEqual([]);
expect(dispatch(press({ key: "f" }), "page", pageHandlers(calls))).toBe(true);
expect(calls).toEqual(["goFeed"]);
});
// Every scope has its own window listener, so the `h` completing `g h` reaches the global one too.
it("keeps the completing key from every other scope, whichever listener runs first", () => {
const calls: string[] = [];
const global = {
help: () => calls.push("help"),
palette: () => {},
home: () => {},
};
// another scope BEFORE the owner: the pending step belongs to `page`, so it stands aside
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
const h = press({ key: "h" });
expect(dispatch(h, "global", global)).toBe(false);
expect(dispatch(h, "page", pageHandlers(calls))).toBe(true);
expect(calls).toEqual(["goHome"]);
// another scope AFTER the owner: the owner stamped the event
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
const j = press({ key: "j" }); // breaks the sequence: no `g j`
expect(dispatch(j, "page", pageHandlers(calls))).toBe(false);
expect(dispatch(j, "list", listHandlers(calls))).toBe(false);
expect(calls).toEqual(["goHome"]);
});
it("gives up after the window, so a late key is an ordinary key again", () => {
const calls: string[] = [];
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
vi.setSystemTime(clock + 1_600);
expect(dispatch(press({ key: "j" }), "list", listHandlers(calls))).toBe(true);
expect(dispatch(press({ key: "f" }), "page", pageHandlers(calls))).toBe(false);
expect(calls).toEqual(["next"]);
});
it("is not broken by reaching for a modifier between the steps", () => {
const calls: string[] = [];
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls));
dispatch(press({ key: "d" }), "page", pageHandlers(calls));
expect(calls).toEqual(["goDownloads"]);
});
it("does not start or finish inside a text field", () => {
const calls: string[] = [];
const input = document.createElement("input");
document.body.append(input);
expect(dispatch(press({ key: "g" }, input), "page", pageHandlers(calls))).toBe(false);
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
expect(dispatch(press({ key: "f" }, input), "page", pageHandlers(calls))).toBe(false);
expect(calls).toEqual([]);
input.remove();
});
});
describe("page-level scopes under a layer", () => {
it("are quiet while a keyboard-owning layer is open", () => {
const calls: string[] = [];
const id = pushLayer();
expect(dispatch(press({ key: "j" }), "list", listHandlers(calls))).toBe(false);
expect(dispatch(press({ key: "s" }), "search", { focus: () => calls.push("s") })).toBe(false);
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
dispatch(press({ key: "f" }), "page", pageHandlers(calls));
removeLayer(id);
expect(calls).toEqual([]);
});
// The layer's OWN keys must keep working — that is the player the page keys are standing aside for.
it("leave the layer's own scope alone", () => {
const calls: string[] = [];
const id = pushLayer();
expect(
dispatch(press({ key: "k" }), "plexPlayer", { playPause: () => calls.push("pp") } as never),
).toBe(true);
removeLayer(id);
expect(calls).toEqual(["pp"]);
});
it("keep working under a passive layer (an open tooltip)", () => {
const calls: string[] = [];
const id = pushLayer({ passive: true });
expect(dispatch(press({ key: "j" }), "list", listHandlers(calls))).toBe(true);
removeLayer(id);
expect(calls).toEqual(["next"]);
});
});
describe("the page-level rows", () => {
// Owner rule D1, enforced rather than commented: on the Hungarian layout the owner types on, a
// chord with a modifier (or `/`, `?`, which ARE Shift chords there) is not a quick key.
it("need no modifier and no Shift-only character", () => {
for (const s of SHORTCUTS) {
if (!["page", "search", "list"].includes(s.scope)) continue;
for (const chord of s.keys) {
for (const step of chord.split(" ")) {
expect(step.includes("+"), `${s.scope}:${s.id} ${chord}`).toBe(false);
expect(/^[a-z0-9,.-]$|^(Enter|Escape|Arrow(Up|Down|Left|Right))$/.test(step), chord).toBe(
true,
);
}
}
}
});
it("render as the keys to press, step by step", () => {
expect(formatChord("g h")).toBe("G H");
expect(formatChord("g ,")).toBe("G ,");
});
// `g h` shares Home's line with Shift+W; leaving out an unreachable chord must not take that with it.
it("drop an unreachable module's chord but keep the line it shares", () => {
const lines = helpLines(["page", "global"], ["home", "feed"]);
expect(lines.find((l) => l.labelKey === "keys.home")?.chords).toEqual(["g h", "Shift+w"]);
expect(lines.some((l) => l.labelKey === "keys.goFeed")).toBe(true);
expect(lines.some((l) => l.labelKey === "keys.goPlex")).toBe(false);
expect(
helpLines(["page", "global"], []).find((l) => l.labelKey === "keys.home")?.chords,
).toEqual(["Shift+w"]);
});
});
+239 -25
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, type RefObject } from "react";
import { anyBlockingLayer } from "./layerStack";
import { createStore } from "./store";
import type { Page } from "./urlState";
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
// Before E10 S3 six surfaces each owned a private `keydown` listener with its own key literals and
@@ -18,8 +20,26 @@ import { createStore } from "./store";
// one place, and the sheet renders this table rather than a hand-copied array beside it.
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
* is mounted. The help sheet shows `global` plus the topmost registered scope. */
export type KeyScope = "global" | "undo" | "contextMenu" | "ytPlayer" | "plexPlayer" | "plexBrowse";
* is mounted. The help sheet shows `global` plus the registered scopes.
*
* `page`, `search` and `list` are the PAGE-LEVEL scopes (E11 S4): they act on the page underneath
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
* run with the player's own layer up. */
export type KeyScope =
| "global"
| "page"
| "search"
| "list"
| "undo"
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>(["page", "search", "list"]);
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
@@ -42,11 +62,19 @@ export interface Shortcut {
* and a player whose Escape died inside its own subtitle-offset input is a trap. Every other
* shortcut stays out of the way of typing. */
readonly allowInInput?: boolean;
/** The module a `g` chord jumps to. The sheet lists the row only while that module is available
* to this account, and the handler refuses it otherwise — a gated module has no page to land on. */
readonly page?: Page;
}
// Chord syntax: `Mod` = Ctrl on Windows/Linux, ⌘ on macOS. `Shift`/`Alt` are literal. The final
// segment is a `KeyboardEvent.key` name, except `Space` (which also accepts `code`, because a
// focused button reports `key === " "` inconsistently across engines).
// focused button reports `key === " "` inconsistently across engines). A SPACE separates the two
// steps of a sequence: `g h` is `g`, then `h` within SEQUENCE_MS.
//
// Owner rule (E11 D1): no new binding may need a modifier on the Hungarian QWERTZ layout — so no `/`
// or `?` (both Shift chords there). Letters, digits, `-`, `,`, `.`, Esc/Enter/arrows only. `keyMatches`
// compares `e.key`, so a letter follows the printed legend (the HU Z/Y swap included).
export const SHORTCUTS = [
// --- global ---------------------------------------------------------------------------------
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
@@ -60,6 +88,50 @@ export const SHORTCUTS = [
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
// --- page-level: jump to a module (quiet under an overlay) -----------------------------------
// English initials, because the Hungarian ones collide (Letöltések / Lejátszási listák). `g h` is
// Shift+W's modifier-free twin and shares its help line. Admin pages stay on the palette.
{ id: "goHome", scope: "page", keys: ["g h"], labelKey: "keys.home", page: "home" },
{ id: "goFeed", scope: "page", keys: ["g f"], labelKey: "keys.goFeed", page: "feed" },
{ id: "goChannels", scope: "page", keys: ["g c"], labelKey: "keys.goChannels", page: "channels" },
{
id: "goPlaylists",
scope: "page",
keys: ["g p"],
labelKey: "keys.goPlaylists",
page: "playlists",
},
{ id: "goPlex", scope: "page", keys: ["g x"], labelKey: "keys.goPlex", page: "plex" },
{
id: "goNotifications",
scope: "page",
keys: ["g n"],
labelKey: "keys.goNotifications",
page: "notifications",
},
{ id: "goMessages", scope: "page", keys: ["g m"], labelKey: "keys.goMessages", page: "messages" },
{
id: "goDownloads",
scope: "page",
keys: ["g d"],
labelKey: "keys.goDownloads",
page: "downloads",
},
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
// --- the module's search box (wherever a SearchBar is mounted) -------------------------------
{ id: "focus", scope: "search", keys: ["s"], labelKey: "keys.search" },
// --- list / grid navigation (wherever a list registers with `useListNav`) --------------------
{ id: "next", scope: "list", keys: ["j"], labelKey: "keys.listStep" },
{ id: "prev", scope: "list", keys: ["k"], labelKey: "keys.listStep" },
{ id: "left", scope: "list", keys: ["ArrowLeft"], labelKey: "keys.gridMove" },
{ id: "right", scope: "list", keys: ["ArrowRight"], labelKey: "keys.gridMove" },
{ id: "up", scope: "list", keys: ["ArrowUp"], labelKey: "keys.gridMove" },
{ id: "down", scope: "list", keys: ["ArrowDown"], labelKey: "keys.gridMove" },
{ id: "open", scope: "list", keys: ["Enter"], labelKey: "keys.open" },
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
@@ -206,23 +278,87 @@ export function matchesShortcut<S extends KeyScope>(
return !!row?.keys.some((k) => matches(e, k));
}
// ── Two-step sequences (`g h`) ─────────────────────────────────────────────────────────────────
// Each scope keeps its own window listener, so a sequence cannot live in one listener's closure: the
// `h` that completes `g h` also reaches the global listener (where `h` opens the sheet) and every
// other live scope. So the pending first step is module state, and it belongs to the scope that
// started it — every OTHER scope ignores the next key while it is pending, and the owner stamps the
// event once it has resolved it, so a scope whose listener runs after the owner's ignores it too.
// The stamp is the layerStack Escape trick: every listener in one dispatch gets the same object.
const SEQUENCE_MS = 1500;
let pending: { scope: KeyScope; key: string; at: number } | null = null;
const sequenceEvents = new WeakSet<KeyboardEvent>();
// A modifier's own keydown (reaching for Shift) neither completes nor breaks a sequence.
const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
function liveSequence(e: KeyboardEvent): typeof pending {
if (!pending) return null;
if (Date.now() - pending.at >= SEQUENCE_MS) {
pending = null;
return null;
}
return MODIFIER_KEYS.has(e.key) ? null : pending;
}
const stepsOf = (chord: string): string[] => chord.split(" ");
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
* whether to `preventDefault`, because only it knows if it actually acted) or a sequence's first
* step was taken. `handlers` is a mapped type over the scope's ids, so the table and the
* implementation cannot drift without tsc saying so. Skipped while typing unless the row opts in
* with `allowInInput` (Escape, and only Escape), and — for a page-level scope — while a layer that
* owns the keyboard is open. The rule lives here rather than in each listener so a new page-level
* surface cannot forget it. */
export function dispatch<S extends KeyScope>(
e: KeyboardEvent,
scope: S,
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
): boolean {
if (sequenceEvents.has(e)) return false;
const live = liveSequence(e);
if (live && live.scope !== scope) return false;
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
const typing = isTypingTarget(e);
const run = (id: string) => (handlers as Record<string, (ev: KeyboardEvent) => void>)[id]?.(e);
if (live) {
// This key ends the sequence either way: it completes a row, or it breaks the attempt.
pending = null;
sequenceEvents.add(e);
if (quiet || typing) return false;
for (const s of ROWS) {
if (s.scope !== scope || s.handledLocally) continue;
const hit = s.keys.some((k) => {
const [first, second] = stepsOf(k);
return second !== undefined && first === live.key && matches(e, second);
});
if (hit) {
run(s.id);
return true;
}
}
return false;
}
if (quiet) return false;
for (const s of ROWS) {
if (s.scope !== scope || s.handledLocally) continue;
if (typing && !s.allowInInput) continue;
if (!s.keys.some((k) => matches(e, k))) continue;
(handlers as Record<string, (ev: KeyboardEvent) => void>)[s.id]?.(e);
if (!s.keys.some((k) => stepsOf(k).length === 1 && matches(e, k))) continue;
run(s.id);
return true;
}
if (typing) return false;
// Nothing matched outright — does this key open one of the scope's sequences?
for (const s of ROWS) {
if (s.scope !== scope || s.handledLocally) continue;
const first = s.keys.map(stepsOf).find((st) => st.length === 2 && matches(e, st[0]!))?.[0];
if (first !== undefined) {
pending = { scope, key: first, at: Date.now() };
sequenceEvents.add(e);
return true;
}
}
return false;
}
@@ -261,18 +397,40 @@ export function useActiveScopes(): KeyScope[] {
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
// (E10 review round 2). Innermost registration wins the ordering.
return [...new Set([...stack].reverse())].concat("global");
const live = [...new Set([...stack].reverse())];
// The page-level scopes go in a FIXED order after the surfaces, not in registration order: App
// registers `page` in its own effect, which React runs after every child's — so by mount order
// the module jumps would head the sheet, above the list keys of the page you are looking at.
const surfaces = live.filter((s) => !isPageLevelScope(s));
const pageLevel = (["list", "search", "page"] as const).filter((s) => live.includes(s));
return [...surfaces, ...pageLevel, "global"];
}
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
// there is exactly one answer to "what can I press here" instead of one per player.
const helpOpen = createStore(false);
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
const helpOverLayer = createStore(false);
export const closeKeyboardHelp = (): void => helpOpen.set(false);
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
export const toggleKeyboardHelp = (): void => {
const next = !helpOpen.get();
if (next) helpOverLayer.set(anyBlockingLayer());
helpOpen.set(next);
};
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.use();
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
const navigablePages = createStore<readonly Page[]>([]);
export const useNavigablePages = (): readonly Page[] => navigablePages.use();
// ── The command palette ───────────────────────────────────────────────────────────────────────
const paletteOpen = createStore(false);
@@ -280,21 +438,32 @@ const paletteOpen = createStore(false);
export const closePalette = (): void => paletteOpen.set(false);
export const usePaletteOpen = (): boolean => paletteOpen.use();
/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either
* overlay because both are opened by it: a listener that sat in one of them would have to route a
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
* calls this; the overlays are pure renderers of their store.
/** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside
* either overlay because both are opened by it: a listener that sat in one of them would have to
* route a key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything.
* App calls this; the overlays are pure renderers of their store.
*
* `pages` is the set of modules this account can reach (a `g` chord to anything else is a no-op).
*
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
* popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
// The Home jump is the one global key whose action lives OUTSIDE this file — navigation is a
export function useGlobalKeymap(
enabled: boolean,
onNavigate: (page: Page) => void,
pages: readonly Page[],
): void {
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
const homeRef = useRef(onHome);
homeRef.current = onHome;
const navRef = useRef(onNavigate);
navRef.current = onNavigate;
useKeyScope("page", enabled);
const pagesKey = pages.join(",");
useEffect(() => {
navigablePages.set(pagesKey ? (pagesKey.split(",") as Page[]) : []);
}, [pagesKey]);
useEffect(() => {
if (!enabled) {
// Leaving a stale `true` behind would spring the overlay open on the next mount.
@@ -302,6 +471,17 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
paletteOpen.set(false);
return;
}
// Built from the rows, so a new `g` row needs no second edit here — the row's `page` IS the action.
const pageHandlers = Object.fromEntries(
ROWS.filter((s) => s.scope === "page").map((s) => [
s.id,
(ev: KeyboardEvent) => {
if (!s.page || !navigablePages.get().includes(s.page)) return;
ev.preventDefault();
navRef.current(s.page);
},
]),
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
const onKey = (e: KeyboardEvent) => {
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
@@ -309,10 +489,10 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
// our own depth bookkeeping keeps counting them — a desynced stack whose next browser Back
// walks out of the app. Holding a key is not a second press.
if (e.repeat) return;
dispatch(e, "global", {
const handled = dispatch(e, "global", {
help: (ev) => {
ev.preventDefault();
helpOpen.set((v) => !v);
toggleKeyboardHelp();
},
palette: (ev) => {
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
@@ -321,9 +501,10 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
},
home: (ev) => {
ev.preventDefault();
homeRef.current();
navRef.current("home");
},
});
if (!handled) dispatch(e, "page", pageHandlers);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
@@ -344,6 +525,30 @@ export function useHelpConditions(conditions: HelpConditions): void {
}, [episode]);
}
// ── The module's search box ───────────────────────────────────────────────────────────────────
/** Bind `s` to "put the caret in this search box" while the box is mounted. The one SearchBar in the
* header calls this; the text is selected so the next keystrokes replace the old query (an arrow key
* keeps it). `preventDefault` keeps the `s` itself out of the field it just focused. */
export function useSearchFocusKey(input: RefObject<HTMLInputElement | null>): void {
useKeyScope("search");
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.repeat) return;
dispatch(e, "search", {
focus: (ev) => {
const el = input.current;
if (!el) return;
ev.preventDefault();
el.focus();
el.select();
},
});
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [input]);
}
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
// ── Display ───────────────────────────────────────────────────────────────────────────────────
@@ -361,8 +566,13 @@ const GLYPHS: Record<string, string> = {
const isMac = (): boolean =>
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent);
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac). */
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac), and a
* sequence step by step: `g h` → `G H`. */
export function formatChord(chord: string): string {
return stepsOf(chord).map(formatStep).join(" ");
}
function formatStep(chord: string): string {
return chord
.split("+")
.map((part, i, all) => {
@@ -380,15 +590,19 @@ export interface HelpLine {
when?: ShortcutWhen;
}
/** The shortcuts of `scopes`, grouped into help lines by description. Scopes come out in the order
/** The shortcuts of `scopes`, grouped into help lines by description (with `pages`, the `g` chords
* are limited to those modules). Scopes come out in the order
* ASKED FOR, rows within a scope in table order — so a surface passing its own scope first heads its
* sheet with its own keys and leaves the app-wide ones underneath, where they read as context. */
export function helpLines(scopes: readonly KeyScope[]): HelpLine[] {
export function helpLines(scopes: readonly KeyScope[], pages?: readonly Page[]): HelpLine[] {
const out: HelpLine[] = [];
const byLabel = new Map<string, HelpLine>();
for (const scope of scopes) {
for (const s of ROWS) {
if (s.scope !== scope) continue;
// A `g` chord to a module this account cannot reach is left out ROW by row, not line by line:
// `g h` shares its line with Shift+W, which must stay.
if (s.page && pages && !pages.includes(s.page)) continue;
const existing = byLabel.get(s.labelKey);
if (existing) {
existing.chords.push(...s.keys);
+18 -2
View File
@@ -16,15 +16,28 @@ import { useEffect, useRef } from "react";
let stack: number[] = [];
let nextId = 1;
// Layers that take Escape but do not own the keyboard: an open tooltip or hover card. They sit on the
// stack for Escape ordering like any other layer, but `anyBlockingLayer` looks past them — otherwise
// a tooltip that happens to be open under a resting mouse pointer would silence the page's `j`/`k`.
const passive = new Set<number>();
export function pushLayer(): number {
export function pushLayer(opts?: { passive?: boolean }): number {
const id = nextId++;
stack.push(id);
if (opts?.passive) passive.add(id);
return id;
}
export function removeLayer(id: number): void {
stack = stack.filter((x) => x !== id);
passive.delete(id);
}
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
* underneath, which the user cannot see or reach while such a layer is up. */
export function anyBlockingLayer(): boolean {
return stack.some((id) => !passive.has(id));
}
export function isTopmost(id: number): boolean {
@@ -62,14 +75,17 @@ export function escapeHandled(e: KeyboardEvent): boolean {
export function useLayer(
active: boolean,
onEscape?: () => void,
opts?: { passive?: boolean },
): { isTopmost: () => boolean; anyAbove: () => boolean } {
const idRef = useRef<number | null>(null);
const escRef = useRef(onEscape);
escRef.current = onEscape;
const passiveRef = useRef(!!opts?.passive);
passiveRef.current = !!opts?.passive;
useEffect(() => {
if (!active) return;
const id = pushLayer();
const id = pushLayer({ passive: passiveRef.current });
idRef.current = id;
let onKey: ((e: KeyboardEvent) => void) | undefined;
if (escRef.current) {
+220
View File
@@ -0,0 +1,220 @@
// @vitest-environment jsdom
//
// `lib/listNav` against real DOM: `j`/`k` from item to item and across lists in reading order, the
// arrows around a grid, Enter as a click on the item's own "open" element — and the places where the
// keys must stay out of the way (a field, an overlay, a control inside the card).
import { useRef } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import { navItemAttrs, useListNav } from "./listNav";
import { pushLayer, removeLayer } from "./layerStack";
afterEach(cleanup);
function List({
id,
count,
cols = 1,
mounted,
reveal,
onOpen = () => {},
}: {
id: string;
count: number;
cols?: number;
/** Mount only the first N items, the way a virtualized grid leaves the rest out of the DOM. */
mounted?: number;
reveal?: (index: number) => void;
onOpen?: (id: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useListNav(ref, { enabled: true, count, cols, reveal });
return (
<div ref={ref}>
{Array.from({ length: mounted ?? count }, (_, i) => (
<div key={i} {...navItemAttrs(i)} data-testid={`${id}${i}`}>
<a
href="#"
data-nav-open=""
onClick={(e) => {
e.preventDefault();
onOpen(`${id}${i}`);
}}
>
title
</a>
<button data-testid={`${id}${i}-action`}>action</button>
</div>
))}
</div>
);
}
/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */
function press(key: string): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
act(() => {
(document.activeElement ?? document.body).dispatchEvent(e);
});
return e;
}
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
describe("useListNav", () => {
it("lands the first j on an item, then steps one item at a time", () => {
render(<List id="a" count={3} />);
press("j");
expect(focused()).toBe("a0");
press("j");
expect(focused()).toBe("a1");
press("k");
expect(focused()).toBe("a0");
});
// The Tab order is the reader's, not ours: only the item the keyboard is on is focusable, and the
// previous one gives its tabindex back.
it("roves a single tabindex=-1 and never adds a Tab stop", () => {
render(<List id="a" count={3} />);
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
press("j");
expect(screen.getByTestId("a0").getAttribute("tabindex")).toBe("-1");
press("j");
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
expect(screen.getByTestId("a1").getAttribute("tabindex")).toBe("-1");
});
// The player (E11 S2) restores focus to whatever held it when it opened. If the item gave its
// tabindex back on blur, that restore would silently fail and focus would fall to <body>.
it("keeps the item focusable after focus leaves it, so a dialog can restore onto it", () => {
render(
<>
<List id="a" count={2} />
<button data-testid="outside">elsewhere</button>
</>,
);
press("j");
act(() => screen.getByTestId("outside").focus());
act(() => screen.getByTestId("a0").focus());
expect(focused()).toBe("a0");
});
it("stops at both ends of the page instead of wrapping", () => {
render(<List id="a" count={2} />);
press("j");
press("k");
expect(focused()).toBe("a0");
press("j");
press("j");
expect(focused()).toBe("a1");
});
// Owner decision D4: j/k walk every collection on the page in reading order.
it("continues into the next list, and back, in document order", () => {
render(
<>
<List id="a" count={2} />
<List id="b" count={2} />
</>,
);
press("j");
press("j");
press("j");
expect(focused()).toBe("b0");
press("k");
expect(focused()).toBe("a1");
});
it("moves around a grid with the arrows, row by row", () => {
// 3 columns, 7 items: rows [0 1 2] [3 4 5] [6]
render(<List id="a" count={7} cols={3} />);
press("j");
press("ArrowRight");
expect(focused()).toBe("a1");
press("ArrowDown");
expect(focused()).toBe("a4");
press("ArrowDown"); // the row below is short: land on its last item
expect(focused()).toBe("a6");
press("ArrowDown"); // already on the last row
expect(focused()).toBe("a6");
press("ArrowUp");
expect(focused()).toBe("a3");
press("ArrowLeft"); // left wraps to the end of the row above — reading order
expect(focused()).toBe("a2");
});
it("works the arrows from a control inside the card too", () => {
render(<List id="a" count={4} cols={2} />);
act(() => screen.getByTestId("a0-action").focus());
press("ArrowDown");
expect(focused()).toBe("a2");
});
// Outside a list an arrow scrolls the page; that native meaning must survive.
it("leaves the arrows alone when focus is not in a list", () => {
render(<List id="a" count={4} cols={2} />);
expect(press("ArrowDown").defaultPrevented).toBe(false);
expect(focused()).toBeUndefined();
});
it("opens the item on Enter by clicking its own open element", () => {
const onOpen = vi.fn();
render(<List id="a" count={2} onOpen={onOpen} />);
press("j");
press("j");
expect(press("Enter").defaultPrevented).toBe(true);
expect(onOpen).toHaveBeenCalledWith("a1");
});
it("leaves Enter to a control inside the card", () => {
const onOpen = vi.fn();
render(<List id="a" count={2} onOpen={onOpen} />);
act(() => screen.getByTestId("a0-action").focus());
expect(press("Enter").defaultPrevented).toBe(false);
expect(onOpen).not.toHaveBeenCalled();
});
it("stays out of a text field", () => {
render(
<>
<input data-testid="field" />
<List id="a" count={2} />
</>,
);
act(() => screen.getByTestId("field").focus());
press("j");
expect(focused()).toBe("field");
});
it("is quiet under a layer that owns the keyboard, but not under a tooltip", () => {
render(<List id="a" count={2} />);
const modal = pushLayer();
press("j");
expect(focused()).toBeUndefined();
removeLayer(modal);
const tooltip = pushLayer({ passive: true });
press("j");
expect(focused()).toBe("a0");
removeLayer(tooltip);
});
// A virtualized grid only mounts a screen of rows: the next item may not exist yet.
it("asks the list to reveal an unmounted item, then focuses it once it renders", async () => {
const reveal = vi.fn();
const { rerender } = render(<List id="a" count={5} mounted={2} reveal={reveal} />);
press("j");
press("j");
press("j");
expect(reveal).toHaveBeenCalledWith(2);
expect(focused()).toBe("a1");
rerender(<List id="a" count={5} reveal={reveal} />);
await waitFor(() => expect(focused()).toBe("a2"));
});
it("unbinds when the last list unmounts", () => {
const { unmount } = render(<List id="a" count={2} />);
unmount();
expect(press("j").defaultPrevented).toBe(false);
});
});
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useRef, type RefObject } from "react";
import { dispatch, useKeyScope } from "./keymap";
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
// the tenth card sat ~135 Tabs from the top of the page. `j`/`k` step from ITEM to item instead, the
// arrows move around a grid, and Enter opens — the same thing a click on the item does.
//
// How an item takes part, without this file knowing what an item is:
// - its root element carries `data-nav-index` (its index in the list — `navItemAttrs`),
// - the one element whose click means "open" carries `data-nav-open` (the title link on a video card),
// - the list's container registers with `useListNav`, saying how many items there are, how many
// columns a row has, and how to bring an unmounted item into the DOM (a virtualized grid).
//
// The Tab order is untouched: an item root is NOT a Tab stop. It gets `tabindex="-1"` at the moment
// the keyboard lands on it — one item at a time, the previous one gives it back — so Tab still walks
// the controls inside the cards, a mouse click on a card's blank area still focuses nothing, and the
// player's focus restore (E11 S2) can land back on the card it was opened from.
//
// Every page-level list shares ONE window listener, and the lists are walked in DOCUMENT order: `j`
// at the end of one list continues at the start of the next (owner decision D4, "reading order").
// The arrows stay inside the list they are in.
interface NavList {
readonly el: HTMLElement;
readonly count: () => number;
readonly cols: () => number;
readonly reveal: (index: number) => void;
}
const lists = new Map<HTMLElement, NavList>();
// The item we gave a tabindex to, so the next move can take it back.
let roved: HTMLElement | null = null;
const INDEX = "data-nav-index";
const LIST = "data-nav-list";
// How long to wait for a virtualized item to mount after asking the list to reveal it.
const REVEAL_FRAMES = 10;
/** Spread onto an item's ROOT element. */
export const navItemAttrs = (index: number): { "data-nav-index": number } => ({
[INDEX]: index,
});
const indexOf = (item: Element): number => Number(item.getAttribute(INDEX));
function itemAt(list: NavList, index: number): HTMLElement | null {
return list.el.querySelector<HTMLElement>(`[${INDEX}="${index}"]`);
}
function ordered(): NavList[] {
return [...lists.values()]
.filter((l) => l.el.isConnected)
.sort((a, b) =>
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
);
}
/** The item holding focus (on its root or on a control inside it), and the list it belongs to. */
function current(): { list: NavList; index: number; item: HTMLElement } | null {
const item = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>(`[${INDEX}]`);
const list =
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${LIST}]`) as HTMLElement);
return item && list ? { list, index: indexOf(item), item } : null;
}
function scrollParent(node: HTMLElement): HTMLElement | null {
for (let el = node.parentElement; el; el = el.parentElement) {
const oy = getComputedStyle(el).overflowY;
if (oy === "auto" || oy === "scroll") return el;
}
return null;
}
/** Where `j` lands when nothing in a list has focus yet: the first item the reader can see, not the
* top of the list — after scrolling down a feed, the first press must not throw them back up. "Can
* see" = its bottom edge is below the top of the scroller's visible band, which starts under the
* floating header (the item's own `scroll-margin-top` is exactly that header clearance). */
function firstVisible(): { list: NavList; index: number } | null {
let fallback: { list: NavList; index: number } | null = null;
for (const list of ordered()) {
const items = [...list.el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
.filter((el) => el.parentElement?.closest(`[${LIST}]`) === list.el)
.sort((a, b) => indexOf(a) - indexOf(b));
if (!items.length) continue;
fallback ??= { list, index: indexOf(items[0]!) };
const top = scrollParent(list.el)?.getBoundingClientRect().top ?? 0;
for (const el of items) {
const band = top + (parseFloat(getComputedStyle(el).scrollMarginTop) || 0);
if (el.getBoundingClientRect().bottom > band) return { list, index: indexOf(el) };
}
}
return fallback;
}
function focusItem(el: HTMLElement): void {
if (roved && roved !== el) roved.removeAttribute("tabindex");
roved = null;
// An item that is focusable in its own right (a Plex card is a role=button) keeps its tabindex.
if (!el.hasAttribute("tabindex")) {
el.setAttribute("tabindex", "-1");
roved = el;
}
el.focus({ preventScroll: true });
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
// header or past the bottom edge scrolls just far enough to clear it.
el.scrollIntoView?.({ block: "nearest" });
}
function focusIndex(list: NavList, index: number): void {
const el = itemAt(list, index);
if (el) return focusItem(el);
// Virtualized and not mounted: have the list scroll it in, then take it as soon as it renders.
list.reveal(index);
let frames = 0;
const wait = () => {
const got = itemAt(list, index);
if (got) focusItem(got);
else if (++frames < REVEAL_FRAMES) requestAnimationFrame(wait);
};
requestAnimationFrame(wait);
}
function step(delta: 1 | -1): void {
const cur = current();
if (!cur) {
const start = firstVisible();
if (start) focusIndex(start.list, start.index);
return;
}
const next = cur.index + delta;
if (next >= 0 && next < cur.list.count()) return focusIndex(cur.list, next);
// Past this list's end: on to the neighbouring list in reading order, skipping empty ones. At the
// first or last item of the page it stops — no wrap to the other end, no loading the next page.
const all = ordered();
for (let at = all.indexOf(cur.list) + delta; at >= 0 && at < all.length; at += delta) {
const n = all[at]!.count();
if (n > 0) return focusIndex(all[at]!, delta > 0 ? 0 : n - 1);
}
}
/** Move inside the focused item's list. ←→ step one item (wrapping rows, the reading order); ↑↓ one
* row, landing on the last item when the row below is a short last row. Returns false when focus is
* not in a list, so the arrow keeps its native meaning (scrolling) there. */
function move(dx: -1 | 0 | 1, dy: -1 | 0 | 1): boolean {
const cur = current();
if (!cur) return false;
const count = cur.list.count();
const cols = Math.max(1, cur.list.cols());
const row = Math.floor(cur.index / cols);
let target = cur.index;
if (dx) target = cur.index + dx;
else if (dy > 0 && row < Math.floor((count - 1) / cols))
target = Math.min(cur.index + cols, count - 1);
else if (dy < 0 && row > 0) target = cur.index - cols;
if (target !== cur.index && target >= 0 && target < count) focusIndex(cur.list, target);
return true;
}
function onKey(e: KeyboardEvent): void {
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
if (move(dx, dy)) ev.preventDefault();
};
dispatch(e, "list", {
next: (ev) => {
ev.preventDefault();
step(1);
},
prev: (ev) => {
ev.preventDefault();
step(-1);
},
left: arrow(-1, 0),
right: arrow(1, 0),
up: arrow(0, -1),
down: arrow(0, 1),
// Only on the item ROOT: Enter on a button inside the card must stay that button's Enter.
open: (ev) => {
const cur = current();
if (!cur || document.activeElement !== cur.item) return;
const target = cur.item.querySelector<HTMLElement>("[data-nav-open]");
if (!target) return;
ev.preventDefault();
// A real click on the item's own "open" element, so Enter runs exactly the click path — not a
// second copy of what opening means that could drift from it.
target.click();
},
});
}
/** Register `ref` as a keyboard-navigable list while `enabled`. `count` is the number of items (the
* loaded ones, for an infinite list), `cols` the items per row (1 for a column), and `reveal` scrolls
* an item that is not mounted into view — omit it for a list that mounts everything. */
export function useListNav(
ref: RefObject<HTMLElement | null>,
opts: { enabled: boolean; count: number; cols: number; reveal?: (index: number) => void },
): void {
const optsRef = useRef(opts);
optsRef.current = opts;
useKeyScope("list", opts.enabled);
useEffect(() => {
const el = ref.current;
if (!opts.enabled || !el) return;
lists.set(el, {
el,
count: () => optsRef.current.count,
cols: () => optsRef.current.cols,
reveal: (i) => optsRef.current.reveal?.(i),
});
el.setAttribute(LIST, "");
if (lists.size === 1) window.addEventListener("keydown", onKey);
return () => {
lists.delete(el);
el.removeAttribute(LIST);
if (lists.size === 0) window.removeEventListener("keydown", onKey);
};
}, [ref, opts.enabled]);
}
+94
View File
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
//
// The `page` scope as App binds it through `useGlobalKeymap`: the `g` chords jump to a module this
// account can reach, stay out of an overlay's way, and leave the global keys (Shift+W, h) intact.
// Its own file for the same reason as keymap.sequence.test.ts: a pending `g` is module state.
import { useEffect } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render } from "@testing-library/react";
import { closeKeyboardHelp, useGlobalKeymap, useKeyboardHelpOpen } from "./keymap";
import { pushLayer, removeLayer } from "./layerStack";
import type { Page } from "./urlState";
let clock = Date.UTC(2031, 0, 1);
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
clock += 3_600_000;
vi.setSystemTime(clock);
});
afterEach(() => {
cleanup();
closeKeyboardHelp();
vi.useRealTimers();
});
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "downloads", "settings"];
function Host({
onNavigate,
pages = ALL,
onHelp = () => {},
}: {
onNavigate: (p: Page) => void;
pages?: Page[];
onHelp?: (open: boolean) => void;
}) {
useGlobalKeymap(true, onNavigate, pages);
const help = useKeyboardHelpOpen();
useEffect(() => {
onHelp(help);
}, [help, onHelp]);
return null;
}
const key = (init: KeyboardEventInit) =>
act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }),
);
});
describe("the g chords", () => {
it("jump to the module named by the second key", () => {
const nav = vi.fn();
render(<Host onNavigate={nav} />);
key({ key: "f" });
expect(nav).not.toHaveBeenCalled();
key({ key: "g" });
key({ key: "f" });
key({ key: "g" });
key({ key: "," });
expect(nav.mock.calls).toEqual([["feed"], ["settings"]]);
});
// `h` alone opens the sheet; the `h` of `g h` must not ALSO do that.
it("take the h of g h away from the help sheet", () => {
const nav = vi.fn();
let help = false;
render(<Host onNavigate={nav} onHelp={(v) => (help = v)} />);
key({ key: "g" });
key({ key: "h" });
expect(nav).toHaveBeenCalledWith("home");
expect(help).toBe(false);
});
it("refuse a module this account cannot reach", () => {
const nav = vi.fn();
render(<Host onNavigate={nav} pages={["home", "feed"]} />);
key({ key: "g" });
key({ key: "x" });
expect(nav).not.toHaveBeenCalled();
});
it("stand aside for an overlay, while Shift+W still answers there", () => {
const nav = vi.fn();
render(<Host onNavigate={nav} />);
const id = pushLayer();
key({ key: "g" });
key({ key: "f" });
expect(nav).not.toHaveBeenCalled();
key({ key: "w", shiftKey: true });
expect(nav).toHaveBeenCalledWith("home");
removeLayer(id);
});
});