UNPKG

@ivteplo/html-context-menu-element

Version:

An HTML custom element, designed to let you quickly create an easily-customizable context menu.

388 lines (386 loc) 19.5 kB
(function(global, factory) { typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ContextMenuElement = {})); })(this, function(exports2) { "use strict";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; var __vite_style__ = document.createElement("style"); __vite_style__.textContent = '@charset "UTF-8";\n/**\n * Copyright (c) 2024-2025 Ivan Teplov\n * Licensed under the Apache license 2.0\n */\nmenu[is=context-menu] {\n --divider-color: #dadce0;\n --border: 0.0625rem solid var(--divider-color);\n --shadow-color: #8e8e8e;\n --shadow: 0.125rem 0.125rem 0.125rem var(--focused-item-color);\n --focused-item-background-color: #e8e8e9;\n --disabled-item-foreground-color: #5f6368;\n --keystroke-foreground-color: #5f6368;\n --foreground-color: #202124;\n --arrow-color: #626365;\n --background-color: white;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";\n font-size: 0.78rem;\n}\n@media (prefers-color-scheme: dark) {\n menu[is=context-menu] {\n --divider-color: #3c4043;\n --focused-item-background-color: #3f4042;\n --disabled-item-foreground-color: #9aa0a6;\n --keystroke-foreground-color: #9aa0a6;\n --foreground-color: #dceaed;\n --arrow-color: #acaeb1;\n --background-color: #292a2d;\n }\n}\n\nmenu[is=context-menu] {\n background-color: var(--background-color);\n color: var(--foreground-color);\n display: flex;\n flex-direction: column;\n border: var(--border);\n padding: 0.125rem;\n box-shadow: var(--shadow);\n position: fixed;\n /* Item separator*/\n}\nmenu[is=context-menu]:not([open]) {\n display: none;\n}\nmenu[is=context-menu] hr {\n display: block;\n border: none;\n height: 0.0625rem;\n justify-self: stretch;\n background-color: var(--divider-color);\n margin: 0.1875rem 0.125rem;\n}\nmenu[is=context-menu] button {\n background-color: var(--background-color);\n color: inherit;\n display: flex;\n text-align: left;\n justify-content: space-between;\n gap: 3rem;\n padding: 0.25rem 2rem;\n cursor: pointer;\n user-select: none;\n border: none;\n outline: none;\n font: inherit;\n white-space: nowrap;\n}\nmenu[is=context-menu] button::after {\n content: attr(keystroke);\n margin-left: auto;\n color: var(--keystroke-foreground-color);\n}\nmenu[is=context-menu] button:hover, menu[is=context-menu] button:focus {\n background-color: var(--focused-item-background-color);\n}\nmenu[is=context-menu] button:disabled {\n color: var(--disabled-item-foreground-color);\n}\n\ndetails[is=context-menu-group] {\n position: relative;\n}\ndetails[is=context-menu-group] > summary {\n background-color: var(--background-color);\n color: inherit;\n display: flex;\n text-align: left;\n justify-content: space-between;\n gap: 3rem;\n padding: 0.25rem 2rem;\n cursor: pointer;\n user-select: none;\n border: none;\n outline: none;\n font: inherit;\n white-space: nowrap;\n}\ndetails[is=context-menu-group] > summary::after {\n content: "⯈";\n margin-right: -1rem;\n}\n[dir=rtl] details[is=context-menu-group] > summary::after, :dir(rtl) details[is=context-menu-group] > summary::after {\n content: "⯇";\n}\ndetails[is=context-menu-group] > summary:hover {\n background-color: var(--focused-item-background-color);\n}\ndetails[is=context-menu-group]:focus-within > summary {\n background-color: var(--focused-item-background-color);\n}\ndetails[is=context-menu-group] > menu {\n background-color: var(--background-color);\n color: var(--foreground-color);\n display: flex;\n flex-direction: column;\n border: var(--border);\n padding: 0.125rem;\n box-shadow: var(--shadow);\n position: absolute;\n z-index: 1;\n}\ndetails[is=context-menu-group][data-x-expand-to=right] > menu {\n left: 100%;\n}\ndetails[is=context-menu-group][data-x-expand-to=left] > menu {\n right: 100%;\n}\ndetails[is=context-menu-group][data-y-expand-to=bottom] > menu {\n top: 0;\n}\ndetails[is=context-menu-group][data-y-expand-to=top] > menu {\n bottom: 0;\n}/*$vite$:1*/'; document.head.appendChild(__vite_style__); 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`); } exports2.ContextMenuElement = ContextMenuElement; exports2.ContextMenuGroupElement = ContextMenuGroupElement$1; exports2.defineElements = defineElements; Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" }); });