/** * Present an existing menu at a viewport position. * * PopupMenu owns placement, dismissal and keyboard movement. The application * remains responsible for menu actions and for enabling individual items. */ export class PopupMenu { constructor(element, trigger = null) { this.element = element; this.trigger = trigger; if (this.trigger) { this.trigger.setAttribute("aria-haspopup", "menu"); this.trigger.setAttribute("aria-expanded", "false"); } this.element.addEventListener("click", (event) => { if (event.target.closest('[role^="menuitem"]')) this.close(); }); this.element.addEventListener("keydown", (event) => this.handleKeydown(event)); document.addEventListener("pointerdown", (event) => { if (!this.isOpen()) return; if (this.element.contains(event.target)) return; if (this.trigger && this.trigger.contains(event.target)) return; this.close(); }); } isOpen() { return !this.element.classList.contains("hidden"); } /** Open the menu at client coordinates and keep it inside the viewport. */ openAt(clientX, clientY) { if (this.element.parentElement !== document.body) document.body.append(this.element); this.element.classList.remove("hidden"); const left = Math.max(8, Math.min(clientX, window.innerWidth - this.element.offsetWidth - 8)); const top = Math.max(8, Math.min(clientY, window.innerHeight - this.element.offsetHeight - 8)); this.element.style.left = `${left}px`; this.element.style.top = `${top}px`; if (this.trigger) this.trigger.setAttribute("aria-expanded", "true"); const first = this.items()[0]; if (first) first.focus({ preventScroll: true }); } close() { const returnFocus = this.element.contains(document.activeElement); this.element.classList.add("hidden"); if (this.trigger) this.trigger.setAttribute("aria-expanded", "false"); if (returnFocus && this.trigger) this.trigger.focus({ preventScroll: true }); } items() { return Array.from(this.element.querySelectorAll('[role^="menuitem"]')) .filter((item) => !item.disabled && !item.classList.contains("hidden")); } handleKeydown(event) { const items = this.items(); const current = items.indexOf(document.activeElement); let next = null; if (event.key === "ArrowDown") next = current < items.length - 1 ? current + 1 : 0; if (event.key === "ArrowUp") next = current > 0 ? current - 1 : items.length - 1; if (event.key === "Home") next = 0; if (event.key === "End") next = items.length - 1; if (event.key === "Escape") { event.preventDefault(); this.close(); return; } if (next === null || !items[next]) return; event.preventDefault(); items[next].focus({ preventScroll: true }); } }