import { DeesElement, css, cssManager, customElement, html, property, state, type TemplateResult, } from '@design.estate/dees-element'; import { mobileComponentStyles } from '../../00componentstyles.js'; import '../dees-mobile-icon/dees-mobile-icon.js'; import { demoFunc } from './dees-mobile-contextmenu.demo.js'; export interface IContextMenuItem { label?: string; icon?: string; action?: () => void; danger?: boolean; divider?: boolean; } declare global { interface HTMLElementTagNameMap { 'dees-mobile-contextmenu': DeesMobileContextmenu; } } @customElement('dees-mobile-contextmenu') export class DeesMobileContextmenu extends DeesElement { public static demo = demoFunc; @property({ type: Array }) accessor items: IContextMenuItem[] = []; @property({ type: Number }) accessor x: number = 0; @property({ type: Number }) accessor y: number = 0; @property({ type: Boolean }) accessor isTouch: boolean = false; @state() accessor isClosing: boolean = false; @state() accessor transformOrigin: string = 'top left'; public static styles = [ cssManager.defaultStyles, mobileComponentStyles, css` :host { position: fixed; z-index: var(--dees-z-contextmenu, 10000); } :host(.closing) .menu { animation: scaleOut 100ms ease-in; } .menu { background: ${cssManager.bdTheme('#ffffff', '#18181b')}; border: 1px solid ${cssManager.bdTheme('#e4e4e7', '#27272a')}; border-radius: 0.5rem; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); padding: 0.5rem 0; min-width: 180px; animation: scaleIn 100ms ease-out; } .menu-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 1rem; font-size: 0.875rem; color: ${cssManager.bdTheme('#09090b', '#fafafa')}; cursor: pointer; transition: all 100ms ease; background: none; border: none; width: 100%; text-align: left; font-family: inherit; } .menu-item:hover { background: ${cssManager.bdTheme('#f4f4f5', '#27272a')}; } .menu-item.danger { color: ${cssManager.bdTheme('#dc2626', '#ef4444')}; } .divider { height: 1px; background: ${cssManager.bdTheme('#e4e4e7', '#27272a')}; margin: 0.25rem 0; } @keyframes scaleIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } } @keyframes scaleOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.9); } } `, ]; /** * Factory method to create and show a context menu */ public static createAndShow( items: IContextMenuItem[], x: number, y: number, isTouch = false ): DeesMobileContextmenu { // Remove any existing context menu const existing = document.querySelector('dees-mobile-contextmenu'); if (existing) { existing.remove(); } // Create new menu const menu = document.createElement('dees-mobile-contextmenu') as DeesMobileContextmenu; menu.items = items; menu.x = x; menu.y = y; menu.isTouch = isTouch; // Add to document document.body.appendChild(menu); // Position after render to handle viewport bounds requestAnimationFrame(() => { menu.adjustPosition(); }); // Close on outside click const handleClick = (e: MouseEvent) => { if (!e.composedPath().includes(menu)) { menu.close(); document.removeEventListener('click', handleClick, true); } }; // Add listener on next tick to avoid immediate close setTimeout(() => { document.addEventListener('click', handleClick, true); }, 0); return menu; } private adjustPosition(): void { const rect = this.getBoundingClientRect(); const menuWidth = rect.width; const menuHeight = rect.height; const padding = 10; let adjustedX = this.x; let adjustedY = this.y; // Calculate available space in each direction const spaceTop = this.y - padding; const spaceBottom = window.innerHeight - this.y - padding; const spaceLeft = this.x - padding; const spaceRight = window.innerWidth - this.x - padding; // For touch interactions, prefer opening upward if there's space if (this.isTouch && spaceTop >= menuHeight) { // Open upward from touch point adjustedY = this.y - menuHeight; this.transformOrigin = 'bottom left'; // Adjust X if needed if (spaceRight < menuWidth && spaceLeft >= menuWidth) { adjustedX = this.x - menuWidth; this.transformOrigin = 'bottom right'; } } else { // Default behavior (open downward/rightward) // Flip horizontally if not enough space on right if (spaceRight < menuWidth && spaceLeft >= menuWidth) { adjustedX = this.x - menuWidth; this.transformOrigin = this.transformOrigin.replace('left', 'right'); } // Flip vertically if not enough space below if (spaceBottom < menuHeight && spaceTop >= menuHeight) { adjustedY = this.y - menuHeight; this.transformOrigin = this.transformOrigin.replace('top', 'bottom'); } } // Final boundary checks to keep menu fully visible adjustedX = Math.max(padding, Math.min(adjustedX, window.innerWidth - menuWidth - padding)); adjustedY = Math.max(padding, Math.min(adjustedY, window.innerHeight - menuHeight - padding)); this.style.left = `${adjustedX}px`; this.style.top = `${adjustedY}px`; // Update the menu's transform origin const menu = this.shadowRoot?.querySelector('.menu') as HTMLElement; if (menu) { menu.style.transformOrigin = this.transformOrigin; } } public close(): void { if (this.isClosing) return; this.isClosing = true; this.classList.add('closing'); // Wait for the next frame to ensure animation starts requestAnimationFrame(() => { // Listen for animation end const menu = this.shadowRoot?.querySelector('.menu'); if (menu) { menu.addEventListener( 'animationend', () => { this.remove(); }, { once: true } ); } else { // Fallback if menu not found setTimeout(() => this.remove(), 100); } }); } private handleItemClick(item: IContextMenuItem): void { if (!item.divider && item.action) { item.action(); this.close(); } } public render(): TemplateResult { return html` `; } }