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; export function pushLayer(): number { const id = nextId++; stack.push(id); return id; } export function removeLayer(id: number): void { stack = stack.filter((x) => x !== 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; } // 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, ): { isTopmost: () => boolean; anyAbove: () => boolean } { const idRef = useRef(null); const escRef = useRef(onEscape); escRef.current = onEscape; useEffect(() => { if (!active) return; const id = pushLayer(); 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), }; }