The mask over a stale read lifted when the write settled, before the refetch landed, and the band took the rows back. A band holding only what asks by its kind (an access-requests nudge) left the button enabled with nothing to do. The unread dot was only drawn: the spoken label now says it, apart from the name Back finds the row by, and follows the row while it holds the focus.
606 lines
28 KiB
TypeScript
606 lines
28 KiB
TypeScript
import { useEffect, useEffectEvent, useState, type RefObject } from "react";
|
|
import { dispatch, setListInTopLayerProbe, useKeyScope } from "./keymap";
|
|
import { anyBlockingLayer, topBlockingLayerRoot } from "./layerStack";
|
|
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;
|
|
// A named item's label follows its spoken state while it holds the focus: Mark read hands the
|
|
// focus to the row BEFORE the row reads read, and the label given then said "unread" until the
|
|
// keyboard moved on.
|
|
const relabel =
|
|
typeof MutationObserver === "undefined"
|
|
? null
|
|
: new MutationObserver(() => {
|
|
if (named && roved) roved.setAttribute("aria-label", spokenName(roved));
|
|
});
|
|
|
|
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.
|
|
*
|
|
* `label` is what is SPOKEN instead of `name`, when it carries a state the item can lose while the
|
|
* page is away (a notification's "unread"): `name` is also how Back finds the item again, and a
|
|
* name that changed with the state would miss it. */
|
|
export const navItemAttrs = (
|
|
index: number,
|
|
name?: string | null,
|
|
{ inset = false, label }: { inset?: boolean; label?: string } = {},
|
|
): {
|
|
"data-nav-index": number;
|
|
"data-nav-name"?: string;
|
|
"data-nav-label"?: string;
|
|
"data-nav-inset"?: "";
|
|
} => ({
|
|
[INDEX]: index,
|
|
...(name ? { "data-nav-name": name } : null),
|
|
...(label ? { "data-nav-label": label } : 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[] {
|
|
// Inside an open layer (the player) the walk stays in it: `k` on its first row must not land on
|
|
// the feed behind the scrim.
|
|
const layer = anyBlockingLayer() ? topBlockingLayerRoot() : null;
|
|
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)
|
|
.filter((l) => !layer || layer.contains(l.el))
|
|
.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;
|
|
}
|
|
|
|
/** True while the focus is on an item of a keyboard-walked list (or a control inside one): its arrows
|
|
* move between the items, so a surface that also binds them (the player's seek) leaves them alone. */
|
|
export const focusInNavList = (): boolean => current() !== null;
|
|
|
|
// The help sheet lists the list keys over a layer only when this says there is a list to walk in it.
|
|
setListInTopLayerProbe(() => {
|
|
const layer = topBlockingLayerRoot();
|
|
return !!layer && [...lists.keys()].some((el) => layer.contains(el));
|
|
});
|
|
|
|
/** 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();
|
|
}
|
|
|
|
/** What a screen reader hears while the item holds the focus: its `label` when it gives one. */
|
|
const spokenName = (el: HTMLElement): string => el.getAttribute("data-nav-label") || itemName(el);
|
|
|
|
function focusItem(el: HTMLElement, reveal = true): void {
|
|
if (roved !== el) {
|
|
if (named) roved?.removeAttribute("role");
|
|
if (named) roved?.removeAttribute("aria-label");
|
|
roved?.removeAttribute("tabindex");
|
|
relabel?.disconnect();
|
|
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 = spokenName(el);
|
|
if (
|
|
el.tagName === "DIV" &&
|
|
name &&
|
|
!el.hasAttribute("role") &&
|
|
!el.hasAttribute("aria-label")
|
|
) {
|
|
el.setAttribute("role", "group");
|
|
el.setAttribute("aria-label", name);
|
|
named = true;
|
|
relabel?.observe(el, { attributeFilter: ["data-nav-label", "data-nav-name"] });
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
};
|
|
// The list keys act on the page, so an open layer silences them — unless the focus is in a list
|
|
// INSIDE the topmost layer: that list is what the user is in (the player's related videos). Or was,
|
|
// a moment ago: a row marked from its menu leaves the list and the focus falls to <body>, and `j`
|
|
// goes on from its slot (`slotOfRemoved`) as it does on the page.
|
|
const layer = topBlockingLayerRoot();
|
|
const cur = current();
|
|
const inTopLayer =
|
|
!!layer &&
|
|
(cur
|
|
? layer.contains(cur.item)
|
|
: focusIsUnclaimed(document.activeElement) && !!last && layer.contains(last.list.el));
|
|
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();
|
|
},
|
|
},
|
|
{ inTopLayer },
|
|
);
|
|
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);
|
|
},
|
|
},
|
|
{ inTopLayer },
|
|
);
|
|
}
|
|
|
|
// 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 to hand the focus back to: in `list`, 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). Then the one item of that name in another of the page's lists: an item can change lists
|
|
* while the page was away (a notification asked for the reader on the visit it was opened from and
|
|
* sits in the stream on the next), and the place left behind is now its neighbour's. Only when the
|
|
* name is the page's alone — of two, either could be the one. Else the saved place. */
|
|
function restoreTarget(
|
|
all: readonly NavList[],
|
|
list: NavList | undefined,
|
|
pos: NavPosition,
|
|
): HTMLElement | null {
|
|
const at = list ? itemAt(list, pos.index) : null;
|
|
if (list && (!pos.name || (at && itemName(at) === pos.name))) return at;
|
|
if (!pos.name) return null;
|
|
const named = (l: NavList) => ownItems(l.el).filter((it) => itemName(it) === pos.name);
|
|
if (list) {
|
|
const distance = (el: HTMLElement) => Math.abs(indexOf(el) - pos.index);
|
|
const here = named(list).sort((a, b) => distance(a) - distance(b));
|
|
if (here[0]) return here[0];
|
|
}
|
|
const [only, ...more] = all.flatMap(named);
|
|
return only && more.length === 0 ? only : 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 — unless the item's name is the
|
|
* page's alone (`restoreTarget`). 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 — but one found in another list is brought into
|
|
* view. (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 = restoreTarget(all, list, pos);
|
|
// Where the scroll restore put it — unless it is found in another list than the one it was left
|
|
// in: the restored scroll is the old place's, and the item would hold the focus off screen.
|
|
if (el) return focusItem(el, !list?.el.contains(el));
|
|
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]);
|
|
}
|