212 lines
5.3 KiB
TypeScript
212 lines
5.3 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
}
|