76 lines
2.7 KiB
JavaScript
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 });
|
|
}
|
|
}
|