diff --git a/static/js/compare/index.js b/static/js/compare/index.js
index c8b4d8f3a..88b23e36b 100644
--- a/static/js/compare/index.js
+++ b/static/js/compare/index.js
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
import themeModule from '../theme.js';
import presetsModule from '../presets.js';
import markdownModule from '../markdown.js';
+import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
}
let _exportMenuEl = null;
+let _closeExportMenu = () => {};
function _toggleExportMenu(btn) {
if (_exportMenuEl) { _closeExportMenu(); return; }
const r = btn.getBoundingClientRect();
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
}
document.body.appendChild(m);
_exportMenuEl = m;
- setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
-}
-function _closeExportMenu() {
- if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
+ _closeExportMenu = bindMenuDismiss(m, () => {
+ if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
+ }, (ev) => !m.contains(ev.target));
}
async function _exportCopyMarkdown(_btn) {
diff --git a/static/js/cookbook.js b/static/js/cookbook.js
index 43a3ad5d0..967557184 100644
--- a/static/js/cookbook.js
+++ b/static/js/cookbook.js
@@ -33,6 +33,8 @@ import {
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
} from './cookbookServe.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
+
const STORAGE_KEY = 'cookbook-presets';
const LAST_STATE_KEY = 'cookbook-last-state';
const SERVE_STATE_KEY = 'cookbook-serve-state';
@@ -1514,7 +1516,7 @@ async function _fetchDependencies() {
// Wire the installed-package menu.
function _showDepMenu(anchor) {
- document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
+ document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
const row = anchor.closest('.cookbook-dep-row');
if (!row) return;
const pipName = row.dataset.depPip;
@@ -1535,7 +1537,7 @@ async function _fetchDependencies() {
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => {
e.stopPropagation();
- dropdown.remove();
+ close();
await _installDep(pipName, pkgName, isLocalOnly, true, null);
});
dropdown.appendChild(it);
@@ -1563,19 +1565,14 @@ async function _fetchDependencies() {
dropdown.appendChild(source);
}
document.body.appendChild(dropdown);
- const close = (ev) => {
- if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
- dropdown.remove();
- document.removeEventListener('click', close, true);
- }
- };
- setTimeout(() => document.addEventListener('click', close, true), 10);
+ const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
+ !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
}
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (document.querySelector('.cookbook-dep-menu')) {
- document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
+ document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
return;
}
_showDepMenu(btn);
diff --git a/static/js/document.js b/static/js/document.js
index f2e61f559..d4f67a8d9 100644
--- a/static/js/document.js
+++ b/static/js/document.js
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
import signatureModule from './signature.js';
import * as Modals from './modalManager.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let API_BASE = '';
let isOpen = false;
@@ -3331,7 +3332,10 @@ import * as Modals from './modalManager.js';
let _docAiReplyChoiceMenu = null;
function _closeDocAiReplyChoice() {
if (_docAiReplyChoiceMenu) {
- try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
+ // Tear down through the menu's registered dismiss (drops its outside-click
+ // listener + Escape-stack entry) rather than orphaning them with a raw
+ // remove(); the onClose below nulls the ref.
+ try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
_docAiReplyChoiceMenu = null;
}
}
@@ -3382,6 +3386,14 @@ import * as Modals from './modalManager.js';
const noteInput = menu.querySelector('[data-note-input]');
setTimeout(() => noteInput?.focus(), 0);
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
+ document.body.appendChild(menu);
+ _docAiReplyChoiceMenu = menu;
+ // Outside-click AND Escape both route through the central esc-stack via
+ // bindMenuDismiss; onClose owns the actual teardown (node removal + state).
+ const close = bindMenuDismiss(menu, () => {
+ try { menu.remove(); } catch (_) {}
+ if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
+ });
menu.addEventListener('click', async (ev) => {
const choice = ev.target.closest('[data-mode]');
if (!choice) return;
@@ -3389,26 +3401,9 @@ import * as Modals from './modalManager.js';
ev.stopPropagation();
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
const noteHint = (noteInput?.value || '').trim();
- _closeDocAiReplyChoice();
+ close();
await _aiReply({ mode, noteHint });
});
- document.body.appendChild(menu);
- _docAiReplyChoiceMenu = menu;
- const outsideClose = (ev) => {
- if (menu.contains(ev.target)) return;
- document.removeEventListener('click', outsideClose, true);
- _closeDocAiReplyChoice();
- };
- setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
- // Esc to close.
- const escClose = (ev) => {
- if (ev.key === 'Escape') {
- ev.stopPropagation();
- document.removeEventListener('keydown', escClose, true);
- _closeDocAiReplyChoice();
- }
- };
- document.addEventListener('keydown', escClose, true);
}
async function _aiReply(opts = {}) {
@@ -8591,9 +8586,10 @@ import * as Modals from './modalManager.js';
function showExportMenu(e, anchorRect) {
if (e) e.stopPropagation();
- // Remove existing menu if any
+ // Remove existing menu if any (toggle off) — tear it down through its
+ // registered dismiss so the outside-click listener + Escape-stack entry go.
const existing = document.getElementById('doc-export-menu');
- if (existing) { existing.remove(); return; }
+ if (existing) { dismissOrRemove(existing); return; }
// Position from provided rect, clicked element, or fallback to language select
const rect = anchorRect
@@ -8643,7 +8639,7 @@ import * as Modals from './modalManager.js';
const item = document.createElement('button');
item.className = 'doc-overflow-item';
item.textContent = opt.label;
- item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
+ item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
menu.appendChild(item);
if (opt._divider) {
const sep = document.createElement('div');
@@ -8661,21 +8657,9 @@ import * as Modals from './modalManager.js';
menu.style.top = 'auto';
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
}
- const close = (ev) => {
- if (ev && ev.type === 'keydown') {
- if (ev.key !== 'Escape') return;
- ev.preventDefault();
- ev.stopPropagation();
- ev.stopImmediatePropagation?.();
- } else if (ev && menu.contains(ev.target)) {
- return;
- }
- menu.remove();
- document.removeEventListener('click', close);
- document.removeEventListener('keydown', close, true);
- };
- setTimeout(() => document.addEventListener('click', close), 100);
- document.addEventListener('keydown', close, true);
+ // Outside-click AND Escape both route through the central esc-stack via
+ // bindMenuDismiss; onClose owns the actual node removal.
+ const close = bindMenuDismiss(menu, () => { menu.remove(); });
}
function exportAsHtml() {
diff --git a/static/js/emailInbox.js b/static/js/emailInbox.js
index 1b4d67a4e..8155df7ab 100644
--- a/static/js/emailInbox.js
+++ b/static/js/emailInbox.js
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
const _acct = () => window.__odysseusActiveEmailAccount
@@ -915,7 +916,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
}
function _showEmailMenu(em, anchor, itemEl) {
- document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
+ document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'dropdown email-dropdown show';
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
_showRemindSubmenu(em, dropdown);
return;
}
- dropdown.remove();
+ close();
a.action();
});
dropdown.appendChild(menuItem);
@@ -946,13 +947,7 @@ function _showEmailMenu(em, anchor, itemEl) {
anchor.appendChild(dropdown);
- const close = (e) => {
- if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
- dropdown.remove();
- document.removeEventListener('click', close, true);
- }
- };
- setTimeout(() => document.addEventListener('click', close, true), 10);
+ const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
}
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
@@ -987,7 +982,7 @@ function _showRemindSubmenu(em, parentDropdown) {
item.innerHTML = `${p.label}${p.sub}`;
item.addEventListener('click', async (e) => {
e.stopPropagation();
- parentDropdown.remove();
+ dismissOrRemove(parentDropdown);
await _createReplyReminder(em, p.date);
});
parentDropdown.appendChild(item);
@@ -997,7 +992,7 @@ function _showRemindSubmenu(em, parentDropdown) {
customItem.innerHTML = 'Pick date and time…';
customItem.addEventListener('click', async (e) => {
e.stopPropagation();
- parentDropdown.remove();
+ dismissOrRemove(parentDropdown);
const tmp = document.createElement('input');
tmp.type = 'datetime-local';
const def = new Date(tomorrow);
diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js
index c38573e22..516bc932a 100644
--- a/static/js/emailLibrary.js
+++ b/static/js/emailLibrary.js
@@ -23,6 +23,7 @@ import {
} from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js';
import { collapseSidebarToRail } from './modalSnap.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
@@ -5499,16 +5500,12 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
// Toggle: if a dropdown for THIS anchor is already open, close it.
const existing = document.querySelector('.email-card-dropdown');
if (existing && existing._anchor === anchor) {
- existing.remove();
- anchor.classList.remove('reader-more-active');
+ dismissOrRemove(existing);
return;
}
- // Otherwise close any other open dropdown (and clear its anchor's active
- // state) before opening a fresh one.
- document.querySelectorAll('.email-card-dropdown').forEach(d => {
- if (d._anchor) d._anchor.classList.remove('reader-more-active');
- d.remove();
- });
+ // Otherwise close any other open dropdown (its own teardown clears its
+ // anchor's active state) before opening a fresh one.
+ document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
@@ -5721,8 +5718,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
+ close();
a.action();
});
dropdown.appendChild(item);
@@ -5735,25 +5731,20 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
+ close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
- const close = (ev) => {
- if (!dropdown.contains(ev.target) && ev.target !== anchor) {
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
- document.removeEventListener('click', close, true);
- }
- };
- setTimeout(() => document.addEventListener('click', close, true), 10);
+ const close = bindMenuDismiss(dropdown, () => {
+ dropdown.remove();
+ anchor.classList.remove('reader-more-active');
+ }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _showCardMenu(em, anchor) {
- document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
+ document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown';
@@ -5918,8 +5909,7 @@ function _showCardMenu(em, anchor) {
_showLibRemindSubmenu(em, dropdown);
return;
}
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
+ close();
a.action();
});
dropdown.appendChild(item);
@@ -5932,26 +5922,21 @@ function _showCardMenu(em, anchor) {
cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel';
cancelItem.addEventListener('click', (e) => {
e.stopPropagation();
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
+ close();
});
dropdown.appendChild(cancelItem);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
- const close = (ev) => {
- if (!dropdown.contains(ev.target) && ev.target !== anchor) {
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
- document.removeEventListener('click', close, true);
- }
- };
- setTimeout(() => document.addEventListener('click', close, true), 10);
+ const close = bindMenuDismiss(dropdown, () => {
+ dropdown.remove();
+ anchor.classList.remove('reader-more-active');
+ }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
function _showBulkActionsMenu(anchor) {
- document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
+ document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
const dropdown = document.createElement('div');
dropdown.className = 'email-card-dropdown email-bulk-menu';
const rect = anchor.getBoundingClientRect();
@@ -5968,7 +5953,7 @@ function _showBulkActionsMenu(anchor) {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `${a.icon}${a.label}`;
- it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
+ it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
@@ -5978,7 +5963,7 @@ function _showBulkActionsMenu(anchor) {
cancelIt.innerHTML = `${_cancelIco2}Cancel`;
cancelIt.addEventListener('click', (e) => {
e.stopPropagation();
- dropdown.remove();
+ close();
// Cancel inside the bulk-Actions menu also exits select mode — matches the
// documents bulk dropdown.
state._selectMode = false;
@@ -5989,13 +5974,9 @@ function _showBulkActionsMenu(anchor) {
dropdown.appendChild(cancelIt);
document.body.appendChild(dropdown);
_fitEmailDropdown(dropdown, rect);
- const close = (ev) => {
- if (!dropdown.contains(ev.target) && ev.target !== anchor) {
- dropdown.remove();
- document.removeEventListener('click', close, true);
- }
- };
- setTimeout(() => document.addEventListener('click', close, true), 10);
+ const close = bindMenuDismiss(dropdown, () => {
+ dropdown.remove();
+ }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
function _updateBulkBar() {
diff --git a/static/js/gallery.js b/static/js/gallery.js
index 1d35f6504..40a66a7ee 100644
--- a/static/js/gallery.js
+++ b/static/js/gallery.js
@@ -6,6 +6,7 @@ import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -2514,7 +2515,7 @@ export function openGallery() {
// shares the exact same dropdown style/behaviour.
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
function _showGalleryBulkMenu(anchor) {
- document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
+ document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
// matches every other menu in the app. Positioned fixed at the button.
const dropdown = document.createElement('div');
@@ -2548,17 +2549,11 @@ export function openGallery() {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
it.innerHTML = `${a.icon}${a.label}`;
- it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
+ it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
document.body.appendChild(dropdown);
- const close = (ev) => {
- if (!dropdown.contains(ev.target) && ev.target !== anchor) {
- dropdown.remove();
- document.removeEventListener('click', close, true);
- }
- };
- setTimeout(() => document.addEventListener('click', close, true), 10);
+ const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
}
_bulkActionsBtn?.addEventListener('click', (e) => {
@@ -2567,7 +2562,7 @@ export function openGallery() {
// should close it. The outside-click handler explicitly skips clicks on
// the anchor, so the button itself has to do its own dismiss.
const existing = document.querySelector('.gallery-bulk-menu');
- if (existing) { existing.remove(); return; }
+ if (existing) { dismissOrRemove(existing); return; }
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
_showGalleryBulkMenu(e.currentTarget);
});
diff --git a/static/js/notes.js b/static/js/notes.js
index e7e3a7ded..f19e350c4 100644
--- a/static/js/notes.js
+++ b/static/js/notes.js
@@ -11,6 +11,7 @@ import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
import { topToolWindowZ } from './toolWindowZOrder.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -3360,7 +3361,7 @@ function _buildForm(note = null) {
function _pickCustomDate() {
// Replace the dropdown menu with a small inline picker
- document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
+ document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
const menu = document.createElement('div');
menu.className = 'note-reminder-menu';
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
@@ -3394,14 +3395,11 @@ function _buildForm(note = null) {
if (typeof dInput.showPicker === 'function') {
try { dInput.showPicker(); } catch {}
}
+ const close = bindMenuDismiss(menu, () => { menu.remove(); });
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
if (dInput.value) _setReminder(dInput.value);
- menu.remove();
+ close();
});
- setTimeout(() => {
- const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
- document.addEventListener('click', close);
- }, 0);
}
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
@@ -4311,7 +4309,7 @@ function _serializeNoteForCopy(note) {
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
function _openNoteCornerMenu(btn) {
- document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
+ document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
const id = btn.dataset.noteId;
const note = _notes.find(n => n.id === id);
if (!note) return;
@@ -4338,14 +4336,9 @@ function _openNoteCornerMenu(btn) {
const below = window.innerHeight - r.bottom;
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
- const close = (ev) => {
- if (ev && menu.contains(ev.target)) return;
- menu.remove();
- document.removeEventListener('click', close, true);
- };
- setTimeout(() => document.addEventListener('click', close, true), 0);
- menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
- menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
+ const close = bindMenuDismiss(menu, () => { menu.remove(); });
+ menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
+ menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
}
function _positionNoteMenu(menu, btn, width = 196) {
diff --git a/static/js/settings.js b/static/js/settings.js
index 1dbe7f6d7..8dbf1258e 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
+import { bindMenuDismiss } from './escMenuStack.js';
let initialized = false;
let modalEl = null;
@@ -3838,7 +3839,10 @@ async function initUnifiedIntegrations() {
if (lbl) lbl.textContent = text;
if (ico) ico.innerHTML = _apiIconFor(k);
};
- const _close = () => { menu.style.display = 'none'; };
+ // Menu is reused (hidden, not recreated). close() hides it and tears down
+ // its outside-click listener + Escape-stack entry; bindMenuDismiss is
+ // re-registered fresh on each open (see _open).
+ let _close = () => { menu.style.display = 'none'; };
const _open = () => {
menu.style.display = 'block';
const tRect = trig.getBoundingClientRect();
@@ -3847,8 +3851,7 @@ async function initUnifiedIntegrations() {
const above = tRect.top;
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
- const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
- setTimeout(() => document.addEventListener('click', onDoc, true), 0);
+ _close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
};
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
menu.querySelectorAll('.ufapi-option').forEach(btn => {
@@ -4584,7 +4587,10 @@ async function initUnifiedIntegrations() {
if (labelEl) labelEl.textContent = lbl;
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
};
- const _closeMenu = () => { menu.style.display = 'none'; };
+ // Menu is reused (hidden, not recreated). _closeMenu hides it and tears
+ // down its outside-click listener + Escape-stack entry; bindMenuDismiss is
+ // re-registered fresh on each open (see _openMenu).
+ let _closeMenu = () => { menu.style.display = 'none'; };
const _openMenu = () => {
menu.style.display = 'block';
// Drop-up when there's not enough room below the trigger.
@@ -4597,8 +4603,7 @@ async function initUnifiedIntegrations() {
} else {
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
}
- const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
- setTimeout(() => document.addEventListener('click', onDoc, true), 0);
+ _closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
};
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
menu.querySelectorAll('.ufp-option').forEach(btn => {
@@ -5650,8 +5655,11 @@ async function initUnifiedIntegrations() {
addBtn.parentElement.style.position = 'relative';
addBtn.parentElement.classList.add('uf-add-anchor');
}
+ // Menu is created per open and removed on close. _closeMenu routes through
+ // the bindMenuDismiss close() bound when the menu opens, so the outside-click
+ // listener + Escape-stack entry are torn down alongside the node removal.
let _menuEl = null;
- const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
+ let _closeMenu = () => {};
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (_menuEl) { _closeMenu(); return; }
@@ -5683,8 +5691,7 @@ async function initUnifiedIntegrations() {
showForm(k, 'new');
});
});
- const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
- setTimeout(() => document.addEventListener('click', onDoc, true), 0);
+ _closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
});
}
diff --git a/static/js/skills.js b/static/js/skills.js
index 44f7082b6..d60c933a2 100644
--- a/static/js/skills.js
+++ b/static/js/skills.js
@@ -7,6 +7,7 @@
import uiModule from './ui.js';
import * as spinnerModule from './spinner.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API = window.location.origin;
let skills = [];
@@ -391,14 +392,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
// Kebab dropdown for a collapsed skill card — same actions + icons as the
// expanded footer (Publish/Unpublish · Edit · Delete).
function _openSkillMenu(btn, card, sk, name, isPublished) {
- document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
+ document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
const menu = document.createElement('div');
menu.className = 'skill-kebab-menu';
const mk = (paths, label, opts, onClick) => {
const item = document.createElement('button');
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
item.innerHTML = _svg(paths, opts) + `${label}`;
- item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
+ item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
menu.appendChild(item);
};
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
@@ -410,7 +411,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
selItem.innerHTML = 'Select';
selItem.addEventListener('click', (e) => {
e.stopPropagation();
- menu.remove();
+ close();
if (!_selectMode) _enterSelectMode();
_selectedNames.add(name);
renderSkillsList();
@@ -432,7 +433,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
const cancelItem = document.createElement('button');
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
cancelItem.innerHTML = 'Cancel';
- cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
+ cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
menu.appendChild(cancelItem);
document.body.appendChild(menu);
@@ -453,8 +454,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
menu.style.overflowY = 'auto';
}
- const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
- setTimeout(() => document.addEventListener('click', close, true), 0);
+ const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
}
// Cards for the agent's built-in tool capabilities (from
diff --git a/static/js/tasks.js b/static/js/tasks.js
index 2914389b9..e44d13034 100644
--- a/static/js/tasks.js
+++ b/static/js/tasks.js
@@ -8,6 +8,7 @@ import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
+import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -899,7 +900,7 @@ function _attachTaskLongPress(card, menuBtn) {
function _showTaskDropdown(anchor, items) {
// Remove any existing dropdown
- document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
+ document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
const dd = document.createElement('div');
dd.className = 'task-dropdown';
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
@@ -914,7 +915,7 @@ function _showTaskDropdown(anchor, items) {
}
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
- btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
+ btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
dd.appendChild(btn);
});
document.body.appendChild(dd);
@@ -926,16 +927,13 @@ function _showTaskDropdown(anchor, items) {
dd.style.top = top + 'px';
dd.style.left = left + 'px';
const openedAt = performance.now();
- const close = (e) => {
+ const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
// Ignore any clicks that occur within 250ms of the open (covers touch
// "ghost click" duplicates that were firing right after pointerup and
- // removing the dropdown before the user could see it).
- if (performance.now() - openedAt < 250) return;
- if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
- };
- // requestAnimationFrame so the listener is registered AFTER the current
- // pointer/click event cycle has finished bubbling.
- requestAnimationFrame(() => document.addEventListener('click', close));
+ // removing the dropdown before the user could see it) — treat as inside.
+ if (performance.now() - openedAt < 250) return false;
+ return !dd.contains(ev.target);
+ });
}
// ---- Presets ----