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
+10 -1
View File
@@ -34,6 +34,7 @@ import {
type Placement,
type UseRoleProps,
} from "@floating-ui/react";
import { useLayer } from "../lib/layerStack";
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
@@ -136,7 +137,11 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
handleClose: safePolygon(),
});
const focus = useFocus(context, { enabled: isHover });
const dismiss = useDismiss(context);
// Escape for an interactive (non-hover) popover is dispatched through the shared `layerStack`
// (below) so it coordinates with Modals and players, not just other floating nodes — so turn
// floating-ui's own Escape off there. Outside-press stays with floating-ui (tree-aware). Hover
// tooltips keep floating-ui's Escape and are NOT layers (they mustn't make a player defer).
const dismiss = useDismiss(context, { escapeKey: isHover });
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
const listNav = useListNavigation(context, {
enabled: isNav,
@@ -157,6 +162,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
// Register the open interactive popover as a dismissable layer (topmost-only Escape, and so a
// player/modal underneath defers to it). Hover tooltips are excluded on purpose.
useLayer(opts.open && !isHover, () => opts.onOpenChange(false));
return {
nodeId,
mode,
+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;
};
}, []);
+17 -4
View File
@@ -27,7 +27,7 @@ import {
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { modalCount } from "./Modal";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
import DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "../lib/youtube";
@@ -490,12 +490,28 @@ export default function PlayerModal({
staleTime: 5 * 60_000,
});
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (e.g. the
// download dialog, or — after S4 — an anchored menu) takes Escape first and this player defers.
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
// maximise or close the player underneath it.
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
if (fullscreenElement()) return;
// Same for our own maximise: Esc steps back out of it before it closes the player.
@@ -504,9 +520,6 @@ export default function PlayerModal({
setMaximized(false);
return;
}
// Defer to any Modal opened above the player (e.g. the download dialog): its Escape closes
// it, ours would otherwise also fire and close the player underneath it (U-C).
if (modalCount() > 0) return;
onClose();
return;
}
+17 -3
View File
@@ -46,7 +46,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
import { modalCount } from "./Modal";
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
@@ -808,6 +808,18 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
handleBack();
}, [handleBack]);
// This player is a dismissable layer: register it so a Modal or popover opened ABOVE it (its own
// settings/tracks menus included) takes Escape first and this player defers (R8 S3).
const layerIdRef = useRef<number | null>(null);
useEffect(() => {
const id = pushLayer();
layerIdRef.current = id;
return () => {
removeLayer(id);
layerIdRef.current = null;
};
}, []);
// --- keyboard --------------------------------------------------------------------------------
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -874,8 +886,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
else handleBack();
break;
case "Escape":
// Defer to any Modal opened above the player — let its Escape close it, not ours (U-C).
if (modalCount() > 0) break;
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
else if (menuOpenRef.current) {
setMenuOpen(false);