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(); // 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(`[${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(`[${INDEX}]`); const list = item && lists.get(item.parentElement?.closest(`[${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(`[${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 ) 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?.(`[${INDEX}]`); const list = item && lists.get(item.parentElement?.closest(`[${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 , 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