import { useEffect, useRef, type RefObject } from "react"; import { useLayer } from "./layerStack"; /** While `active`, close (call `onClose`) on Escape or a mousedown outside ALL of the given * element(s). The reusable half of the popover/dropdown pattern that several components each * hand-rolled; positioning stays with the caller (it varies per popover). Pass the panel ref * (and optionally a trigger ref so clicking the trigger doesn't immediately re-close). * * Escape is dispatched through the shared `layerStack` (R8 S3): the popover registers as a layer and * closes only when it is topmost, so a Modal or another popover opened above it takes the Escape * first, and a player below defers to it. This replaces the old blanket `stopPropagation` — which * swallowed a player's own Escape (e.g. PlexPlayer's auto-skip-cancel) whenever any popover was open. */ export function useDismiss( active: boolean, onClose: () => void, refs: RefObject | RefObject[], ): void { // Registration + topmost-only Escape. `trapsTab: false`, because this hook installs NO Tab trap: // it owns Escape and the outside-press, nothing more. Claiming the trap made a dialog underneath // stand its own down (`anyBlockingLayerAbove`) for a layer that traps nothing, so Tab walked out // of the dialog while one of its own menus was open — measured in a real browser on the Plex // player, whose settings and track menus are both dismissables (deep-review round 4). Every // consumer renders its panel INSIDE the surface below it, so that surface's trap already spans // the panel; a portaled one would need its own trap and must not use this default. useLayer(active, onClose, { trapsTab: false }); // Outside-press stays per-instance (the refs/positioning are the caller's): close on a mousedown // that lands outside every registered element. // Both arguments are read through refs so the listener can stay subscribed once per open/close — // which is the point of the `[active]` dep list — without going STALE. The disabled dep list this // replaces asserted that onClose was "read fresh from this run's closure"; it was not. The closure // is built when `active` last flipped, so a caller that re-rendered with a different handler while // the popover stayed open kept getting the old one called (regression test in useDismiss.dom.test). // `refs` is the weaker case — the ref OBJECTS are stable, so a late-mounting node is seen either // way — but a caller that changes WHICH refs it passes while open was equally ignored. const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const refsRef = useRef(refs); refsRef.current = refs; useEffect(() => { if (!active) return; const onDown = (e: MouseEvent) => { const current = refsRef.current; const list = Array.isArray(current) ? current : [current]; const target = e.target as Node; if (!list.some((r) => r.current?.contains(target))) onCloseRef.current(); }; document.addEventListener("mousedown", onDown); return () => { document.removeEventListener("mousedown", onDown); }; }, [active]); }