/** @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` ${(actions || []).map((a) => a.separator ? html`` : html` this._onCtxAction(a, context, e)}> ${a.label} ` )} `; } // --------------------------------------------------------------------------- // 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; } } };