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:
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user