/** Custom right-click menu (#125). * * One menu exists at a time: opening a second closes the first. The menu is * appended to `` rather than to the row it belongs to, so an overflowing * sidebar cannot clip it. */ export interface ContextMenuItem { label: string; onSelect: () => void; /** Renders the entry in the destructive style. */ danger?: boolean; } let open: { el: HTMLElement; dispose: () => void } | null = null; /** Close the menu that is currently up, if any. */ export function closeContextMenu(): void { open?.dispose(); } /** * Open a menu at viewport coordinates `x`/`y`. The menu closes on selection, * on Escape, on a pointer press anywhere outside it, and on scroll or resize — * a menu anchored to coordinates goes stale the moment the page moves under it. */ export function openContextMenu(x: number, y: number, items: ContextMenuItem[]): HTMLElement { closeContextMenu(); const el = document.createElement("div"); el.className = "context-menu"; el.setAttribute("role", "menu"); for (const item of items) { const btn = document.createElement("button"); btn.type = "button"; btn.className = item.danger ? "context-menu-item is-danger" : "context-menu-item"; btn.setAttribute("role", "menuitem"); btn.textContent = item.label; btn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); closeContextMenu(); item.onSelect(); }); el.appendChild(btn); } document.body.appendChild(el); // Nudge back inside the viewport when the click was near an edge. happy-dom // reports 0 for every measurement, which lands the menu at the click point — // the same place it would sit with room to spare. const rect = el.getBoundingClientRect(); const maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; el.style.left = `${Math.max(0, Math.min(x, maxX))}px`; el.style.top = `${Math.max(0, Math.min(y, maxY))}px`; const onPointerDown = (ev: Event) => { if (!el.contains(ev.target as Node)) closeContextMenu(); }; const onKeyDown = (ev: KeyboardEvent) => { if (ev.key === "Escape") closeContextMenu(); }; const dispose = () => { if (open?.el !== el) return; open = null; el.remove(); window.removeEventListener("pointerdown", onPointerDown, true); window.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("scroll", closeContextMenu, true); window.removeEventListener("resize", closeContextMenu); }; window.addEventListener("pointerdown", onPointerDown, true); window.addEventListener("keydown", onKeyDown, true); window.addEventListener("scroll", closeContextMenu, true); window.addEventListener("resize", closeContextMenu); open = { el, dispose }; (el.firstElementChild as HTMLElement | null)?.focus(); return el; }