@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
JavaScript
(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" });
});