import { Component, ElementRef, computed, effect, inject, signal, viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { ContextMenuService, type MenuItem } from './context-menu.service'; import { FocusService } from './focus.service'; /** Rough per-item metrics, used only to decide whether the menu must flip. */ const ITEM_H = 26; const SEPARATOR_H = 7; const MENU_W = 224; // w-56 const EDGE_GAP = 6; /** * The panel right-click menu. Replaces electrobun's native ContextMenu, which * is a no-op on Linux — see ContextMenuService for the why. Emits the same * `ctx-*` / `col:*` action strings the native menu did. */ @Component({ selector: 'app-context-menu', imports: [NgIcon], template: ` @if (menu.request(); as req) {
} `, // Closing on scroll/blur matches native menu behavior: the anchor row moves // out from under a menu that would otherwise float over stale content. host: { '(window:blur)': 'close()', '(window:resize)': 'close()', '(document:scroll)': 'close()', }, }) export class ContextMenu { readonly menu = inject(ContextMenuService); private readonly focus = inject(FocusService); private readonly menuEl = viewChild>('menuEl'); readonly activeIndex = signal(-1); /** Click point, flipped/clamped so the menu never overflows the viewport. */ readonly position = computed(() => { const req = this.menu.request(); if (!req) return { x: 0, y: 0 }; const height = req.items.reduce((sum, i) => sum + (i.type === 'separator' ? SEPARATOR_H : ITEM_H), 0) + 8; const x = req.x + MENU_W > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - MENU_W) : req.x; const y = req.y + height > window.innerHeight - EDGE_GAP ? Math.max(EDGE_GAP, req.y - height) : req.y; return { x, y }; }); constructor() { // Focus the menu once it exists so arrow keys / Escape reach it. Keyed on // the viewChild signal, NOT on request(): under zoneless CD an effect can // run before the @if has rendered the element. effect(() => this.menuEl()?.nativeElement.focus()); } close(): void { if (!this.menu.request()) return; this.menu.close(); this.activeIndex.set(-1); // a fresh menu starts with nothing highlighted this.focus.bump(); } pick(item: MenuItem): void { if (!item.action) return; const action = item.action; this.close(); this.menu.onPick(action); } /** Right-clicking the backdrop closes rather than stacking a second menu. */ onBackdropContext(event: MouseEvent): void { event.preventDefault(); this.close(); } onKeydown(event: KeyboardEvent): void { const items = this.menu.request()?.items ?? []; switch (event.key) { case 'Escape': event.preventDefault(); event.stopPropagation(); this.close(); break; case 'ArrowDown': event.preventDefault(); this.step(items, 1); break; case 'ArrowUp': event.preventDefault(); this.step(items, -1); break; case 'Enter': case ' ': { event.preventDefault(); const item = items[this.activeIndex()]; if (item) this.pick(item); break; } } } /** Move the highlight by one selectable item, skipping separators, wrapping. */ private step(items: MenuItem[], delta: number): void { const count = items.length; if (!count) return; let index = this.activeIndex(); for (let hops = 0; hops < count; hops++) { index = (index + delta + count) % count; if (items[index].type !== 'separator') { this.activeIndex.set(index); return; } } } }