// One dismissal stack for the whole app. // // Each overlay in v4 wired its own outside-click and Escape handler, so Escape // inside a modal with an open autocomplete closed the modal -- every listener // fired, and none of them knew about the others. Here there is a single pair of // document listeners over a stack, and Escape pops the TOP entry only. const stack = []; let listening = false; function onKeyDown(event) { if (event.key !== "Escape" || !stack.length) return; const top = stack[stack.length - 1]; if (!top.closeOnEsc) return; // stopPropagation matters: without it a parent's own key handler also runs // and closes a second layer on one press. event.stopPropagation(); event.preventDefault(); top.dismiss("escape"); } function onPointerDown(event) { if (!stack.length) return; const top = stack[stack.length - 1]; if (!top.closeOnOutside) return; if (event.target.closest && event.target.closest(".cx-toasts")) return; // a toast belongs to no overlay for (const node of top.nodes) { // Guarded: an anchor can be a VIRTUAL point (a context menu is anchored to // coordinates, not an element), and those have no .contains. Letting one // through threw on the first click anywhere in the app. if (node && typeof node.contains === "function" && node.contains(event.target)) return; } top.dismiss("outside"); } function listen() { if (listening) return; // Capture phase, so an overlay that stops propagation on its own content // cannot accidentally suppress dismissal of something above it. document.addEventListener("keydown", onKeyDown, true); document.addEventListener("pointerdown", onPointerDown, true); listening = true; } function unlisten() { if (!listening) return; document.removeEventListener("keydown", onKeyDown, true); document.removeEventListener("pointerdown", onPointerDown, true); listening = false; } /** * push({ nodes, onDismiss, closeOnEsc, closeOnOutside }) -> handle * * `nodes` are the regions that count as "inside" -- a popover plus the control * that opened it, so clicking the trigger again does not dismiss-then-reopen. */ export function push({ nodes = [], onDismiss, closeOnEsc = true, closeOnOutside = true } = {}) { const entry = { // Only real nodes can answer "was the click inside me". nodes: [].concat(nodes).filter((n) => n && typeof n.contains === "function"), closeOnEsc, closeOnOutside, done: false, dismiss(reason) { if (entry.done) return; entry.done = true; remove(entry); if (onDismiss) onDismiss(reason); }, }; stack.push(entry); listen(); return { dismiss: (reason = "manual") => entry.dismiss(reason), // Closing without running onDismiss: the caller is already tearing down. release() { entry.done = true; remove(entry); }, get depth() { return stack.indexOf(entry) + 1; }, get isTop() { return stack[stack.length - 1] === entry; }, }; } function remove(entry) { const i = stack.indexOf(entry); if (i !== -1) stack.splice(i, 1); if (!stack.length) unlisten(); } export const depth = () => stack.length; export function _resetDismiss() { stack.length = 0; unlisten(); }