fix: make transient dropdown/popup menus close on Escape

The global Escape arbiter in ui.js only sees `.modal` elements, so the many
ad-hoc dropdowns and context popups that are built on the fly and appended to
<body> ignored Escape entirely: document-library card/chat menus, chat
context/stats/overflow popups, cookbook serve & running menus, calendar event
menus, and compare pane menus.

Add a small DOM-free dismissal registry (static/js/escMenuStack.js). Menus
register a dismiss callback while open, and the arbiter closes the
most-recently-opened one first, so a menu opened over a modal closes before the
modal. bindMenuDismiss() wires the ubiquitous "append-to-body, close on outside
click" idiom to both the outside-click listener and the Escape stack in one
call, and dismissOrRemove() lets the pre-existing bulk removers (scroll/swipe/
modal-dismiss cleanup, reopen sweeps) tear a menu down through its real teardown
instead of orphaning its stack entry.

Covers ~14 menus across documentLibrary, chatRenderer, cookbookServe,
cookbookRunning, calendar, and compare/panes. Every teardown path — item click,
outside click, swipe, toggle, rebuild, bulk cleanup — routes through the
registry so no entry is ever stranded.

tests/test_esc_menu_stack_js.py pins the registry's LIFO and
exactly-one-per-press guarantees (node-driven; skips when node is absent).
This commit is contained in:
Collin Osborne
2026-06-01 14:23:22 -04:00
parent 70a71f603c
commit 471ee494f0
10 changed files with 373 additions and 155 deletions

View File

@@ -10,6 +10,7 @@ import spinnerModule from './spinner.js';
import markdownModule from './markdown.js';
import { makeWindowDraggable } from './windowDrag.js';
import { langIcon } from './langIcons.js';
import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// ── Injected references from documentModule ──
let API_BASE = '';
@@ -184,7 +185,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
function _showLibDropdown(anchor, items, opts) {
opts = opts || {};
document.querySelectorAll('._lib-dd').forEach(d => d.remove());
document.querySelectorAll('._lib-dd').forEach(dismissOrRemove);
const dd = document.createElement('div');
dd.className = 'dropdown session-dropdown-menu _lib-dd';
for (const item of items) {
@@ -193,7 +194,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const iconKey = item.icon || item.label.toLowerCase();
const iconSvg = _LIB_DD_ICONS[iconKey] || '';
row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>';
row.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); });
dd.appendChild(row);
}
if (typeof opts.onSelect === 'function') {
@@ -202,7 +203,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
sel.innerHTML =
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
+ '<span>Select</span>';
sel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); opts.onSelect(); });
sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); });
dd.appendChild(sel);
}
const cancel = document.createElement('div');
@@ -210,7 +211,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
cancel.innerHTML =
'<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>'
+ '<span>Cancel</span>';
cancel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
dd.appendChild(cancel);
document.body.appendChild(dd);
const rect = anchor.getBoundingClientRect();
@@ -225,8 +226,18 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
}
if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; }
});
const close = (e) => { if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } };
// Single idempotent teardown shared by every dismissal path (item click,
// outside click, swipe, and the Escape arbiter via registerMenuDismiss).
let _unreg = () => {};
const teardown = () => {
_unreg(); _unreg = () => {};
document.removeEventListener('click', close);
dd.remove();
};
const close = (e) => { if (!dd.contains(e.target)) teardown(); };
setTimeout(() => document.addEventListener('click', close), 0);
_unreg = registerMenuDismiss(teardown);
dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly
// Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the
// popup down and release past the threshold to close. Below threshold,
@@ -257,8 +268,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
dd.style.transform = 'translateY(120px)';
dd.style.opacity = '0';
setTimeout(() => dd.remove(), 160);
// Unregister + drop the outside-click listener now; defer the DOM
// removal so the slide-out animation can play.
_unreg(); _unreg = () => {};
document.removeEventListener('click', close);
setTimeout(() => dd.remove(), 160);
} else {
dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
dd.style.transform = '';
@@ -380,6 +394,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
function libraryRenderGrid() {
const grid = document.getElementById('doclib-grid');
if (!grid) return;
// An open card menu is mounted on <body> (to escape overflow clipping), so
// clearing the grid would orphan it; dismiss it first so its listener +
// Escape-stack entry go too.
document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove);
grid.innerHTML = '';
// Drop any previous inline load-more — regenerated below alongside the list.
if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
@@ -576,8 +594,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
if (dropdown) {
const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
if (isOpen) {
dropdown.style.display = 'none';
menuWrap.appendChild(dropdown);
hideCardDropdown();
} else {
// Position fixed on body to escape overflow clipping
const rect = menuBtn.getBoundingClientRect();
@@ -593,15 +610,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px';
if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; }
});
// Close on outside click
const close = (ev) => {
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) {
dropdown.style.display = 'none';
menuWrap.appendChild(dropdown);
document.removeEventListener('click', close, true);
}
// Close on outside click or Escape (the latter via the registry).
_cardDocClick = (ev) => {
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown();
};
setTimeout(() => document.addEventListener('click', close, true), 0);
setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0);
_cardUnreg = registerMenuDismiss(hideCardDropdown);
}
}
});
@@ -612,6 +626,21 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
dropdown.className = 'doclib-card-dropdown';
dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;';
// Single close path for the card action dropdown, shared by the toggle
// button, the outside-click listener, every menu item, and the Escape
// arbiter (via registerMenuDismiss). Hides the menu, returns it to its
// wrapper, drops the outside-click listener, and unregisters from the
// Escape stack. Idempotent — safe to call from whichever path fires first.
let _cardUnreg = () => {};
let _cardDocClick = null;
function hideCardDropdown() {
_cardUnreg(); _cardUnreg = () => {};
if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; }
dropdown.style.display = 'none';
if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown);
}
dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this
const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _openIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
@@ -621,7 +650,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
openItem.style.cssText = 'background:none;border:none;width:100%;';
openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
if (doc.session_id) {
openItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryOpenInSession(doc); });
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); });
} else {
openItem.disabled = true;
openItem.style.opacity = '0.35';
@@ -636,7 +665,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
cloneItem.style.cssText = 'background:none;border:none;width:100%;';
cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
cloneItem.title = 'Clone to active session';
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryImportDocument(doc); });
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); });
dropdown.appendChild(cloneItem);
// Export
@@ -647,7 +676,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
exportItem.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.style.display = 'none';
hideCardDropdown();
try {
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
if (!res.ok) throw new Error('Failed');
@@ -673,7 +702,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
archiveItem.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.style.display = 'none';
hideCardDropdown();
const toArchived = !_libraryArchivedView;
try {
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
@@ -693,7 +722,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
deleteItem.className = 'dropdown-item-compact dropdown-item-danger';
deleteItem.style.cssText = 'background:none;border:none;width:100%;';
deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>';
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryDeleteSingle(doc.id, card); });
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); });
dropdown.appendChild(deleteItem);
menuWrap.appendChild(dropdown);