Add context menu

This commit is contained in:
2026-04-23 10:22:46 +02:00
parent 6be4d378f3
commit fc31e87890
2 changed files with 184 additions and 3 deletions

View File

@@ -6,11 +6,18 @@ export const clipboard = function(superClass) {
};
};
export const copyToClipboard = (content) => {
export const copyToClipboard = async (content) => {
const txtEl = document.createElement('input');
txtEl.type = 'hidden';
document.body.appendChild(txtEl);
txtEl.value = content;
navigator.clipboard.writeText(content);
document.body.removeChild(txtEl);
try {
await navigator.clipboard.writeText(content);
return true;
} catch (err) {
return false;
} finally {
document.body.removeChild(txtEl);
}
}

174
context-menu.js Normal file
View File

@@ -0,0 +1,174 @@
/**
@license
Copyright (c) 2026 trading_peter
This program is available under Apache License Version 2.0
*/
import '@tp/tp-popup/tp-popup-menu.js';
import '@tp/tp-popup/tp-popup-menu-item.js';
import { html } from 'lit';
/**
* General-purpose context menu mixin for LitElement components.
*
* The host must also use the Position mixin (from helpers/position.js)
* so that `_posFixed()` is available.
*
* Usage:
* 1. Mix into your element: ContextMenu(Position(LitElement))
* 2. Call `this._openContextMenu(e, actions, context)` from a
* `contextmenu` event handler, where:
* - e: the original MouseEvent
* - actions: array of { label, action, icon?, disabled? }
* - context: arbitrary data passed through to the event detail
* 3. Include `${this._renderCtxMenu()}` somewhere in your render().
* 4. Listen for the `ctx-action` event on the host to handle actions:
* detail: { action, context, originalEvent }
*
* The mixin also dispatches a cancelable `ctx-open` event before showing
* the menu. Calling `e.preventDefault()` on it suppresses the menu.
*/
export const ContextMenu = (superClass) => class ContextMenuHost extends superClass {
static get properties() {
return {
/** @private */
_ctxMenu: { type: Object, state: true },
};
}
constructor() {
super();
this._ctxMenu = null;
this._ctxOutsideHandler = null;
this._ctxKeyHandler = null;
}
disconnectedCallback() {
super.disconnectedCallback();
this._removeCtxHandlers();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Open a context menu at the pointer position.
*
* @param {MouseEvent} e - The contextmenu event.
* @param {Array} actions - Menu items: [{ label, action, icon?, disabled? }]
* @param {*} [context] - Arbitrary data forwarded in events.
*/
async _openContextMenu(e, actions, context) {
e.preventDefault();
e.stopPropagation();
const openEvt = new CustomEvent('ctx-open', {
detail: { actions, context, originalEvent: e },
bubbles: true,
composed: true,
cancelable: true,
});
this.dispatchEvent(openEvt);
if (openEvt.defaultPrevented) return;
const x = e.clientX;
const y = e.clientY;
this._ctxMenu = { actions, context };
this.requestUpdate();
await this.updateComplete;
const menu = this.shadowRoot.querySelector('.ctx-menu');
if (menu) {
const anchor = {
getBoundingClientRect: () => ({
top: y, bottom: y, left: x, right: x, width: 0, height: 0,
}),
};
this._posFixed(anchor, menu, { valign: 'bottom', halign: 'left' });
}
this._attachCtxHandlers();
}
/** Programmatically close the context menu. */
_closeContextMenu() {
this._ctxMenu = null;
this.requestUpdate();
this._removeCtxHandlers();
}
// ---------------------------------------------------------------------------
// Rendering — include in your host's render()
// ---------------------------------------------------------------------------
/** Returns the context-menu template. Place inside your host render(). */
_renderCtxMenu() {
if (!this._ctxMenu) return null;
const { actions, context } = this._ctxMenu;
return html`
<tp-popup-menu class="ctx-menu" part="ctx-menu">
${(actions || []).map((a) => a.separator
? html`<tp-popup-menu-divider part="ctx-separator"></tp-popup-menu-divider>`
: html`
<tp-popup-menu-item
part="ctx-menu-item"
.icon=${a.icon || null}
?disabled=${a.disabled}
@click=${(e) => this._onCtxAction(a, context, e)}>
${a.label}
</tp-popup-menu-item>
`
)}
</tp-popup-menu>
`;
}
// ---------------------------------------------------------------------------
// Internal
// ---------------------------------------------------------------------------
/** @private */
_onCtxAction(action, context, e) {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('ctx-action', {
detail: { action: action.action, context, originalEvent: e },
bubbles: true,
composed: true,
}));
this._closeContextMenu();
}
/** @private */
_attachCtxHandlers() {
if (this._ctxOutsideHandler) return;
this._ctxOutsideHandler = (e) => {
const menu = this.shadowRoot.querySelector('.ctx-menu');
if (menu && e.composedPath().includes(menu)) return;
this._closeContextMenu();
};
this._ctxKeyHandler = (e) => {
if (e.key === 'Escape') this._closeContextMenu();
};
window.addEventListener('pointerdown', this._ctxOutsideHandler, true);
window.addEventListener('keydown', this._ctxKeyHandler, true);
}
/** @private */
_removeCtxHandlers() {
if (this._ctxOutsideHandler) {
window.removeEventListener('pointerdown', this._ctxOutsideHandler, true);
this._ctxOutsideHandler = null;
}
if (this._ctxKeyHandler) {
window.removeEventListener('keydown', this._ctxKeyHandler, true);
this._ctxKeyHandler = null;
}
}
};