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:
@@ -30,7 +30,7 @@ import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import { isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
@@ -275,11 +275,9 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
|
||||
// membership below.
|
||||
//
|
||||
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also
|
||||
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible
|
||||
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep
|
||||
// were finished, which is the false-completeness that let a demo refusal sit unconverted for a
|
||||
// whole round. It is on ROADMAP.md instead (round 7).
|
||||
// It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
|
||||
// joined in E11 S2, so all four (`ui/Modal`, the wizard, this, the player) now share
|
||||
// `useDialogChrome` and its one counted scroll lock.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialogChrome(panelRef);
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
||||
@@ -295,14 +293,14 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
// landing page grows (deep-review round 7).
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!isTopmost(id)) return;
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
|
||||
const panel = panelRef.current;
|
||||
if (e.key === "Tab" && panel) trapTab(e, panel, panel);
|
||||
if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user