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