Files
racket-wiki/static/js/widgets/popup-menu.js
T

76 lines
2.7 KiB
JavaScript

/**
* 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 });
}
}