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
@@ -5,7 +5,7 @@
// stamped onto the entry being left (`rememberNavPosition`) and a popstate landing on it focuses the item
// once the page's lists are back. A landing is simulated the way the browser delivers one: the entry's
// state is current and a popstate carries it. Its own file: history.ts and listNav keep module state.
import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { rememberNavPosition } from "./history";
@@ -92,6 +92,44 @@ describe("rememberNavPosition · Back focuses the item a view was left from", ()
await waitFor(() => expect(focused()).toBe("a1"));
});
// A sub-view (a Plex show page) switches back in its OWN popstate listener, registered after this
// module's: while ours runs, focus still sits in the view being left. Measured in Chrome (deep-review
// E11 round 1) — Discover card → show → `j` on the show page → Back left focus on <body>.
it("restores when a later popstate listener switches the view the focus was still in", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
function Views() {
const [view, setView] = useState<"sub" | "page">("sub");
useEffect(() => {
const onPop = () => setView("page");
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
return view === "page" ? <Page /> : <List id="s" count={2} />;
}
render(<Views />);
press("j");
expect(focused()).toBe("s0");
landOn(stamped);
await waitFor(() => expect(focused()).toBe("b1"));
});
// A restore still waiting for the landed page's lists must not outlive the next push: it would
// focus "list 1, item 1" of whatever page that push opened.
it("is cancelled by the next push", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);
unmount();
landOn(stamped);
render(<List id="b" count={3} />); // one list of the two: the restore keeps waiting
await sleep(120);
window.history.pushState({ ...rememberNavPosition(), view: "elsewhere" }, "");
render(<Page />); // enough lists now, on the page the push opened
await sleep(150);
expect(focused()).toBeUndefined();
});
it("leaves focus where it is when something already has it", async () => {
const { unmount } = render(<Page />);
const stamped = leaveFrom("b1", 4);