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
+29 -40
View File
@@ -8,6 +8,7 @@ import uiModule from './ui.js';
import spinnerModule from './spinner.js';
import { providerLogo } from './providers.js';
import { modelColor } from './chatRenderer.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// Shared state/functions injected by init()
let _envState;
@@ -193,18 +194,19 @@ function _rerenderCachedModels() {
list.querySelectorAll('.hwfit-cached-menu-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle: if a dropdown for THIS button is already open, close it.
// Toggle: if a dropdown for THIS button is already open, close it
// (through its own dismiss so the Escape-stack entry goes with it).
const existing = document.querySelector('.hwfit-cached-dropdown');
if (existing && existing._anchor === btn) {
existing.remove();
btn.classList.remove('cookbook-menu-active');
if (typeof existing._dismiss === 'function') existing._dismiss();
else { existing.remove(); btn.classList.remove('cookbook-menu-active'); }
return;
}
// Otherwise close any other open menu (and clear its anchor's active
// state) before opening fresh.
document.querySelectorAll('.hwfit-cached-dropdown').forEach(d => {
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active');
d.remove();
if (typeof d._dismiss === 'function') d._dismiss(); else d.remove();
});
const item = btn.closest('.memory-item');
const repo = item?.dataset.repo;
@@ -215,6 +217,9 @@ function _rerenderCachedModels() {
dropdown.className = 'hwfit-cached-dropdown';
dropdown._anchor = btn;
btn.classList.add('cookbook-menu-active');
// Shared close — used by every item, the mobile Cancel, outside-click,
// and the Escape arbiter (reassigned to the registry-aware close below).
let closeDropdown = () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); };
const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _serveIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
const _retryIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
@@ -230,8 +235,7 @@ function _rerenderCachedModels() {
div.className = 'dropdown-item-compact' + (opt.danger ? ' dropdown-item-danger' : '');
div.innerHTML = _di(opt.icon) + '<span>' + opt.label + '</span>';
div.addEventListener('click', () => {
dropdown.remove();
btn.classList.remove('cookbook-menu-active');
closeDropdown();
if (opt.action === 'serve') item.click();
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
else if (opt.action === 'retry') _retryCachedModel(repo, m);
@@ -264,10 +268,7 @@ function _rerenderCachedModels() {
const cancelDiv = document.createElement('div');
cancelDiv.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelDiv.innerHTML = _di(_cancelIco) + '<span>Cancel</span>';
cancelDiv.addEventListener('click', () => {
dropdown.remove();
btn.classList.remove('cookbook-menu-active');
});
cancelDiv.addEventListener('click', () => { closeDropdown(); });
dropdown.appendChild(cancelDiv);
const rect = btn.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
@@ -290,8 +291,7 @@ function _rerenderCachedModels() {
dropdown.style.top = top + 'px';
dropdown.style.visibility = '';
}
const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== btn) { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); document.removeEventListener('click', close, true); } };
setTimeout(() => document.addEventListener('click', close, true), 0);
closeDropdown = bindMenuDismiss(dropdown, () => { dropdown.remove(); btn.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== btn);
});
});
@@ -666,10 +666,11 @@ function _rerenderCachedModels() {
// reflects the stored presets. Standard Odysseus .dropdown look, positioned
// fixed at the toggle and right-aligned to it.
function _showSavedConfigMenu(anchor) {
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
document.querySelectorAll('.cookbook-saved-menu').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
const modelSlots = _presetsForModel(_loadPresets(), repo);
const dropdown = document.createElement('div');
dropdown.className = 'dropdown cookbook-saved-menu';
let closeMenu = () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); };
const rect = anchor.getBoundingClientRect();
const minW = 190;
// Cap width/height to the viewport and start hidden — we clamp the final
@@ -710,7 +711,7 @@ function _rerenderCachedModels() {
if (e.target === del) return;
e.stopPropagation();
// Close the menu FIRST so it always dismisses, even if loading throws.
dropdown.remove();
closeMenu();
_loadSlotIntoPanel(idx);
// Confirm the click landed — loading is silent otherwise, so it was
// unclear the settings actually changed.
@@ -751,14 +752,7 @@ function _rerenderCachedModels() {
dropdown.style.left = `${left}px`;
dropdown.style.top = `${top}px`;
dropdown.style.visibility = '';
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
dropdown.remove();
anchor.classList.remove('cookbook-menu-active');
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
closeMenu = bindMenuDismiss(dropdown, () => { dropdown.remove(); anchor.classList.remove('cookbook-menu-active'); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
}
// "Save" segment — save the current config directly.
@@ -766,7 +760,7 @@ function _rerenderCachedModels() {
if (savedSaveBtn) {
savedSaveBtn.addEventListener('click', async (e) => {
e.stopPropagation();
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
document.querySelectorAll('.cookbook-saved-menu').forEach(dismissOrRemove);
await _saveCurrentConfig();
});
}
@@ -775,9 +769,10 @@ function _rerenderCachedModels() {
if (savedArrowBtn) {
savedArrowBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (document.querySelector('.cookbook-saved-menu')) {
document.querySelectorAll('.cookbook-saved-menu').forEach(d => d.remove());
savedArrowBtn.classList.remove('cookbook-menu-active');
const openSaved = document.querySelector('.cookbook-saved-menu');
if (openSaved) {
if (typeof openSaved._dismiss === 'function') openSaved._dismiss();
else { openSaved.remove(); savedArrowBtn.classList.remove('cookbook-menu-active'); }
return;
}
savedArrowBtn.classList.add('cookbook-menu-active');
@@ -822,9 +817,10 @@ function _rerenderCachedModels() {
if (_splitArrow) {
_splitArrow.addEventListener('click', (ev) => {
ev.stopPropagation();
document.querySelectorAll('.cookbook-gpu-split-menu').forEach(m => m.remove());
document.querySelectorAll('.cookbook-gpu-split-menu').forEach(m => { if (typeof m._dismiss === 'function') m._dismiss(); else m.remove(); });
const menu = document.createElement('div');
menu.className = 'cookbook-task-dropdown cookbook-gpu-split-menu';
let closeMenu = () => menu.remove();
const mk = (label, cls, onClick) => {
const it = document.createElement('div');
it.className = 'dropdown-item-compact' + (cls ? ' ' + cls : '');
@@ -832,7 +828,7 @@ function _rerenderCachedModels() {
it.textContent = label;
it.addEventListener('click', (e) => {
e.stopPropagation();
menu.remove();
closeMenu();
if (onClick) onClick();
});
return it;
@@ -859,18 +855,11 @@ function _rerenderCachedModels() {
}
menu.style.top = top + 'px';
}
const close = (e) => {
if (!menu.contains(e.target) && e.target !== _splitArrow) {
menu.remove();
document.removeEventListener('click', close);
window.removeEventListener('scroll', _scrollClose, true);
}
};
const _scrollClose = () => { menu.remove(); document.removeEventListener('click', close); window.removeEventListener('scroll', _scrollClose, true); };
setTimeout(() => {
document.addEventListener('click', close);
window.addEventListener('scroll', _scrollClose, true);
}, 0);
// Close on outside click or Escape (via the registry); also dismiss
// on scroll since the popup is fixed-positioned to the arrow.
const _scrollClose = () => closeMenu();
closeMenu = bindMenuDismiss(menu, () => { menu.remove(); window.removeEventListener('scroll', _scrollClose, true); }, (e) => !menu.contains(e.target) && e.target !== _splitArrow);
window.addEventListener('scroll', _scrollClose, true);
});
}
const _withSpinner = async (btn, fn) => {