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 ; 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(); 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(); 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(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), }; }