import { useEffect, useEffectEvent, 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`: 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. 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 an effect event 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 onPress = useEffectEvent((e: MouseEvent) => { const list = Array.isArray(refs) ? refs : [refs]; const target = e.target as Node; if (!list.some((r) => r.current?.contains(target))) onClose(); }); useEffect(() => { if (!active) return; const onDown = (e: MouseEvent) => onPress(e); document.addEventListener("mousedown", onDown); return () => { document.removeEventListener("mousedown", onDown); }; }, [active]); }