fix(keys): epic-end review round 1 — Back, menus and dialogs keep the focus where it belongs

Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped).

- Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from
  even when focus was still inside the view: the first attempt now waits one task, past the
  sub-view's own popstate listener (measured in Chrome: it left focus on <body>).
- The restore listener is installed at load, so the first Back after a reload finds it; a push
  cancels a restore still waiting for the last landing's lists.
- Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex
  player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped
  the navigation.
- j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer
  leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from
  its slot when that slot is on screen.
- An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search).
- A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard,
  Lightbox): new layerStack `anyBlockingLayerAbove`.
- "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct`
  that ActionMenu now uses too.
- Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change;
  stale comments about PlayerModal's scroll lock and dialog contract.
This commit is contained in:
2026-09-13 17:17:49 +02:00
parent bbd31c7143
commit f130a1c05a
22 changed files with 439 additions and 64 deletions
+51 -10
View File
@@ -120,13 +120,44 @@ function firstVisible(): { list: NavList; index: number } | null {
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 item = (e.target as HTMLElement | null)?.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 };
}
/** 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 && roved !== el) roved.removeAttribute("tabindex");
roved = null;
// An item that is focusable in its own right (a Plex card is a role=button) keeps its tabindex.
if (!el.hasAttribute("tabindex")) {
el.setAttribute("tabindex", "-1");
roved = el;
if (roved !== el) {
roved?.removeAttribute("tabindex");
roved = null;
// 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;
}
}
el.focus({ preventScroll: true });
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
@@ -151,7 +182,7 @@ function focusIndex(list: NavList, index: number): void {
function step(delta: 1 | -1): void {
const cur = current();
if (!cur) {
const start = firstVisible();
const start = slotOfRemoved(delta) ?? firstVisible();
if (start) focusIndex(start.list, start.index);
return;
}
@@ -284,7 +315,11 @@ export function restoreNavPosition(pos: NavPosition): () => void {
if (el) return focusItem(el, false);
if (Date.now() < deadline) timer = setTimeout(attempt, RESTORE_POLL_MS);
};
attempt();
// 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);
}
@@ -309,11 +344,17 @@ export function useListNav(
reveal: (i) => optsRef.current.reveal?.(i),
});
el.setAttribute(LIST, "");
if (lists.size === 1) window.addEventListener("keydown", onKey);
if (lists.size === 1) {
window.addEventListener("keydown", onKey);
document.addEventListener("focusin", onFocusIn);
}
return () => {
lists.delete(el);
el.removeAttribute(LIST);
if (lists.size === 0) window.removeEventListener("keydown", onKey);
if (lists.size === 0) {
window.removeEventListener("keydown", onKey);
document.removeEventListener("focusin", onFocusIn);
}
};
}, [ref, opts.enabled]);
}