Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed. - VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was written for (req had already reported the error); it now always shows. The cut list holds still while Create queues, and a retry continues the "(i/N)" series. - ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on hover. - listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot. - AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus back); the dead panel-ref check is gone. - AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames the message for an unreachable server. Pager comment points at scrollParent.
423 lines
20 KiB
TypeScript
423 lines
20 KiB
TypeScript
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
import { dispatch, useKeyScope } from "./keymap";
|
|
import { scrollParent } from "./listPaging";
|
|
|
|
// ── 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").
|
|
// ←→ 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;
|
|
}
|
|
|
|
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 (deep-review E11 round 3). */
|
|
export const navItemAttrs = (
|
|
index: number,
|
|
name?: string | null,
|
|
): { "data-nav-index": number; "data-nav-name"?: string } => ({
|
|
[INDEX]: index,
|
|
...(name ? { "data-nav-name": name } : 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 responsive table renders its rows twice — a table and a card list, one
|
|
// of them display:none at any width (ui/DataTable) — and `j` must not walk into the hidden copy.
|
|
.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 };
|
|
// 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" (deep-review
|
|
// E11 round 2). 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;
|
|
}
|
|
|
|
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 (deep-review E11 round 2). A row (<tr>) keeps its own role.
|
|
// The item's own name when it gives one (`navItemAttrs`), else the open element's label or text:
|
|
// a title link's text is the title (deep-review E11 round 3).
|
|
const open = el.querySelector("[data-nav-open]");
|
|
const name = (
|
|
el.getAttribute("data-nav-name") ||
|
|
open?.getAttribute("aria-label") ||
|
|
open?.textContent ||
|
|
""
|
|
).trim();
|
|
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): 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 = 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: during a keyboard drag a playlist
|
|
// row's grip moves the row with the arrows (dnd-kit listens on the document, before this window
|
|
// listener, and prevents the default of every key it acts on).
|
|
if (e.defaultPrevented) return;
|
|
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();
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── 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 (E11 S5 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), and the item's index. */
|
|
export interface NavPosition {
|
|
readonly list: number;
|
|
readonly lists: number;
|
|
readonly label?: string | null;
|
|
readonly index: number;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
// 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;
|
|
|
|
/** 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 (deep-review E11 round 2). 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. Returns a cancel. */
|
|
export function restoreNavPosition(pos: NavPosition): () => void {
|
|
const deadline = Date.now() + RESTORE_MS;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const attempt = () => {
|
|
const active = document.activeElement;
|
|
if (active && active !== document.body) return;
|
|
const all = ordered();
|
|
const list = pos.label
|
|
? all.find((l) => labelOf(l) === pos.label)
|
|
: all.length === pos.lists
|
|
? all[pos.list]
|
|
: undefined;
|
|
const el = list ? itemAt(list, pos.index) : 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, deep-review E11
|
|
// round 1: 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 },
|
|
): void {
|
|
const optsRef = useRef(opts);
|
|
optsRef.current = 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);
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!opts.enabled || !el) return;
|
|
lists.set(el, {
|
|
el,
|
|
count: () => optsRef.current.count ?? ownItems(el).length,
|
|
cols: () => optsRef.current.cols ?? measuredCols(el),
|
|
reveal: (i) => optsRef.current.reveal?.(i),
|
|
});
|
|
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]);
|
|
}
|