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,