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
+6 -11
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