175 lines
5.2 KiB
JavaScript
175 lines
5.2 KiB
JavaScript
/**
|
|
@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;
|
|
}
|
|
}
|
|
};
|