From 8fb276aa90db43dc9fd889ee2a65c11fa23abeab Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 13 Sep 2026 04:16:20 +0200 Subject: [PATCH] feat(keys): j/k walk the feed, s finds the search box, g jumps to a module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CLAUDE.md | 1 + frontend/src/App.tsx | 10 +- frontend/src/components/AboutCell.tsx | 2 +- .../src/components/PlexSearchBox.dom.test.tsx | 19 ++ frontend/src/components/PlexSearchBox.tsx | 1 + .../components/SearchBar.keys.dom.test.tsx | 119 ++++++++ frontend/src/components/SearchBar.tsx | 13 +- .../src/components/VideoCard.nav.dom.test.tsx | 89 ++++++ frontend/src/components/VideoCard.tsx | 10 + frontend/src/components/VirtualFeed.tsx | 4 +- .../src/components/ui/AnchoredPopover.tsx | 5 +- .../components/ui/KeyboardHelp.dom.test.tsx | 32 ++- frontend/src/components/ui/KeyboardHelp.tsx | 15 +- .../components/ui/Tooltip.layer.dom.test.tsx | 66 +++++ frontend/src/components/ui/VirtualGrid.tsx | 25 +- frontend/src/i18n/locales/en/keys.json | 15 +- frontend/src/i18n/locales/hu/keys.json | 15 +- frontend/src/index.css | 24 +- frontend/src/lib/globalKeymap.dom.test.tsx | 2 +- frontend/src/lib/keymap.sequence.test.ts | 166 +++++++++++ frontend/src/lib/keymap.ts | 264 ++++++++++++++++-- frontend/src/lib/layerStack.ts | 20 +- frontend/src/lib/listNav.dom.test.tsx | 220 +++++++++++++++ frontend/src/lib/listNav.ts | 218 +++++++++++++++ frontend/src/lib/pageKeys.dom.test.tsx | 94 +++++++ 25 files changed, 1400 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/SearchBar.keys.dom.test.tsx create mode 100644 frontend/src/components/VideoCard.nav.dom.test.tsx create mode 100644 frontend/src/components/ui/Tooltip.layer.dom.test.tsx create mode 100644 frontend/src/lib/keymap.sequence.test.ts create mode 100644 frontend/src/lib/listNav.dom.test.tsx create mode 100644 frontend/src/lib/listNav.ts create mode 100644 frontend/src/lib/pageKeys.dom.test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index d580fc7d..b93f9bfc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory | FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks | | FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled ` + , + ); + const trigger = screen.getByText("icon").parentElement as HTMLElement; + act(() => { + fireEvent.pointerEnter(trigger); + fireEvent.mouseEnter(trigger); + fireEvent.mouseMove(trigger); + }); + const surface = await screen.findByRole("tooltip"); + await waitFor(() => expect(surface.style.visibility).not.toBe("hidden")); + + expect(anyBlockingLayer()).toBe(false); + + act(() => { + fireEvent.keyDown(document, { key: "Escape" }); + }); + await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull()); + }); +}); diff --git a/frontend/src/components/ui/VirtualGrid.tsx b/frontend/src/components/ui/VirtualGrid.tsx index 24fe8d92..f496ff65 100644 --- a/frontend/src/components/ui/VirtualGrid.tsx +++ b/frontend/src/components/ui/VirtualGrid.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { useListNav } from "../../lib/listNav"; import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns"; import { shouldPrefetchNext } from "./readAhead"; @@ -66,8 +67,13 @@ export interface VirtualGridProps { rowEst: number; /** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived * layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to - * protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */ - renderItem: (item: T, itemWidth: number) => ReactNode; + * protect a memo'd item from re-rendering on every resize frame, pass a stable value down. + * `index` is the item's position in `items` — what `navItemAttrs` wants on the item's root. */ + renderItem: (item: T, itemWidth: number, index: number) => ReactNode; + /** Take part in page keyboard navigation (`lib/listNav`): `j`/`k`/arrows/Enter over the items. + * The grid supplies the column count and scrolls an unmounted item's row in; the item must spread + * `navItemAttrs(index)` on its root and mark its "open" element `data-nav-open`. */ + keyboardNav?: boolean; /** Infinite scroll — omit all three for a finite client-side list. */ hasNextPage?: boolean; isFetchingNextPage?: boolean; @@ -85,6 +91,7 @@ export default function VirtualGrid({ hasNextPage = false, isFetchingNextPage = false, fetchNextPage, + keyboardNav = false, }: VirtualGridProps) { const listRef = useRef(null); const [scrollEl, setScrollEl] = useState(null); @@ -139,6 +146,15 @@ export default function VirtualGrid({ const virtualRows = virtualizer.getVirtualItems(); + // Only ~a screen of rows (plus overscan) is mounted, so a key that moves further — or past the + // overscan at the edge — has to scroll the row in before the item exists to be focused. + useListNav(listRef, { + enabled: keyboardNav, + count: items.length, + cols: colCount, + reveal: (index) => virtualizer.scrollToIndex(Math.floor(index / colCount), { align: "auto" }), + }); + // Infinite-scroll read-ahead: pull the next page once the last rendered row is within the // read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row // count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no @@ -159,6 +175,7 @@ export default function VirtualGrid({ {virtualRows.map((vr) => { const row = rows[vr.index]; if (!row) return null; + const first = vr.index * colCount; return (
({ {/* A transparent Fragment carries React's key so the rendered item stays the DIRECT grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned to the bottom edge), which a wrapper element would silently break. */} - {row.map((item) => ( - {renderItem(item, itemWidth)} + {row.map((item, i) => ( + {renderItem(item, itemWidth, first + i)} ))}
diff --git a/frontend/src/i18n/locales/en/keys.json b/frontend/src/i18n/locales/en/keys.json index 37f9076c..b7e3decd 100644 --- a/frontend/src/i18n/locales/en/keys.json +++ b/frontend/src/i18n/locales/en/keys.json @@ -21,5 +21,18 @@ "back": "Stop & back to feed", "browseBack": "Back one level", "closePlayer": "Close the player", - "palette": "Open the jump-to palette" + "palette": "Open the jump-to palette", + "goFeed": "Go to Feed", + "goChannels": "Go to Channels", + "goPlaylists": "Go to Playlists", + "goPlex": "Go to Plex", + "goNotifications": "Go to Notifications", + "goMessages": "Go to Messages", + "goDownloads": "Go to Downloads", + "goStats": "Go to Stats", + "goSettings": "Go to Settings", + "search": "Jump to the search box", + "listStep": "Next / previous item", + "gridMove": "Move around the list", + "open": "Open the selected item" } diff --git a/frontend/src/i18n/locales/hu/keys.json b/frontend/src/i18n/locales/hu/keys.json index b6e24c44..d50a001b 100644 --- a/frontend/src/i18n/locales/hu/keys.json +++ b/frontend/src/i18n/locales/hu/keys.json @@ -21,5 +21,18 @@ "back": "Leállítás és vissza a feedre", "browseBack": "Vissza egy szintet", "closePlayer": "Lejátszó bezárása", - "palette": "Ugrás-paletta megnyitása" + "palette": "Ugrás-paletta megnyitása", + "goFeed": "Ugrás: Hírfolyam", + "goChannels": "Ugrás: Csatornák", + "goPlaylists": "Ugrás: Lejátszási listák", + "goPlex": "Ugrás: Plex", + "goNotifications": "Ugrás: Értesítések", + "goMessages": "Ugrás: Üzenetek", + "goDownloads": "Ugrás: Letöltések", + "goStats": "Ugrás: Statisztika", + "goSettings": "Ugrás: Beállítások", + "search": "Ugrás a keresőbe", + "listStep": "Következő / előző elem", + "gridMove": "Mozgás a listában", + "open": "A kijelölt elem megnyitása" } diff --git a/frontend/src/index.css b/frontend/src/index.css index ba7ded13..d35aa695 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -84,7 +84,8 @@ [role="button"]:focus-visible, [role="tab"]:focus-visible, summary:focus-visible { - outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); + /* Solid accent (E11 D2 = B): the 60% mix of R9 all but vanished on the dark glass. */ + outline: 2px solid var(--accent); outline-offset: 2px; } } @@ -435,10 +436,29 @@ body { .selectable:focus-visible { /* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */ - outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); + outline: 2px solid var(--accent); outline-offset: -2px; } + /* E11 S4 · a list/grid item the keyboard landed on (`lib/listNav`): the SAME solid ring, around the + whole card rather than one control inside it (D2 = B). It is focus-visible only, so a mouse click + never paints it, and it is a ring rather than a fill, so it never reads like the hover tint. The + scroll margins are what `scrollIntoView({block:"nearest"})` honours: they keep a card stepped to + from sliding under the floating header (`--hdr-h`) or flush against the bottom edge. */ + [data-nav-index] { + scroll-margin-top: calc(var(--hdr-h, 0px) + 0.75rem); + scroll-margin-bottom: 0.75rem; + /* A transparent resting ring of the SAME geometry: the video card transitions `all`, so without + it the ring animated in from the initial outline (3px, currentColor) — a white flash on every + `j`, measured in Chrome. Now only the colour fades in. */ + outline: 2px solid transparent; + outline-offset: 2px; + } + [data-nav-index]:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + /* ===== R10 S4 · Selection-paint recipes ===== The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`), diff --git a/frontend/src/lib/globalKeymap.dom.test.tsx b/frontend/src/lib/globalKeymap.dom.test.tsx index 023f242c..e4ac224f 100644 --- a/frontend/src/lib/globalKeymap.dom.test.tsx +++ b/frontend/src/lib/globalKeymap.dom.test.tsx @@ -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(() => { diff --git a/frontend/src/lib/keymap.sequence.test.ts b/frontend/src/lib/keymap.sequence.test.ts new file mode 100644 index 00000000..2e8d6c3b --- /dev/null +++ b/frontend/src/lib/keymap.sequence.test.ts @@ -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"]); + }); +}); diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index 83a236a2..68dedce6 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -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 = new Set(["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( 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(); +// 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( e: KeyboardEvent, scope: S, handlers: { readonly [K in ShortcutId]: (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 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 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([]); +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): 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 = { 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(); 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); diff --git a/frontend/src/lib/layerStack.ts b/frontend/src/lib/layerStack.ts index 2ac0057b..d56d8512 100644 --- a/frontend/src/lib/layerStack.ts +++ b/frontend/src/lib/layerStack.ts @@ -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(); -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(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) { diff --git a/frontend/src/lib/listNav.dom.test.tsx b/frontend/src/lib/listNav.dom.test.tsx new file mode 100644 index 00000000..3e24205b --- /dev/null +++ b/frontend/src/lib/listNav.dom.test.tsx @@ -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(null); + useListNav(ref, { enabled: true, count, cols, reveal }); + return ( +
+ {Array.from({ length: mounted ?? count }, (_, i) => ( + + ))} +
+ ); +} + +/** 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(); + 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(); + 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 . + it("keeps the item focusable after focus leaves it, so a dialog can restore onto it", () => { + render( + <> + + + , + ); + 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(); + 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( + <> + + + , + ); + 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(); + 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(); + 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(); + 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(); + 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(); + act(() => screen.getByTestId("a0-action").focus()); + expect(press("Enter").defaultPrevented).toBe(false); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("stays out of a text field", () => { + render( + <> + + + , + ); + 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(); + 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(); + press("j"); + press("j"); + press("j"); + expect(reveal).toHaveBeenCalledWith(2); + expect(focused()).toBe("a1"); + rerender(); + await waitFor(() => expect(focused()).toBe("a2")); + }); + + it("unbinds when the last list unmounts", () => { + const { unmount } = render(); + unmount(); + expect(press("j").defaultPrevented).toBe(false); + }); +}); diff --git a/frontend/src/lib/listNav.ts b/frontend/src/lib/listNav.ts new file mode 100644 index 00000000..d8be132a --- /dev/null +++ b/frontend/src/lib/listNav.ts @@ -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(); +// 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(`[${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(`[${INDEX}]`); + const list = + item && lists.get(item.parentElement?.closest(`[${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(`[${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("[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, + 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]); +} diff --git a/frontend/src/lib/pageKeys.dom.test.tsx b/frontend/src/lib/pageKeys.dom.test.tsx new file mode 100644 index 00000000..526b6c9c --- /dev/null +++ b/frontend/src/lib/pageKeys.dom.test.tsx @@ -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(); + 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( (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(); + 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(); + const id = pushLayer(); + key({ key: "g" }); + key({ key: "f" }); + expect(nav).not.toHaveBeenCalled(); + key({ key: "w", shiftKey: true }); + expect(nav).toHaveBeenCalledWith("home"); + removeLayer(id); + }); +});