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
+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]);
}