feat(overlay): shared layer-stack for topmost-only Escape across modals, popovers, players (R8 S3)

Replace the three mutually-unaware Escape systems — Modal's private modalStack,
the popovers' document listeners with a blanket stopPropagation, and the two
players' modalCount() checks — with one shared lib/layerStack.ts. Every open
dismissable layer registers in open order; a layer acts on Escape only when it
is topmost, and reads anyLayerAbove() to decide whether to defer. Coordination
is now a stack lookup, not event-propagation order, so it no longer depends on
which listener happens to fire first.

- Modal + useDismiss register via the stack; useDismiss drops the blanket
  stopPropagation (its documented side effect: it swallowed a player's own
  Escape while any popover was open).
- Both players register as a layer and defer via anyLayerAbove() instead of
  modalCount(); PlayerModal checks it before its maximise/fullscreen steps.
  A player MENU is a layer too, so it closes first and the next Escape reaches
  the auto-skip-cancel — fixing the documented PlexPlayer bug.
- AnchoredPopover routes non-hover Escape through the stack (floating-ui's own
  escapeKey off there) and registers as a layer, so a player/modal underneath
  defers once S4 migrates the in-player menus. FloatingTree still owns
  popover-vs-popover ordering; the stack adds the cross-type ordering.
This commit is contained in:
2026-07-30 23:33:50 +02:00
parent d950867d26
commit 7ed98956cc
6 changed files with 142 additions and 45 deletions
+8 -17
View File
@@ -3,20 +3,12 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
// Stack of open modals so ESC only closes the topmost one — e.g. an error dialog over the
// tag editor: pressing ESC dismisses just the error and returns to the editor underneath.
let modalStack: number[] = [];
let nextModalId = 1;
/** How many <Modal>s are currently open. The players (which use their own window-level Escape
* listeners, not this stack) check it so their Escape defers to a Modal opened above them —
* otherwise one Escape closes the player UNDER the dialog and then the dialog too (U-C). */
export function modalCount(): number {
return modalStack.length;
}
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close.
// Small centered modal shell (portaled to <body>): backdrop + ESC + scroll-lock close. Each open
// modal is a layer in the shared `layerStack` so ESC only closes the topmost one — e.g. an error
// dialog over the tag editor dismisses just the error and returns to the editor underneath, and a
// player below defers to it (R8 S3).
export default function Modal({
title,
onClose,
@@ -41,10 +33,9 @@ export default function Modal({
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
const id = nextModalId++;
modalStack.push(id);
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && modalStack[modalStack.length - 1] === id) {
if (e.key === "Escape" && isTopmost(id)) {
e.stopPropagation();
onCloseRef.current();
}
@@ -54,7 +45,7 @@ export default function Modal({
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
modalStack = modalStack.filter((x) => x !== id);
removeLayer(id);
document.body.style.overflow = prev;
};
}, []);