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.
124 lines
6.0 KiB
TypeScript
124 lines
6.0 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
|
|
// R8 S3 — one shared registry of open "dismissable layers" (modals, popovers, players), ordered by
|
|
// open time. It replaces the three mutually-unaware Escape systems that each guessed at ordering with
|
|
// its own `stopPropagation`: Modal's private `modalStack`, the popovers' document listeners, and the
|
|
// two players' `modalCount()` checks. Now EVERY layer keeps its own key handler but coordinates
|
|
// through this one stack — a layer acts on Escape only when it is topmost, and reads `anyAbove()` to
|
|
// know whether to defer. Because the decision is a stack lookup, not event-propagation order, the fix
|
|
// is robust to which listener happens to run first (the old stopPropagation was not).
|
|
//
|
|
// Notably this fixes the documented PlexPlayer bug: its menus register here, so the player sees a menu
|
|
// as a layer ABOVE it and defers, letting the menu's own Escape close it first — the player's
|
|
// auto-skip-cancel Escape then lands on the next press, instead of being swallowed by a blanket
|
|
// stopPropagation. (Popover-vs-popover ordering is still floating-ui's <FloatingTree>; this stack adds
|
|
// the cross-TYPE ordering the tree can't see.)
|
|
|
|
let stack: number[] = [];
|
|
let nextId = 1;
|
|
// Layers that take Escape but do not own the keyboard: an open tooltip or hover card. They sit on the
|
|
// stack for Escape ordering like any other layer, but `anyBlockingLayer` looks past them — otherwise
|
|
// a tooltip that happens to be open under a resting mouse pointer would silence the page's `j`/`k`.
|
|
const passive = new Set<number>();
|
|
|
|
export function pushLayer(opts?: { passive?: boolean }): number {
|
|
const id = nextId++;
|
|
stack.push(id);
|
|
if (opts?.passive) passive.add(id);
|
|
return id;
|
|
}
|
|
|
|
export function removeLayer(id: number): void {
|
|
stack = stack.filter((x) => x !== id);
|
|
passive.delete(id);
|
|
}
|
|
|
|
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
|
|
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
|
|
* underneath, which the user cannot see or reach while such a layer is up. */
|
|
export function anyBlockingLayer(): boolean {
|
|
return stack.some((id) => !passive.has(id));
|
|
}
|
|
|
|
export function isTopmost(id: number): boolean {
|
|
return stack.length > 0 && stack[stack.length - 1] === id;
|
|
}
|
|
|
|
/** True when at least one layer sits above `id` — the "should I defer?" predicate for a layer with a
|
|
* richer key handler than a plain close (the players). */
|
|
export function anyLayerAbove(id: number): boolean {
|
|
const i = stack.indexOf(id);
|
|
return i !== -1 && i < stack.length - 1;
|
|
}
|
|
|
|
/** True when a layer that owns the keyboard sits above `id` — the predicate for a dialog's Tab trap.
|
|
* A tooltip or hover card open under a resting pointer takes Escape first, but not the Tab: deferring
|
|
* the trap to it let Shift+Tab walk out of the dialog into the page behind its scrim. */
|
|
export function anyBlockingLayerAbove(id: number): boolean {
|
|
const i = stack.indexOf(id);
|
|
return i !== -1 && stack.slice(i + 1).some((x) => !passive.has(x));
|
|
}
|
|
|
|
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
|
|
// (the players, listening on `window`) reads the stamp instead of re-reading the stack. This closes
|
|
// the "one Escape closes BOTH the popover and the player" bug: the popover's Escape listener is on
|
|
// `document` and fires before the player's on `window` (bubble order), and closing the popover flushes
|
|
// its layer off the stack synchronously — so by the time the player's `anyLayerAbove()` runs, the stack
|
|
// already reads "nothing above" and the player ALSO closes. Tagging the one shared KeyboardEvent object
|
|
// (every listener in a single dispatch is handed the same instance) is immune to that mid-dispatch
|
|
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
|
|
// WeakSet so a spent event is GC'd without a manual sweep.
|
|
const handledEscapes = new WeakSet<KeyboardEvent>();
|
|
export function markEscapeHandled(e: KeyboardEvent): void {
|
|
handledEscapes.add(e);
|
|
}
|
|
export function escapeHandled(e: KeyboardEvent): boolean {
|
|
return handledEscapes.has(e);
|
|
}
|
|
|
|
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
|
|
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
|
|
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
|
|
* to decide whether to defer (the players). */
|
|
export function useLayer(
|
|
active: boolean,
|
|
onEscape?: () => void,
|
|
opts?: { passive?: boolean },
|
|
): { isTopmost: () => boolean; anyAbove: () => boolean; blockingAbove: () => boolean } {
|
|
const idRef = useRef<number | null>(null);
|
|
const escRef = useRef(onEscape);
|
|
escRef.current = onEscape;
|
|
const passiveRef = useRef(!!opts?.passive);
|
|
passiveRef.current = !!opts?.passive;
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
const id = pushLayer({ passive: passiveRef.current });
|
|
idRef.current = id;
|
|
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
|
if (escRef.current) {
|
|
onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && isTopmost(id)) {
|
|
// Stamp BEFORE closing so a player's `window` listener (fires after this `document` one)
|
|
// sees the Escape as consumed even though closing us pops this layer off the stack.
|
|
markEscapeHandled(e);
|
|
escRef.current?.();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
}
|
|
return () => {
|
|
if (onKey) document.removeEventListener("keydown", onKey);
|
|
removeLayer(id);
|
|
idRef.current = null;
|
|
};
|
|
// Re-register only when open/closed toggles; onEscape is read fresh via escRef.
|
|
}, [active]);
|
|
|
|
return {
|
|
isTopmost: () => idRef.current != null && isTopmost(idRef.current),
|
|
anyAbove: () => idRef.current != null && anyLayerAbove(idRef.current),
|
|
blockingAbove: () => idRef.current != null && anyBlockingLayerAbove(idRef.current),
|
|
};
|
|
}
|