Files
dees-wcctools/ts_web/elements/wcc-contextmenu.ts

212 lines
5.3 KiB
TypeScript
Raw Normal View History

import { DeesElement, property, html, customElement, type TemplateResult, state, css, cssManager } from '@design.estate/dees-element';
export interface IContextMenuItem {
name: string;
iconName?: string;
action: () => void | Promise<void>;
disabled?: boolean;
}
@customElement('wcc-contextmenu')
export class WccContextmenu extends DeesElement {
// Static method to show context menu at position
public static async show(
event: MouseEvent,
menuItems: IContextMenuItem[]
): Promise<void> {
event.preventDefault();
event.stopPropagation();
// Remove any existing context menu
const existing = document.querySelector('wcc-contextmenu');
if (existing) {
existing.remove();
}
const menu = new WccContextmenu();
menu.menuItems = menuItems;
menu.x = event.clientX;
menu.y = event.clientY;
document.body.appendChild(menu);
// Wait for render then adjust position if needed
await menu.updateComplete;
menu.adjustPosition();
}
@property({ type: Array })
accessor menuItems: IContextMenuItem[] = [];
@property({ type: Number })
accessor x: number = 0;
@property({ type: Number })
accessor y: number = 0;
@state()
accessor visible: boolean = false;
private boundHandleOutsideClick = this.handleOutsideClick.bind(this);
private boundHandleKeydown = this.handleKeydown.bind(this);
public static styles = [
css`
:host {
position: fixed;
z-index: 10000;
opacity: 0;
transform: scale(0.95) translateY(-5px);
transition: opacity 0.15s ease, transform 0.15s ease;
pointer-events: none;
}
:host(.visible) {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.menu {
min-width: 160px;
background: #0f0f0f;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
padding: 4px 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
font-size: 12px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
color: #ccc;
cursor: pointer;
transition: background 0.1s ease;
user-select: none;
}
.menu-item:hover {
background: rgba(59, 130, 246, 0.15);
color: #fff;
}
.menu-item.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
.menu-item .icon {
font-family: 'Material Symbols Outlined';
font-size: 16px;
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
opacity: 0.7;
}
.menu-item:hover .icon {
opacity: 1;
}
.menu-item .label {
flex: 1;
}
`
];
public render(): TemplateResult {
return html`
<div class="menu">
${this.menuItems.map(item => html`
<div
class="menu-item ${item.disabled ? 'disabled' : ''}"
@click=${() => this.handleItemClick(item)}
>
${item.iconName ? html`<span class="icon">${item.iconName}</span>` : null}
<span class="label">${item.name}</span>
</div>
`)}
</div>
`;
}
async connectedCallback() {
await super.connectedCallback();
// Delay adding listeners to avoid immediate close
requestAnimationFrame(() => {
document.addEventListener('click', this.boundHandleOutsideClick);
document.addEventListener('contextmenu', this.boundHandleOutsideClick);
document.addEventListener('keydown', this.boundHandleKeydown);
this.classList.add('visible');
});
}
async disconnectedCallback() {
await super.disconnectedCallback();
document.removeEventListener('click', this.boundHandleOutsideClick);
document.removeEventListener('contextmenu', this.boundHandleOutsideClick);
document.removeEventListener('keydown', this.boundHandleKeydown);
}
private adjustPosition() {
const rect = this.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let x = this.x;
let y = this.y;
// Adjust if menu goes off right edge
if (x + rect.width > windowWidth - 10) {
x = windowWidth - rect.width - 10;
}
// Adjust if menu goes off bottom edge
if (y + rect.height > windowHeight - 10) {
y = windowHeight - rect.height - 10;
}
// Ensure not off left or top
if (x < 10) x = 10;
if (y < 10) y = 10;
this.style.left = `${x}px`;
this.style.top = `${y}px`;
}
private handleOutsideClick(e: Event) {
const path = e.composedPath();
if (!path.includes(this)) {
this.close();
}
}
private handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
this.close();
}
}
private async handleItemClick(item: IContextMenuItem) {
if (item.disabled) return;
await item.action();
this.close();
}
private close() {
this.classList.remove('visible');
setTimeout(() => this.remove(), 150);
}
}