@ivteplo/html-context-menu-element
Version:
An HTML custom element, designed to let you quickly create an easily-customizable context menu.
381 lines (380 loc) • 14.4 kB
JavaScript
var __typeError = (msg) => {
throw TypeError(msg);
};
var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
var _currentTarget, _target, _eventListeners, _ContextMenuElement_instances, onKeyDown_fn, onContextMenuCall_fn, onClick_fn, __buttonWrapper, _ContextMenuGroupElement_instances, buttonWrapper_get, onKeyDown_fn2, open_fn, close_fn, _a;
function findParentThat(meetsCriteria, child) {
let parent = child == null ? void 0 : child.parentElement;
while (parent && !meetsCriteria(parent)) {
parent = parent == null ? void 0 : parent.parentElement;
}
return parent;
}
function isFocusable(element) {
return !element.hasAttribute("disabled") && (element instanceof HTMLButtonElement || element instanceof HTMLAnchorElement || element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || (element.getAttribute("tabindex") ?? "-1") !== "-1");
}
function getNextChildToFocusOnInsideOf(parent, focusedElement, isTheOneAfterCurrent) {
let nextToFocus;
if (!parent.contains(focusedElement)) {
nextToFocus = isTheOneAfterCurrent ? parent.firstElementChild : parent.lastElementChild;
} else {
const directChild = focusedElement.parentElement !== parent ? findParentThat((element) => element.parentElement === parent, focusedElement) : focusedElement;
nextToFocus = isTheOneAfterCurrent ? directChild.nextElementSibling ?? parent.firstElementChild : directChild.previousElementSibling ?? parent.lastElementChild;
}
while (!(isFocusable(nextToFocus) || nextToFocus instanceof ContextMenuGroupElement)) {
nextToFocus = isTheOneAfterCurrent ? nextToFocus.nextElementSibling ?? parent.firstElementChild : nextToFocus.previousElementSibling ?? parent.lastElementChild;
}
if (nextToFocus instanceof ContextMenuGroupElement) {
return nextToFocus.querySelector("summary");
}
return nextToFocus;
}
const contextMenuAttribute = "data-context-menu";
const observer = new MutationObserver((mutations) => {
mutations.forEach(handleMutation);
});
const startObserver = () => {
const alreadyInDOM = document.querySelectorAll(`[${contextMenuAttribute}]`);
alreadyInDOM.forEach((item) => {
item.addEventListener("contextmenu", openContextMenuOnClick);
});
window.addEventListener("mousedown", onMenuCollapsingEvent);
window.addEventListener("contextmenu", onMenuCollapsingEvent);
window.addEventListener("scroll", onMenuCollapsingEvent);
window.addEventListener("blur", onMenuCollapsingEvent);
window.addEventListener("resize", onMenuCollapsingEvent);
observer.observe(document.body, {
attributes: true,
subtree: true,
attributeFilter: [contextMenuAttribute],
childList: true
});
};
function handleMutation(mutation) {
if (!(mutation.target instanceof HTMLElement)) return;
if (mutation.type === "attributes") {
return handleElement(mutation.target);
}
mutation.addedNodes.forEach((node) => {
if (!(node instanceof HTMLElement)) return;
handleElement(node);
const childrenWithCustomContextMenu = node.querySelectorAll(`[${contextMenuAttribute}]`);
childrenWithCustomContextMenu.forEach(handleElement);
});
}
function handleElement(element) {
element.removeEventListener("contextmenu", openContextMenuOnClick);
if (element.getAttribute(contextMenuAttribute) !== "") {
element.addEventListener("contextmenu", openContextMenuOnClick);
}
}
function openContextMenuOnClick(event) {
if (!(event.currentTarget instanceof HTMLElement)) return;
const contextMenuID = event.currentTarget.getAttribute(contextMenuAttribute);
const contextMenu = contextMenuID ? document.getElementById(contextMenuID) : null;
if (!(contextMenu instanceof ContextMenuElement)) return;
event.preventDefault();
event.stopPropagation();
if (contextMenu.contains(event.target)) return;
contextMenu.show(event.clientX, event.clientY, {
currentTarget: event.currentTarget,
target: event.target
});
}
function onMenuCollapsingEvent(event) {
const openMenu = document.querySelector("menu[is=context-menu][open]");
if (openMenu && (event.type !== "mousedown" || !openMenu.contains(event.target))) {
openMenu.hide();
}
}
class ContextMenuElement extends HTMLMenuElement {
constructor() {
super();
__privateAdd(this, _ContextMenuElement_instances);
/** @type {HTMLElement | null} */
__privateAdd(this, _currentTarget, null);
/** @type {HTMLElement | null} */
__privateAdd(this, _target, null);
/**
* Object with event listeners with `this`-bindings.
* We need it, because binding `this` returns a new function,
* but we need to be able to remove an event listener
*/
__privateAdd(this, _eventListeners);
__privateSet(this, _eventListeners, {
onContextMenuCall: __privateMethod(this, _ContextMenuElement_instances, onContextMenuCall_fn).bind(this),
onKeyDown: __privateMethod(this, _ContextMenuElement_instances, onKeyDown_fn).bind(this)
});
this.addEventListener("keydown", __privateGet(this, _eventListeners).onKeyDown);
}
/**
* Function to define the context menu element in the HTML Custom Element Registry
* @param {string} tag - the tag name for the context menu element
* @example
* import { ContextMenuElement } from "@ivteplo/html-context-menu-element"
* ContextMenuElement.defineAs("context-menu")
*/
static defineAs(tag) {
customElements.define(tag, this, { extends: "menu" });
}
/**
* Container that has `data-context-menu` set
* and that has been right clicked on.
* @returns {HTMLElement|null}
*/
get currentTarget() {
return __privateGet(this, _currentTarget);
}
/**
* Object that has been right clicked on.
* Can be a child of a container that has the `data-context-menu` set.
* @returns {HTMLElement|null}
*/
get target() {
return __privateGet(this, _target);
}
/**
* @ignore
*/
static get observedAttributes() {
return ["open"];
}
/**
* @ignore
* @param {string} name
* @param {any} _oldValue
* @param {any} newValue
*/
attributeChangedCallback(name, _oldValue, newValue) {
if (name === "open" && newValue !== null) {
window.addEventListener("keydown", __privateGet(this, _eventListeners).onKeyDown);
this.removeEventListener("keydown", __privateGet(this, _eventListeners).onKeyDown);
} else {
window.removeEventListener("keydown", __privateGet(this, _eventListeners).onKeyDown);
this.addEventListener("keydown", __privateGet(this, _eventListeners).onKeyDown);
}
}
/**
* Hide the context menu
*/
hide() {
this.removeAttribute("open");
this.style.top = "";
this.style.left = "";
}
/**
* Displays the context menu near the specified location
* @param {number} x - horizontal click location
* @param {number} y - vertical click location
* @param {{
* currentTarget?: HTMLElement,
* target?: HTMLElement
* }} - objects that triggered the context menu (optional)
*/
show(x, y, { target, currentTarget } = {}) {
var _a2;
if (typeof x !== "number" || typeof y !== "number") {
return console.warn(
"Invalid coordinates passed to the %s#show(x, y) method: (%s, %s).",
this.constructor.name,
x,
y
);
}
if (currentTarget instanceof HTMLElement) {
__privateSet(this, _currentTarget, currentTarget);
}
if (target instanceof HTMLElement) {
__privateSet(this, _target, target);
}
this.setAttribute("open", true);
if (y + this.clientHeight > window.innerHeight) {
this.style.top = `${y - this.clientHeight}px`;
} else {
this.style.top = `${y}px`;
}
if (x + this.clientWidth > window.innerWidth) {
this.style.left = `${x - this.clientWidth}px`;
} else {
this.style.left = `${x}px`;
}
(_a2 = this.querySelector("button")) == null ? void 0 : _a2.focus();
}
/** @ignore */
connectedCallback() {
this.hide();
this.addEventListener("contextmenu", __privateMethod(this, _ContextMenuElement_instances, onContextMenuCall_fn));
this.addEventListener("click", __privateMethod(this, _ContextMenuElement_instances, onClick_fn));
startObserver();
}
/**
* @ignore
*/
disconnectedCallback() {
}
}
_currentTarget = new WeakMap();
_target = new WeakMap();
_eventListeners = new WeakMap();
_ContextMenuElement_instances = new WeakSet();
/**
* Gets called whenever any key has been pressed on the keyboard
* @param {KeyboardEvent} event
*/
onKeyDown_fn = function(event) {
if (event.key === "Tab") {
event.preventDefault();
} else if (event.key === "Escape") {
this.hide();
event.stopImmediatePropagation();
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
event.stopImmediatePropagation();
const isArrowDown = event.key === "ArrowDown";
const focusedElement = document.activeElement;
const element = getNextChildToFocusOnInsideOf(this, focusedElement, isArrowDown);
element == null ? void 0 : element.focus();
}
};
/**
* Gets called when the right button is clicked inside the context menu
* @param {PointerEvent} event
*/
onContextMenuCall_fn = function(event) {
event.preventDefault();
event.stopImmediatePropagation();
if (event.target instanceof HTMLButtonElement) {
event.target.click();
}
};
/**
* Gets called when a click inside of the context menu has been performed.
* @param {PointerEvent} event
*/
onClick_fn = function(event) {
if (!event.defaultPrevented && event.target instanceof HTMLButtonElement) {
this.hide();
}
};
let ContextMenuGroupElement$1 = (_a = class extends HTMLDetailsElement {
constructor() {
super();
__privateAdd(this, _ContextMenuGroupElement_instances);
__privateAdd(this, __buttonWrapper);
this.addEventListener("mouseover", () => {
this.open = true;
});
this.addEventListener("keydown", __privateMethod(this, _ContextMenuGroupElement_instances, onKeyDown_fn2).bind(this));
this.addEventListener("mouseleave", () => {
this.open = false;
});
}
/**
* Function to define the context menu group element in the HTML Custom Element Registry
* @param {string} tag - the tag name for the context menu group element
* @example
* import { ContextMenuGroupElement } from "@ivteplo/html-context-menu-element"
* ContextMenuGroupElement.defineAs("context-menu-group")
*/
static defineAs(tag) {
customElements.define(tag, this, { extends: "details" });
}
/**
* @ignore
*/
static get observedAttributes() {
return ["open"];
}
/**
* @ignore
* Method that gets called whenever the 'open' attribute changes
* (list of observed attributes is specified in the static method `observedAttributes`)
* @param {string} name
* @param {any} _oldValue
* @param {any} newValue
*/
attributeChangedCallback(name, _oldValue, newValue) {
if (name === "open") {
if (newValue !== null) {
__privateMethod(this, _ContextMenuGroupElement_instances, open_fn).call(this);
} else {
__privateMethod(this, _ContextMenuGroupElement_instances, close_fn).call(this);
}
}
}
}, __buttonWrapper = new WeakMap(), _ContextMenuGroupElement_instances = new WeakSet(), buttonWrapper_get = function() {
if (!__privateGet(this, __buttonWrapper)) {
__privateSet(this, __buttonWrapper, this.querySelector("menu"));
}
return __privateGet(this, __buttonWrapper);
}, /**
* Gets called whenever any keyboard button gets pressed
* @param {KeyboardEvent} event
*/
onKeyDown_fn2 = function(event) {
var _a2, _b, _c;
event.preventDefault();
if (this.open) {
switch (event.key) {
case "Escape":
case "ArrowLeft":
event.stopImmediatePropagation();
this.open = false;
(_a2 = this.querySelector("summary")) == null ? void 0 : _a2.focus();
break;
case "ArrowDown":
case "ArrowUp":
event.stopImmediatePropagation();
(_b = getNextChildToFocusOnInsideOf(__privateGet(this, _ContextMenuGroupElement_instances, buttonWrapper_get), document.activeElement, event.key === "ArrowDown")) == null ? void 0 : _b.focus();
break;
}
} else {
switch (event.key) {
case "Enter":
case "Space":
event.stopImmediatePropagation();
this.open = true;
break;
case "ArrowRight":
event.stopImmediatePropagation();
this.open = true;
(_c = getNextChildToFocusOnInsideOf(__privateGet(this, _ContextMenuGroupElement_instances, buttonWrapper_get), document.activeElement, true)) == null ? void 0 : _c.focus();
break;
}
}
}, /**
* Calculates in which direction the submenu should be opened
*/
open_fn = function() {
const { top, left } = this.getBoundingClientRect();
if (left + this.parentElement.clientWidth + this.clientWidth > window.innerWidth) {
this.setAttribute("data-x-expand-to", "left");
} else {
this.setAttribute("data-x-expand-to", "right");
}
if (top + __privateGet(this, _ContextMenuGroupElement_instances, buttonWrapper_get).clientHeight > window.innerHeight) {
this.setAttribute("data-y-expand-to", "top");
} else {
this.setAttribute("data-y-expand-to", "bottom");
}
}, /**
* Removes the no-longer-needed attributes
*/
close_fn = function() {
this.removeAttribute("data-x-expand-to");
this.removeAttribute("data-y-expand-to");
}, _a);
function defineElements() {
const baseName = "context-menu";
ContextMenuElement.defineAs(baseName);
ContextMenuGroupElement$1.defineAs(`${baseName}-group`);
}
export {
ContextMenuElement,
ContextMenuGroupElement$1 as ContextMenuGroupElement,
defineElements
};