Files
siftlode/frontend/src/lib/listNav.ts
T
peter fa271a31b9 fix(notifications): Mark all read waits for the fresh list, is not offered where it cannot act, and a row says it is unread
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.
2026-10-29 00:38:34 +01:00

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]);
}