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

@@ -7,6 +7,7 @@ import spinnerModule from './spinner.js';
import * as Modals from './modalManager.js';
import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js';
import {
WEEKDAYS, MONTHS, MON_SHORT,
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
@@ -426,9 +427,10 @@ function _clampDropdown(dropdown, anchorRect) {
}
function _showEventMoreMenu(ev, anchor) {
document.querySelectorAll('.cal-event-dropdown').forEach(d => d.remove());
document.querySelectorAll('.cal-event-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
const dropdown = document.createElement('div');
dropdown.className = 'cal-event-dropdown';
let closeMenu = () => dropdown.remove();
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:0px;visibility:hidden;`;
@@ -443,12 +445,12 @@ function _showEventMoreMenu(ev, anchor) {
const _editIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
dropdown.appendChild(_item(_editIcon, 'Edit', () => {
dropdown.remove();
closeMenu();
_showEventForm(ev);
}));
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
dropdown.remove();
closeMenu();
const name = ev.summary ? `"${ev.summary}"` : 'this event';
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
@@ -459,14 +461,7 @@ function _showEventMoreMenu(ev, anchor) {
dropdown._anchorRect = rect;
_clampDropdown(dropdown, rect);
dropdown.style.visibility = '';
const close = (ev2) => {
if (!dropdown.contains(ev2.target) && ev2.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
}
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev2) => !dropdown.contains(ev2.target) && ev2.target !== anchor);}
async function _createEventReminder(ev, dueDate) {
// Store the reminder as an absolute UTC instant (with the Z suffix) so the

View File

@@ -7,6 +7,7 @@ import { addAITTSButton } from './tts-ai.js';
import { providerLogo } from './providers.js';
import settingsModule from './settings.js';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_ICON = '<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
@@ -568,7 +569,7 @@ export function applyModelColor(roleEl, modelName) {
roleEl.style.cursor = 'pointer';
roleEl.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const info = getModelInfo(modelName);
const short = shortModel(modelName);
const logoHtml = providerLogo(modelName);
@@ -626,10 +627,7 @@ export function applyModelColor(roleEl, modelName) {
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
const closePopup = (ev) => {
if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener('click', closePopup, true); }
};
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
bindMenuDismiss(popup, () => popup.remove());
});
}
}
@@ -1332,12 +1330,17 @@ export function createMsgFooter(msgElement) {
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle overflow menu — close any existing one first
// Toggle overflow menu — close any existing one first (through its own
// dismiss so the Escape registry entry goes with it).
const existing = document.querySelector('.msg-overflow-menu');
if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; }
if (existing) {
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (existing._trigger === moreBtn) return;
}
const menu = document.createElement('div');
menu.className = 'msg-overflow-menu';
let closeMenu = () => menu.remove();
overflow.forEach(a => {
const item = document.createElement('button');
item.className = 'msg-overflow-item';
@@ -1347,7 +1350,7 @@ export function createMsgFooter(msgElement) {
item.addEventListener('click', (ev) => {
ev.stopPropagation();
_trackAction(a.id);
menu.remove();
closeMenu();
a.handler(ev);
});
menu.appendChild(item);
@@ -1363,15 +1366,9 @@ export function createMsgFooter(msgElement) {
// Keep within right edge
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
// Close on outside click
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
menu.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
});
// Close on outside click or Escape. The trigger button is treated as
// "inside" so its own click toggles rather than double-fires.
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
actions.appendChild(moreBtn);
}
@@ -1392,9 +1389,14 @@ export function createMsgFooter(msgElement) {
pill.addEventListener('click', (e) => {
e.stopPropagation();
let detail = pill._openDetail || document.querySelector('.memory-used-detail');
if (detail) { detail.remove(); pill._openDetail = null; return; }
if (detail) {
if (typeof detail._dismiss === 'function') detail._dismiss();
else { detail.remove(); pill._openDetail = null; }
return;
}
detail = document.createElement('div');
detail.className = 'memory-used-detail';
let closeDetail = () => { detail.remove(); pill._openDetail = null; };
mems.forEach(m => {
const row = document.createElement('div');
row.className = 'memory-used-row';
@@ -1410,8 +1412,7 @@ export function createMsgFooter(msgElement) {
row.appendChild(text);
row.addEventListener('click', (ev) => {
ev.stopPropagation();
detail.remove();
pill._openDetail = null;
closeDetail();
const memModal = document.getElementById('memory-modal');
if (memModal) memModal.classList.remove('hidden');
});
@@ -1435,15 +1436,8 @@ export function createMsgFooter(msgElement) {
if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
detail.style.visibility = '';
pill._openDetail = detail;
const close = (ev) => {
if (!detail.contains(ev.target) && ev.target !== pill) {
detail.remove();
pill._openDetail = null;
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
});
// Close on outside click or Escape (pill click toggles, so it's inside).
closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
footer.appendChild(pill);
}
@@ -1528,10 +1522,14 @@ export function createUserMsgFooter(msgElement) {
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const existing = document.querySelector('.msg-overflow-menu');
if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; }
if (existing) {
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (existing._trigger === moreBtn) return;
}
const menu = document.createElement('div');
menu.className = 'msg-overflow-menu';
let closeMenu = () => menu.remove();
overflow.forEach(a => {
const item = document.createElement('button');
item.className = 'msg-overflow-item';
@@ -1541,7 +1539,7 @@ export function createUserMsgFooter(msgElement) {
item.addEventListener('click', (ev) => {
ev.stopPropagation();
_trackUserAction(a.id);
menu.remove();
closeMenu();
a.handler(ev);
});
menu.appendChild(item);
@@ -1554,14 +1552,7 @@ export function createUserMsgFooter(msgElement) {
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
menu.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
});
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
actions.appendChild(moreBtn);
}
@@ -1625,7 +1616,7 @@ export function displayMetrics(messageElement, metrics) {
metricsDivider.style.pointerEvents = 'none';
metricsContainer.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a';
const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
@@ -1685,13 +1676,7 @@ export function displayMetrics(messageElement, metrics) {
if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
popup.style.visibility = '';
const closePopup = (ev) => {
if (!popup.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closePopup, true);
}
};
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
bindMenuDismiss(popup, () => popup.remove());
});
// Store real context length for model info popup
@@ -1722,7 +1707,7 @@ export function displayMetrics(messageElement, metrics) {
ctxRing.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-detail-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const usedTokens = inputTokens || 0;
const totalCtx = ctxLen || 0;
@@ -1826,13 +1811,7 @@ export function displayMetrics(messageElement, metrics) {
}
popup.style.visibility = '';
const closePopup = (ev) => {
if (!popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closePopup, true);
}
};
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
});
}

View File

@@ -10,6 +10,7 @@ import { _clearProbeWaves } from './probe.js';
import Storage from '../storage.js';
import uiModule from '../ui.js';
import spinnerModule from '../spinner.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -282,10 +283,11 @@ async function _addPane(anchorBtn) {
// Toggle existing dropdown
const existing = document.querySelector('.add-pane-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const dropdown = document.createElement('div');
dropdown.className = 'add-pane-dropdown';
let closeMenu = () => dropdown.remove();
// Search input for large model lists
if (filtered.length >= 5) {
@@ -326,7 +328,7 @@ async function _addPane(anchorBtn) {
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
await _createAndAppendPane(m);
});
dropdown.appendChild(item);
@@ -371,15 +373,8 @@ async function _addPane(anchorBtn) {
dropdown.style.bottom = 'auto';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
/** Create a new pane for the given model and append it to the compare grid. */
async function _createAndAppendPane(m) {
@@ -551,7 +546,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
// Remove any existing dropdown
const existing = document.querySelector('.pane-model-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
@@ -559,6 +554,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
const dropdown = document.createElement('div');
dropdown.className = 'pane-model-dropdown';
let closeMenu = () => dropdown.remove();
filtered.forEach(m => {
const item = document.createElement('button');
@@ -573,7 +569,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
}
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
// Update the model for this pane and persist
state._selectedModels[paneIdx] = {
@@ -653,15 +649,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
dropdown.style.top = top + 'px';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
// ── Shuffle / reset ──

View File

@@ -6,6 +6,7 @@
import uiModule from './ui.js';
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
import { registerMenuDismiss } from './escMenuStack.js';
// Human-friendly badge label for a task's internal status. Avoids surfacing
// the word "error" in the sidebar — a server the user stopped or one that
@@ -1546,7 +1547,7 @@ export function _renderRunningTab() {
el.addEventListener('touchcancel', _lpCancel, { passive: true });
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.cookbook-task-dropdown').forEach(d => d.remove());
document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
const dropdown = document.createElement('div');
dropdown.className = 'cookbook-task-dropdown';
@@ -1696,7 +1697,7 @@ export function _renderRunningTab() {
const ic = _MENU_ICONS[item.action] || '';
div.innerHTML = `<span style="display:inline-flex;flex-shrink:0;opacity:0.7;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ic}</svg></span><span>${item.label}</span>`;
div.addEventListener('click', () => {
dropdown.remove();
_cleanup();
if (item.custom) { item.custom(); return; }
el.querySelector('.cookbook-task-action-' + item.action)?.click();
});
@@ -1736,17 +1737,21 @@ export function _renderRunningTab() {
// fixed position no longer matches the originating ⋮ button, so
// it visually drifts. Matches the email kebab behaviour.
const scrollClose = () => _cleanup();
let _unreg = () => {};
const _cleanup = () => {
_unreg(); _unreg = () => {};
dropdown.remove();
document.removeEventListener('click', closeHandler);
window.removeEventListener('scroll', scrollClose, true);
window.visualViewport?.removeEventListener('scroll', scrollClose);
};
dropdown._dismiss = _cleanup;
setTimeout(() => {
document.addEventListener('click', closeHandler);
window.addEventListener('scroll', scrollClose, true);
window.visualViewport?.addEventListener('scroll', scrollClose);
}, 0);
_unreg = registerMenuDismiss(_cleanup);
});
}

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);
// 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);
}, 0);
});
}
const _withSpinner = async (btn, fn) => {

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);

102
static/js/escMenuStack.js Normal file
View File

@@ -0,0 +1,102 @@
// static/js/escMenuStack.js
//
// Dismissal registry for transient, ad-hoc overlays — dropdown menus and
// context popups that are built on the fly and appended to <body>, living
// OUTSIDE the .modal system. The global Escape arbiter in ui.js can find
// modals but not these, so each menu registers a dismiss callback here while
// it is open and unregisters when it closes.
//
// The stack is LIFO: dismissTopMenu() closes the most-recently-opened menu
// first, so a dropdown opened on top of a modal closes before the modal does.
// Deliberately DOM-free so it can be unit-tested under plain node (see
// tests/test_esc_menu_stack_js.py).
const _stack = [];
/**
* Register a menu's dismiss callback. Returns an unregister function that the
* menu MUST call from its own teardown (outside-click close, item click, etc.)
* so the stack never holds a stale entry. Calling the returned function more
* than once, or after the menu was already dismissed via Escape, is safe.
*/
export function registerMenuDismiss(dismissFn) {
if (typeof dismissFn !== 'function') return () => {};
const entry = { dismissFn };
_stack.push(entry);
return () => {
const i = _stack.indexOf(entry);
if (i !== -1) _stack.splice(i, 1);
};
}
/**
* Dismiss the most-recently-registered menu, if any. Returns true when a menu
* was dismissed (so the caller can swallow the Escape key), false when nothing
* was open. The entry is popped BEFORE its callback runs, so even if a
* dismissFn forgets to unregister or throws, a single Escape closes exactly
* one menu and the stack never gets stuck.
*/
export function dismissTopMenu() {
const entry = _stack.pop();
if (!entry) return false;
try { entry.dismissFn(); } catch {}
return true;
}
/** Test/debug helper: number of currently-registered menus. */
export function _openMenuCount() {
return _stack.length;
}
/**
* Tear a transient menu down through its registered dismiss callback if it has
* one (releasing its Escape-stack entry and any listeners), else fall back to a
* plain node removal. Use this anywhere menus are cleared in bulk — scroll /
* swipe / modal-dismiss cleanup, or a "close the previous one" reopen sweep —
* instead of a raw `el.remove()`, which would strand the stack entry.
*/
export function dismissOrRemove(el) {
if (!el) return;
if (typeof el._dismiss === 'function') el._dismiss();
else el.remove();
}
// ── DOM convenience wrapper ──────────────────────────────────────────────
// The registry above is intentionally DOM-free (and unit-tested as such).
// bindMenuDismiss is the thin DOM layer most callers actually want: it wires
// the ubiquitous "overlay appended to <body>, closes on an outside click"
// idiom to BOTH the outside-click listener AND the Escape stack in one call,
// so a menu only has to describe how to tear itself down once.
//
// const close = bindMenuDismiss(popup, () => popup.remove());
// // outside-click and Escape now both call close(); call it yourself from
// // item handlers too.
//
// `onClose` runs exactly once (idempotent) and owns the actual teardown
// (removing/hiding the node, clearing anchor state, …). `isOutside(ev)`
// defaults to "the click landed outside `el`"; override it when extra anchors
// should count as inside the menu. The returned idempotent close() is also
// stashed on `el._dismiss`, so bulk removers (see dismissOrRemove) can tear the
// menu down through its real teardown rather than orphaning its stack entry.
export function bindMenuDismiss(el, onClose, isOutside) {
let done = false;
let unreg = () => {};
const onDocClick = (ev) => {
const outside = typeof isOutside === 'function' ? isOutside(ev) : !el.contains(ev.target);
if (outside) close();
};
function close() {
if (done) return;
done = true;
unreg(); unreg = () => {};
document.removeEventListener('click', onDocClick, true);
try { if (typeof onClose === 'function') onClose(); } catch {}
}
// Defer attaching the outside-click listener so the opening click doesn't
// immediately close the menu. Skip the attach if close() already ran in the
// same tick (e.g. an instant Escape) so we never leave a dangling listener.
setTimeout(() => { if (!done) document.addEventListener('click', onDocClick, true); }, 0);
unreg = registerMenuDismiss(close);
el._dismiss = close;
return close;
}

View File

@@ -27,6 +27,7 @@
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
import { dismissOrRemove } from './escMenuStack.js';
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
@@ -1463,7 +1464,7 @@ window.addEventListener('modal-dismissed', (e) => {
if (id === 'cookbook-modal') {
document.querySelectorAll(
'.cookbook-task-dropdown, .cookbook-gpu-split-menu, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
).forEach(d => d.remove());
).forEach(dismissOrRemove);
}
});

View File

@@ -7,6 +7,7 @@
import themeModule from './theme.js';
import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js';
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
let toastEl = null;
let autoScrollEnabled = true;
@@ -769,7 +770,7 @@ function _initScrollDismiss() {
if (chatHistory) {
chatHistory.addEventListener('scroll', () => {
chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show'));
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-popup').forEach(dismissOrRemove);
}, { passive: true });
} else {
// Retry once if element doesn't exist yet
@@ -822,7 +823,8 @@ const uiModule = {
el,
esc,
isTouchInsideModal,
emptyStateIcon
emptyStateIcon,
registerMenuDismiss
};
export default uiModule;
@@ -883,7 +885,9 @@ if ('ontouchstart' in window) {
'.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
).forEach(d => {
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active');
d.remove();
// Registered menus tear down through their own dismiss (releasing the
// Escape-stack entry); unregistered ones (email/dep) just get removed.
dismissOrRemove(d);
});
}
@@ -1200,6 +1204,15 @@ if (!window._odyEscExpandGuard) {
e.stopImmediatePropagation(); e.preventDefault();
return;
}
// Transient ad-hoc menus (dropdowns / context popups) live outside the
// .modal system and register a dismiss callback in escMenuStack. Close the
// most-recently-opened one first — so a menu opened over a modal dismisses
// before the modal — and do it BEFORE the text-input guard below, since a
// menu may own the focused input (e.g. a search dropdown).
if (dismissTopMenu()) {
e.stopImmediatePropagation(); e.preventDefault();
return;
}
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const expanded = document.querySelector('.doclib-card-expanded');

View File

@@ -0,0 +1,116 @@
"""Pin the DOM-free Escape-dismissal registry in static/js/escMenuStack.js.
Driven through `node --input-type=module` so we exercise the real JS without a
full Vitest/Jest setup (same spirit as test_reply_recipients_js.py). Skips when
`node` is not installed rather than failing.
The module source is inlined into the eval'd module body (rather than imported
by path) so the test runs identically on Windows and POSIX — the repo has no
`"type": "module"` in package.json, so a path import of a `.js` file is treated
as CommonJS by node and rejects the ES `export`s. escMenuStack.js has no
imports of its own, so inlining is exact.
Background: ad-hoc dropdowns/popups (document-library card menus, chat context
popups, cookbook serve menus, calendar event menus, compare pane menus) live
outside the .modal system, so the global Escape arbiter in ui.js couldn't see
them. They register a dismiss callback here while open; the arbiter calls
dismissTopMenu() to close the most-recently-opened one. These tests lock in the
LIFO contract and the "exactly one menu per Escape, never get stuck" guarantees
the arbiter relies on.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_HELPER = _REPO / "static" / "js" / "escMenuStack.js"
_HAS_NODE = shutil.which("node") is not None
_SRC = _HELPER.read_text(encoding="utf-8") if _HELPER.exists() else ""
def _run(body: str) -> str:
"""Run `body` as a module with the registry's functions already in scope."""
js = _SRC + "\n" + body
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, encoding="utf-8",
cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_empty_stack_dismiss_is_noop():
# Nothing open: returns false so the arbiter can fall through to modals.
body = "console.log(JSON.stringify([dismissTopMenu(), _openMenuCount()]));"
assert json.loads(_run(body)) == [False, 0]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_dismiss_is_lifo_and_closes_exactly_one():
body = """
const order = [];
registerMenuDismiss(() => order.push('A'));
registerMenuDismiss(() => order.push('B'));
const r1 = dismissTopMenu(); // closes B (most recent)
const r2 = dismissTopMenu(); // closes A
const r3 = dismissTopMenu(); // nothing left
console.log(JSON.stringify({ order, r1, r2, r3, left: _openMenuCount() }));
"""
out = json.loads(_run(body))
assert out["order"] == ["B", "A"] # LIFO
assert [out["r1"], out["r2"], out["r3"]] == [True, True, False]
assert out["left"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_unregister_removes_entry_without_firing():
body = """
let fired = false;
const unreg = registerMenuDismiss(() => { fired = true; });
unreg(); // menu closed itself via outside-click
const r = dismissTopMenu(); // Escape should now find nothing
console.log(JSON.stringify({ fired, r, left: _openMenuCount() }));
"""
# Unregistering must not invoke the callback and must leave the stack empty.
assert json.loads(_run(body)) == {"fired": False, "r": False, "left": 0}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_unregister_targets_correct_entry_when_interleaved():
body = """
const order = [];
const unregA = registerMenuDismiss(() => order.push('A'));
registerMenuDismiss(() => order.push('B'));
unregA(); // remove the older entry, keep B
dismissTopMenu(); // should fire B, not A
console.log(JSON.stringify({ order, left: _openMenuCount() }));
"""
out = json.loads(_run(body))
assert out["order"] == ["B"]
assert out["left"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_throwing_dismiss_still_pops_and_reports_handled():
body = """
registerMenuDismiss(() => { throw new Error('boom'); });
const r = dismissTopMenu(); // must swallow the error...
console.log(JSON.stringify({ r, left: _openMenuCount() }));
"""
# A misbehaving menu must not wedge the stack or crash the arbiter.
assert json.loads(_run(body)) == {"r": True, "left": 0}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_non_function_registration_is_ignored():
body = """
const unreg = registerMenuDismiss(null);
console.log(JSON.stringify({ left: _openMenuCount(), unregType: typeof unreg }));
"""
# Bad input must not enter the stack, and must still return a callable.
assert json.loads(_run(body)) == {"left": 0, "unregType": "function"}