import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element'; export interface ISdigContextMenuAction { id: string; label: string; description?: string; selected?: boolean; disabled?: boolean; danger?: boolean; } export interface ISdigContextMenuActionEventDetail { id: string; action: ISdigContextMenuAction; } type TMenuPosition = { x: number; y: number; ready: boolean; }; declare global { interface HTMLElementTagNameMap { 'sdig-contextmenu': SdigContextmenu; } } @customElement('sdig-contextmenu') export class SdigContextmenu extends DeesElement { public static demo = () => html`
`; public static demoGroups = ['Signature Digital Primitives']; @property({ type: Number }) public accessor anchorX: number = 0; @property({ type: Number }) public accessor anchorY: number = 0; @property({ type: String }) public accessor title: string = ''; @property({ attribute: false }) public accessor actions: ISdigContextMenuAction[] = []; @state() private accessor position: TMenuPosition = { x: 0, y: 0, ready: false }; private positionUpdateFrame: number | null = null; public static styles = css` :host { display: contents; } .menu { position: fixed; z-index: 1000; min-width: 190px; max-width: min(280px, calc(100vw - 16px)); padding: 6px; border: 1px solid var(--border, hsl(0 0% 14.9%)); border-radius: 8px; background: var(--bg-card, hsl(0 0% 7%)); color: var(--text, hsl(0 0% 98%)); box-shadow: 0 16px 42px rgba(0,0,0,0.36); box-sizing: border-box; } .title { padding: 7px 8px; margin-bottom: 4px; border-bottom: 1px solid var(--border-subtle, hsl(0 0% 11%)); font-size: 11px; font-weight: 700; color: var(--text-sec, hsl(0 0% 63.9%)); } .action { width: 100%; min-height: 34px; padding: 8px; border: 0; border-radius: 6px; background: transparent; color: var(--text-sec, hsl(0 0% 63.9%)); display: flex; align-items: center; gap: 8px; text-align: left; font: inherit; font-size: 11px; cursor: pointer; } .action:hover { background: var(--hover, rgba(255,255,255,0.06)); color: var(--text, hsl(0 0% 98%)); } .action.danger { color: var(--error, #ef4444); } .action[disabled] { opacity: 0.45; cursor: not-allowed; } .action[disabled]:hover { background: transparent; color: var(--text-sec, hsl(0 0% 63.9%)); } .action-mark { width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; } .action-mark.selected::before { content: ''; width: 7px; height: 4px; border-left: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg) translate(1px, -1px); } .action-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; } .action-label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .action-description { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--text-muted, hsl(0 0% 48%)); font-size: 10px; line-height: 1.25; } `; public connectedCallback = async () => { await super.connectedCallback(); window.addEventListener('resize', this.queuePositionUpdate); }; public disconnectedCallback = async () => { window.removeEventListener('resize', this.queuePositionUpdate); if (this.positionUpdateFrame !== null) { globalThis.cancelAnimationFrame(this.positionUpdateFrame); this.positionUpdateFrame = null; } await super.disconnectedCallback(); }; public updated() { this.queuePositionUpdate(); } private queuePositionUpdate = () => { if (this.positionUpdateFrame !== null) return; this.positionUpdateFrame = globalThis.requestAnimationFrame(() => { this.positionUpdateFrame = null; this.positionMenu(); }); }; private positionMenu() { const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement | null; if (!menu) return; const margin = 8; const gap = 4; const rect = menu.getBoundingClientRect(); const viewportWidth = globalThis.innerWidth; const viewportHeight = globalThis.innerHeight; const spaceRight = viewportWidth - this.anchorX - margin; const spaceLeft = this.anchorX - margin; const spaceBelow = viewportHeight - this.anchorY - margin; const spaceAbove = this.anchorY - margin; let x = this.anchorX + gap; let y = this.anchorY + gap; if (spaceRight < rect.width + gap && spaceLeft > spaceRight) { x = this.anchorX - rect.width - gap; } if (spaceBelow < rect.height + gap && spaceAbove > spaceBelow) { y = this.anchorY - rect.height - gap; } const maxX = Math.max(margin, viewportWidth - rect.width - margin); const maxY = Math.max(margin, viewportHeight - rect.height - margin); const nextPosition = { x: Math.round(Math.max(margin, Math.min(maxX, x))), y: Math.round(Math.max(margin, Math.min(maxY, y))), ready: true, }; if (this.position.x !== nextPosition.x || this.position.y !== nextPosition.y || this.position.ready !== nextPosition.ready) { this.position = nextPosition; } } private selectAction(action: ISdigContextMenuAction) { if (action.disabled) return; this.dispatchEvent(new CustomEvent('contextmenu-action', { detail: { id: action.id, action }, bubbles: true, composed: true, })); } public render(): TemplateResult { const x = this.position.ready ? this.position.x : this.anchorX; const y = this.position.ready ? this.position.y : this.anchorY; return html` `; } }