/* ObsiGate — generic themed context menu for the XLSX grid (#155, #179). * * Builds a small menu anchored at a screen position. Labels, icons and * callbacks come from the caller, so no i18n lives here. * * #179 — every menu entry carries a representative Lucide icon, the menu * closes as soon as the focus leaves it (outside click, focusout, Escape, * window blur, scroll, resize) and arrow keys walk through the entries. * trackDismissable() exposes the same dismissal contract to the other * spreadsheet menus (Structure, Mise en forme, Export), which keep their * own markup. */ import { safeCreateIcons } from '../utils.js'; let _menu = null; let _openedAt = 0; // Only a touch long-press can emit a click right after the menu opened. let _openedByTouch = false; let _dismissBound = false; export function closeContextMenu() { // Drop the reference BEFORE touching the DOM: removing a menu that holds // the focus fires blur/focusout synchronously, which re-enters this very // function — a second remove() on the mid-flight node throws NotFoundError // and aborts the caller's click handler before its action runs. const menu = _menu; _menu = null; if (menu) menu.remove(); } export function isContextMenuOpen() { return _menu !== null; } function bindDismiss() { if (_dismissBound) return; _dismissBound = true; document.addEventListener("click", (e) => { if (!_menu) return; // Ignore the very gesture that opened the menu — but only for a touch // long-press, which is the one that emits a trailing click. A mouse // right-click does not, so a deliberate outside click made right after // must close the menu instead of being swallowed. if (_openedByTouch && Date.now() - _openedAt < 250) return; if (e.target && e.target.closest && e.target.closest(".xlsx-context-menu")) return; closeContextMenu(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeContextMenu(); }); // #179 — never linger once the focus moves outside the menu (toolbar // button, editor, another panel…). A null relatedTarget (click on a // non-focusable cell) is already covered by the click-outside handler. document.addEventListener("focusout", (e) => { if (!_menu) return; const next = e.relatedTarget; if (next && _menu.contains(next)) return; closeContextMenu(); }); if (typeof window !== "undefined") { window.addEventListener("blur", closeContextMenu); // A scroll detaches the viewport-anchored menu from its cell — except the // tail of the smooth scroll that revealed the right-clicked cell (global // `scroll-behavior: smooth` keeps firing after the menu opened). window.addEventListener("scroll", () => { if (Date.now() - _openedAt > 400) closeContextMenu(); }, true); window.addEventListener("resize", closeContextMenu); } } /** * Walk through a menu's buttons with the keyboard (#179). * @param {HTMLElement} menuEl - menu container * @param {string} itemSelector - CSS selector of the navigable entries */ export function enableArrowNav(menuEl, itemSelector) { menuEl.addEventListener("keydown", (e) => { const items = [...menuEl.querySelectorAll(itemSelector)].filter((b) => !b.disabled); if (!items.length) return; const idx = items.indexOf(document.activeElement); let next = -1; if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % items.length; else if (e.key === "ArrowUp") next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length; else if (e.key === "Home") next = 0; else if (e.key === "End") next = items.length - 1; else return; e.preventDefault(); items[next].focus(); }); } /** * Dismissal contract shared by the spreadsheet's other menus (#179). * Closes on outside click (the toggle button owns its own clicks), Escape, * focus leaving both the menu and its toggle, scroll and resize. * @param {HTMLElement} menuEl - menu element to watch * @param {object} opts * @param {HTMLElement|null} opts.toggle - button opening the menu (exempt) * @param {Function|null} opts.onClose - called once on dismissal (default: remove the element) * @returns {Function} dismiss() — idempotent manual close (unbinds + onClose) */ export function trackDismissable(menuEl, { toggle = null, onClose = null } = {}) { let done = false; const unbind = () => { document.removeEventListener("click", onDocClick); document.removeEventListener("keydown", onKey); document.removeEventListener("focusout", onFocusOut); if (typeof window !== "undefined") { window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onScroll); } }; const dismiss = () => { if (done) return; done = true; unbind(); if (onClose) onClose(); else menuEl.remove(); }; // A re-render can detach the menu without dismissing it: unbind silently // instead of leaking listeners on a dead node. const dropIfDetached = () => { if (!menuEl.isConnected) { if (!done) { done = true; unbind(); } return true; } return false; }; const onDocClick = (e) => { if (done || dropIfDetached()) return; const t = e.target; if (menuEl.contains(t)) return; if (toggle && (toggle === t || toggle.contains(t))) return; dismiss(); }; const onKey = (e) => { if (dropIfDetached()) return; if (e.key === "Escape") dismiss(); }; const onFocusOut = (e) => { if (done || dropIfDetached()) return; const next = e.relatedTarget; if (next && (menuEl.contains(next) || (toggle && toggle.contains(next)))) return; dismiss(); }; // Toolbar-anchored menus travel WITH their button: a scroll inside the // hosting toolbar (focus scroll-into-view, horizontal scroll, the menu's // own overflow) must not dismiss them — only an outside scroll detaches. // Same grace as the grid menu for the smooth-scroll tail of the opening. // A body-level menu has no travelling anchor: every scroll detaches it. const rawAnchor = (toggle && toggle.closest && toggle.closest(".xlsx-toolbar")) || menuEl.parentElement || null; const anchor = rawAnchor && rawAnchor !== document && rawAnchor !== document.body ? rawAnchor : null; const openedAt = Date.now(); const onScroll = (e) => { if (dropIfDetached()) return; if (Date.now() - openedAt < 400) return; const t = e && e.target; if (t && anchor && (t === anchor || (anchor.contains && anchor.contains(t)))) return; dismiss(); }; document.addEventListener("click", onDocClick); document.addEventListener("keydown", onKey); document.addEventListener("focusout", onFocusOut); if (typeof window !== "undefined") { window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onScroll); } return dismiss; } /** * @param {number} x - viewport X of the pointer * @param {number} y - viewport Y of the pointer * @param {Array<{label?: string, icon?: string, separator?: boolean, danger?: boolean, disabled?: boolean, onClick?: Function}>} items * @returns {HTMLElement} the menu element */ export function openContextMenu(x, y, items, { byTouch = false } = {}) { bindDismiss(); closeContextMenu(); const menu = document.createElement("div"); menu.className = "xlsx-context-menu"; menu.setAttribute("role", "menu"); for (const item of items) { if (item.separator) { const sep = document.createElement("div"); sep.className = "xlsx-context-sep"; sep.setAttribute("aria-hidden", "true"); menu.appendChild(sep); continue; } const btn = document.createElement("button"); btn.type = "button"; btn.className = `btn-action xlsx-context-item${item.danger ? " xlsx-context-danger" : ""}`; btn.setAttribute("role", "menuitem"); if (item.icon) { const ic = document.createElement("i"); ic.setAttribute("data-lucide", item.icon); ic.className = "xlsx-menu-icon"; ic.setAttribute("aria-hidden", "true"); btn.appendChild(ic); } const lab = document.createElement("span"); lab.className = "xlsx-menu-label"; lab.textContent = item.label; btn.appendChild(lab); btn.disabled = Boolean(item.disabled); btn.addEventListener("click", () => { closeContextMenu(); if (!item.disabled && item.onClick) item.onClick(); }); menu.appendChild(btn); } enableArrowNav(menu, ".xlsx-context-item"); document.body.appendChild(menu); // Keep the menu inside the viewport (getBoundingClientRect is 0 in JSDOM, // which simply leaves the requested position). const rect = menu.getBoundingClientRect(); const vw = (typeof window !== "undefined" && window.innerWidth) || 1024; const vh = (typeof window !== "undefined" && window.innerHeight) || 768; const left = Math.max(4, Math.min(x, vw - rect.width - 4)); const top = Math.max(4, Math.min(y, vh - rect.height - 4)); menu.style.left = `${left}px`; menu.style.top = `${top}px`; _menu = menu; _openedAt = Date.now(); _openedByTouch = byTouch; // Keyboard users land on the first entry right away; mouse users are // unaffected (hover/click still work). preventScroll avoids yanking the grid. const first = menu.querySelector(".xlsx-context-item:not(:disabled)"); if (first && typeof first.focus === "function") { try { first.focus({ preventScroll: true }); } catch (_) { first.focus(); } } if (typeof safeCreateIcons === "function") safeCreateIcons(); return menu; }