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:
+51
-10
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user