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:
@@ -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 ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user