UNPKG

@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
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 };