The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
531 lines
25 KiB
TypeScript
531 lines
25 KiB
TypeScript
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
|
|
import { dispatch, useKeyScope } from "./keymap";
|
|
import { scrollParent } from "./listPaging";
|
|
import { focusIsUnclaimed } from "./useFocusKeeper";
|
|
|
|
// ── Keyboard navigation over lists and grids (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 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, "reading order").
|
|
// ←→ stay inside the list they are in; ↑ on a list's top row and ↓ on its bottom row go on to the
|
|
// neighbouring list — the rail above or below on Home and Discover — landing on the item nearest the
|
|
// one you left, so a column of rails reads like one grid.
|
|
//
|
|
// A list that mounts everything (a rail, a plain grid) need not say how many items or columns it has:
|
|
// both are read off the DOM when the key is pressed, so an auto-fill grid's columns follow its width.
|
|
|
|
interface NavList {
|
|
readonly el: HTMLElement;
|
|
readonly count: () => number;
|
|
readonly cols: () => number;
|
|
readonly reveal: (index: number) => void;
|
|
/** Takes the item at `index` out of the list (Delete), or null for a list that cannot. */
|
|
readonly remove: () => ((index: number) => void) | null;
|
|
}
|
|
|
|
const lists = new Map<HTMLElement, NavList>();
|
|
// The item we gave a tabindex to, so the next move can take it back — and whether we named it too.
|
|
let roved: HTMLElement | null = null;
|
|
let named = false;
|
|
|
|
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. `name` is what a screen reader calls the item while the keyboard
|
|
* is on it (its title) — pass it where the element Enter opens wraps the whole card (a Plex poster:
|
|
* badges, year, rating and summary run together), shows a caption that is not the item's name, or
|
|
* holds no text at all.
|
|
*
|
|
* `inset` draws the keyboard ring INSIDE the item. The ring sits 2 px outside by default, which a
|
|
* card with a gap round it has room for; an item flush against its neighbours (a log line) or against
|
|
* the edge of a clipping card (a Plex playlist show header) does not: the ring ran past the list, or
|
|
* was clipped away entirely. A table row needs no flag: index.css insets every `tr` by its tag. */
|
|
export const navItemAttrs = (
|
|
index: number,
|
|
name?: string | null,
|
|
{ inset = false }: { inset?: boolean } = {},
|
|
): { "data-nav-index": number; "data-nav-name"?: string; "data-nav-inset"?: "" } => ({
|
|
[INDEX]: index,
|
|
...(name ? { "data-nav-name": name } : null),
|
|
...(inset ? { "data-nav-inset": "" } : null),
|
|
});
|
|
|
|
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()]
|
|
// Only lists on screen: a list can be mounted yet hidden (display:none in a collapsed panel or
|
|
// an inactive tab), and `j` must not walk into rows nobody can see.
|
|
.filter((l) => l.el.isConnected && l.el.checkVisibility?.() !== false)
|
|
.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;
|
|
}
|
|
|
|
/** The mounted items that belong to `el` itself (not to a list nested inside it), in index order. */
|
|
function ownItems(el: HTMLElement): HTMLElement[] {
|
|
return [...el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
|
|
.filter((item) => item.parentElement?.closest(`[${LIST}]`) === el)
|
|
.sort((a, b) => indexOf(a) - indexOf(b));
|
|
}
|
|
|
|
// Items whose tops are this close share a row (sub-pixel layout, a card a hair taller than the next).
|
|
const ROW_SLACK = 4;
|
|
const topOf = (el: Element) => el.getBoundingClientRect().top;
|
|
const centerX = (el: Element) => {
|
|
const r = el.getBoundingClientRect();
|
|
return r.left + r.width / 2;
|
|
};
|
|
|
|
/** Items per row, measured: the leading items that share the first item's top. A rail is one row. */
|
|
function measuredCols(el: HTMLElement): number {
|
|
const items = ownItems(el);
|
|
if (!items.length) return 1;
|
|
const first = topOf(items[0]!);
|
|
const inRow = items.findIndex((item) => Math.abs(topOf(item) - first) > ROW_SLACK);
|
|
return inRow < 0 ? items.length : inRow;
|
|
}
|
|
|
|
/** 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), and — in a
|
|
* rail scrolled sideways — its right edge is past the rail's left edge. */
|
|
function firstVisible(): { list: NavList; index: number } | null {
|
|
let fallback: { list: NavList; index: number } | null = null;
|
|
for (const list of ordered()) {
|
|
const items = ownItems(list.el);
|
|
if (!items.length) continue;
|
|
fallback ??= { list, index: indexOf(items[0]!) };
|
|
const top = scrollParent(list.el)?.getBoundingClientRect().top ?? 0;
|
|
const left = list.el.getBoundingClientRect().left;
|
|
for (const el of items) {
|
|
const band = top + (parseFloat(getComputedStyle(el).scrollMarginTop) || 0);
|
|
const r = el.getBoundingClientRect();
|
|
if (r.bottom > band && r.right > left) return { list, index: indexOf(el) };
|
|
}
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/** The item focus was last inside, so a `j` after that item was REMOVED (a feed card hidden, a
|
|
* download deleted — focus falls to <body>) goes on from its slot instead of starting over. */
|
|
let last: { list: NavList; index: number; el: HTMLElement } | null = null;
|
|
|
|
function onFocusIn(e: FocusEvent): void {
|
|
const target = e.target as HTMLElement | null;
|
|
const item = target?.closest?.<HTMLElement>(`[${INDEX}]`);
|
|
const list =
|
|
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${LIST}]`) as HTMLElement);
|
|
if (item && list) last = { list, index: indexOf(item), el: item };
|
|
// The focus coming to REST on the page region (a click on its background) is not leaving the list
|
|
// for other content — like a click on <body>, which fires no focusin at all.
|
|
else if (focusIsUnclaimed(target)) return;
|
|
// Focus went on to page content that is not an item — the search box, a filter chip: the reader
|
|
// left the list, and whatever it shows next (new results) is not "the slot they were in".
|
|
// A layer opened FROM the item — its menu, a confirm, the Undo toast its removal put
|
|
// up — does not count: focus comes back from it, and what it did (a hidden card, a deleted row, the
|
|
// card put back) is what the slot is kept for. Removing the focused node itself fires no focusin.
|
|
else if (!target?.closest?.('[data-floating-ui-portal], [role="dialog"], [data-toast-region]'))
|
|
last = null;
|
|
}
|
|
|
|
/** Where `j` (`k`: the item before) lands after the last-focused item left the DOM: the item now in
|
|
* its slot, if that slot is on screen. An item that only scrolled out of a virtualized list has left
|
|
* the DOM too, but its slot is off screen then, so the reader's viewport (`firstVisible`) wins. */
|
|
function slotOfRemoved(delta: 1 | -1): { list: NavList; index: number } | null {
|
|
if (!last || last.el.isConnected || lists.get(last.list.el) !== last.list) return null;
|
|
const n = last.list.count();
|
|
const index = Math.min(Math.max(0, delta > 0 ? last.index : last.index - 1), n - 1);
|
|
const el = index >= 0 ? itemAt(last.list, index) : null;
|
|
if (!el) return null;
|
|
const box = scrollParent(last.list.el)?.getBoundingClientRect();
|
|
const r = el.getBoundingClientRect();
|
|
const band = (box?.top ?? 0) + (parseFloat(getComputedStyle(el).scrollMarginTop) || 0);
|
|
return r.bottom > band && r.top < (box?.bottom ?? window.innerHeight)
|
|
? { list: last.list, index }
|
|
: null;
|
|
}
|
|
|
|
/** What an item is called: its own name when it gives one (`navItemAttrs`), else the open element's
|
|
* label or text — a title link's text is the title. */
|
|
function itemName(el: HTMLElement): string {
|
|
const open = el.querySelector("[data-nav-open]");
|
|
return (
|
|
el.getAttribute("data-nav-name") ||
|
|
open?.getAttribute("aria-label") ||
|
|
open?.textContent ||
|
|
""
|
|
).trim();
|
|
}
|
|
|
|
function focusItem(el: HTMLElement, reveal = true): void {
|
|
if (roved !== el) {
|
|
if (named) roved?.removeAttribute("role");
|
|
if (named) roved?.removeAttribute("aria-label");
|
|
roved?.removeAttribute("tabindex");
|
|
roved = null;
|
|
named = false;
|
|
// An item that is a Tab stop in its own right (a native <button> tile, a role=button card with
|
|
// tabIndex 0) keeps it: a -1 here would take it out of the Tab order.
|
|
if (!el.hasAttribute("tabindex") && el.tabIndex < 0) {
|
|
el.setAttribute("tabindex", "-1");
|
|
roved = el;
|
|
// A card root is a plain <div>: focused, it has no role and no name, and a screen reader read it
|
|
// as an unlabelled run of text. While it holds the focus it is a group named after what Enter
|
|
// opens. A row (<tr>) keeps its own role.
|
|
const name = itemName(el);
|
|
if (
|
|
el.tagName === "DIV" &&
|
|
name &&
|
|
!el.hasAttribute("role") &&
|
|
!el.hasAttribute("aria-label")
|
|
) {
|
|
el.setAttribute("role", "group");
|
|
el.setAttribute("aria-label", name);
|
|
named = true;
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
if (reveal) el.scrollIntoView?.({ block: "nearest" });
|
|
}
|
|
|
|
function focusIndex(
|
|
list: NavList,
|
|
index: number,
|
|
land: (el: HTMLElement) => void = (el) => focusItem(el),
|
|
): void {
|
|
const el = itemAt(list, index);
|
|
if (el) return land(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) land(got);
|
|
else if (++frames < REVEAL_FRAMES) requestAnimationFrame(wait);
|
|
};
|
|
requestAnimationFrame(wait);
|
|
}
|
|
|
|
/** Hand the focus to item `index` of the list registered inside `within` — or, with `pick`, to the
|
|
* control `pick` finds in it (the row's own "…") — bringing a virtualized item into the DOM first
|
|
* and scrolling it just far enough to be seen. For a caller that MOVED an item and follows it: a
|
|
* channel re-sorted by the priority its menu set, whose node may have been re-inserted (the focus
|
|
* then fell to <body>) or scrolled off. */
|
|
export function focusNavItem(
|
|
within: HTMLElement,
|
|
index: number,
|
|
pick?: (item: HTMLElement) => HTMLElement | null,
|
|
): void {
|
|
const list = [...lists.values()].find((l) => within.contains(l.el));
|
|
if (!list) return;
|
|
focusIndex(list, index, (item) => {
|
|
const to = pick?.(item);
|
|
if (!to) return focusItem(item);
|
|
to.focus({ preventScroll: true });
|
|
item.scrollIntoView?.({ block: "nearest" });
|
|
});
|
|
}
|
|
|
|
function step(delta: 1 | -1): void {
|
|
const cur = current();
|
|
if (!cur) {
|
|
const start = slotOfRemoved(delta) ?? 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 from the focused item. ←→ step one item inside its list (wrapping rows, the reading order);
|
|
* ↑↓ one row, landing on the last item when the row below is a short last row, and from the top or
|
|
* bottom row on into the neighbouring list. 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;
|
|
else if (dy) crossRow(cur.list, cur.item, dy);
|
|
if (target !== cur.index && target >= 0 && target < count) focusIndex(cur.list, target);
|
|
return true;
|
|
}
|
|
|
|
/** ↓ past a list's bottom row / ↑ past its top row: the nearest row of the next non-empty list that
|
|
* way, on the item horizontally nearest the one being left. Only MOUNTED items are candidates — a
|
|
* virtualized neighbour offers the rows it has rendered, which next to the edge you cross from are
|
|
* the rows nearest it. At the page's first or last list it stays put, like `j`/`k`. */
|
|
function crossRow(from: NavList, item: HTMLElement, dy: -1 | 1): void {
|
|
const all = ordered();
|
|
const x = centerX(item);
|
|
for (let at = all.indexOf(from) + dy; at >= 0 && at < all.length; at += dy) {
|
|
const items = ownItems(all[at]!.el);
|
|
if (!items.length) continue;
|
|
const tops = items.map(topOf);
|
|
const edge = dy > 0 ? Math.min(...tops) : Math.max(...tops);
|
|
const row = items.filter((_, i) => Math.abs(tops[i]! - edge) <= ROW_SLACK);
|
|
const nearest = row.reduce((a, b) =>
|
|
Math.abs(centerX(b) - x) < Math.abs(centerX(a) - x) ? b : a,
|
|
);
|
|
return focusItem(nearest);
|
|
}
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent): void {
|
|
// A key a widget inside the item already handled is not ours (a keyboard drag's arrows): `dispatch`
|
|
// skips every such key, for every scope.
|
|
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();
|
|
},
|
|
});
|
|
dispatch(e, "listDelete", {
|
|
// On the item root, like Enter — and only in a list that registered a remover.
|
|
remove: (ev) => {
|
|
const cur = current();
|
|
const remove = cur?.list.remove();
|
|
if (!cur || !remove || document.activeElement !== cur.item) return;
|
|
ev.preventDefault();
|
|
remove(cur.index);
|
|
refocusSlot(cur.list, cur.index, cur.item);
|
|
},
|
|
});
|
|
}
|
|
|
|
// How long a removal gets to take its row out of the DOM before the focus gives up following it.
|
|
const REMOVE_FRAMES = 30;
|
|
|
|
/** After the focused item was removed, focus the item now in its slot (the next one; the new last
|
|
* when it was the last), so a second Delete takes the next row — the way a mail list behaves. Only
|
|
* while nothing else took the focus meanwhile: the removal fell back to <body>, nothing more. */
|
|
function refocusSlot(list: NavList, index: number, removed: HTMLElement): void {
|
|
let frames = 0;
|
|
const wait = () => {
|
|
if (removed.isConnected) {
|
|
if (++frames < REMOVE_FRAMES) requestAnimationFrame(wait);
|
|
return;
|
|
}
|
|
if (!focusIsUnclaimed()) return;
|
|
const n = list.count();
|
|
if (n > 0) focusIndex(list, Math.min(index, n - 1));
|
|
};
|
|
requestAnimationFrame(wait);
|
|
}
|
|
|
|
// ── The item a view was left from ────────────────────────────────────────────────────────────────
|
|
// Opening something that switches the page or the view — a Plex title from Home's rail, a channel from
|
|
// the Channels table — unmounts the list the keyboard was on, so Back used to hand focus to <body> and
|
|
// the next `j` started over at the top (seen in UAT). The history entry being left remembers the
|
|
// position (lib/history `rememberNavPosition`) and Back gives focus back to that item once its list is
|
|
// on screen again. An overlay (the YouTube player) never unmounts the list; it restores focus itself.
|
|
|
|
/** Where focus sits in the page's lists: the list's place in reading order, how many lists the page
|
|
* had on screen, the list's accessible name when it has one (a rail's title), the item's index, and
|
|
* the item's own name. */
|
|
export interface NavPosition {
|
|
readonly list: number;
|
|
readonly lists: number;
|
|
readonly label?: string | null;
|
|
readonly index: number;
|
|
readonly name?: string | null;
|
|
}
|
|
|
|
const labelOf = (list: NavList): string | null => list.el.getAttribute("aria-label");
|
|
|
|
export function navPosition(): NavPosition | null {
|
|
const cur = current();
|
|
if (!cur) return null;
|
|
const all = ordered();
|
|
const list = all.indexOf(cur.list);
|
|
return list < 0
|
|
? null
|
|
: {
|
|
list,
|
|
lists: all.length,
|
|
label: labelOf(cur.list),
|
|
index: cur.index,
|
|
name: itemName(cur.item) || null,
|
|
};
|
|
}
|
|
|
|
// How long a returning page gets to put its lists back (a lazy chunk, cached data), and how often we
|
|
// look — on a timer, not animation frames: a hidden tab runs none.
|
|
const RESTORE_MS = 2000;
|
|
const RESTORE_POLL_MS = 50;
|
|
|
|
/** The item in `list` to hand the focus back to: the saved place while it still holds the saved
|
|
* name, else the nearest item of that name (names repeat — a log console's identical request
|
|
* lines), else the saved place. */
|
|
function restoreTarget(list: NavList, pos: NavPosition): HTMLElement | null {
|
|
const at = itemAt(list, pos.index);
|
|
if (!pos.name || (at && itemName(at) === pos.name)) return at;
|
|
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
|
|
const named = ownItems(list.el)
|
|
.filter((it) => itemName(it) === pos.name)
|
|
.sort((a, b) => distance(a) - distance(b));
|
|
return named[0] ?? at;
|
|
}
|
|
|
|
/** Focus the item at `pos` once its list is on screen again. A NAMED list (a rail) is found by its
|
|
* name: a rail that appeared above it in the meantime — "Continue watching", filled by the very title
|
|
* watched in between, after the page's cache expired — shifts every place after it, and matching by
|
|
* place landed the focus in the wrong rail. An unnamed list only by its
|
|
* place, and only once the page has exactly as many lists as when it was left: with more or fewer,
|
|
* list N may be another list, and <body> is the honest outcome. Gives up when the time is up, and at
|
|
* once when focus is already somewhere (the reader moved on, a dialog took it). The item is focused
|
|
* where the scroll restore put it, never scrolled to. (A stand-in the focus keeper placed when the view
|
|
* it held was swapped away counts as untouched, like <body>.) Returns a cancel. */
|
|
export function restoreNavPosition(pos: NavPosition): () => void {
|
|
const deadline = Date.now() + RESTORE_MS;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const attempt = () => {
|
|
if (!focusIsUnclaimed()) return;
|
|
const all = ordered();
|
|
const list = pos.label
|
|
? all.find((l) => labelOf(l) === pos.label)
|
|
: all.length === pos.lists
|
|
? all[pos.list]
|
|
: undefined;
|
|
// The item by its NAME first, its place second: a rail's contents can shift while the page was
|
|
// away — Home's Continue rail leaves out whatever the hero shows, so a different hero moved every
|
|
// card after it one place and the focus landed on the neighbour (prod UAT 2026-09-19).
|
|
// The saved place still holding the saved name wins, and among several items of that name the
|
|
// nearest one: names repeat (a log console's `http.request GET /api/feed 200` lines, two
|
|
// downloads of one video), and the first of them is rarely the one left behind.
|
|
const el = list ? restoreTarget(list, pos) : null;
|
|
if (el) return focusItem(el, false);
|
|
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
|
|
};
|
|
// Not inside the popstate dispatch that landed: a sub-view's own listener (`useHistorySubview`)
|
|
// may run after ours and has not switched the view yet — focus still sits in the view being left,
|
|
// and "focus is already somewhere" would give up for good (measured in Chrome:
|
|
// Plex show → Back left focus on <body>). One task later the view has rendered.
|
|
timer = setTimeout(attempt, 0);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
|
|
/** Register `ref` as a keyboard-navigable list while `enabled`. A virtualized list must say how many
|
|
* items it has (`count` — the loaded ones, for an infinite list), how many per row (`cols`) and how
|
|
* to scroll an unmounted item into view (`reveal`). A list that mounts everything omits all three:
|
|
* the count and the columns are then measured from the DOM at the moment a key is pressed. */
|
|
export function useListNav(
|
|
ref: RefObject<HTMLElement | null>,
|
|
opts: {
|
|
enabled: boolean;
|
|
count?: number;
|
|
cols?: number;
|
|
reveal?: (index: number) => void;
|
|
/** A list whose items the Delete key takes out (a playlist's rows): the item's index. */
|
|
onDelete?: (index: number) => void;
|
|
},
|
|
): void {
|
|
// Read when a key is pressed, so the registration below never re-runs for a new `opts` object.
|
|
const latestOpts = useEffectEvent(() => opts);
|
|
// The `list` keys do something only while there is an item to land on. An empty queue, a list still
|
|
// loading or one that failed mounts its container all the same, and the cheat sheet listed j/k, the
|
|
// arrows and Enter over nothing (keymap: a scope is pushed only while its keys really do something).
|
|
const [hasItems, setHasItems] = useState(false);
|
|
useKeyScope("list", opts.enabled && hasItems);
|
|
useKeyScope("listDelete", opts.enabled && hasItems && !!opts.onDelete);
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!opts.enabled || !el) return;
|
|
lists.set(el, {
|
|
el,
|
|
count: () => latestOpts().count ?? ownItems(el).length,
|
|
cols: () => latestOpts().cols ?? measuredCols(el),
|
|
reveal: (i) => latestOpts().reveal?.(i),
|
|
remove: () => latestOpts().onDelete ?? null,
|
|
});
|
|
el.setAttribute(LIST, "");
|
|
const check = () => setHasItems(el.querySelector(`[${INDEX}]`) !== null);
|
|
const watch = new MutationObserver(check);
|
|
watch.observe(el, { childList: true, subtree: true });
|
|
check();
|
|
if (lists.size === 1) {
|
|
window.addEventListener("keydown", onKey);
|
|
document.addEventListener("focusin", onFocusIn);
|
|
}
|
|
return () => {
|
|
watch.disconnect();
|
|
lists.delete(el);
|
|
el.removeAttribute(LIST);
|
|
if (lists.size === 0) {
|
|
window.removeEventListener("keydown", onKey);
|
|
document.removeEventListener("focusin", onFocusIn);
|
|
}
|
|
};
|
|
}, [ref, opts.enabled]);
|
|
}
|