import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/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 = 24; // 7.css: 4px padding x2 + ~15px line const DIVIDER_H = 7; // .has-divider:after: 2px bevel + margins const MENU_W = 200; const EDGE_GAP = 6; /** One rendered
  • : separators collapse onto the item BEFORE them. */ interface MenuRow { item: MenuItem; hasDivider: boolean; } /** * 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. * * The markup is 7.css's menu contract EXACTLY — `ul[role=menu] > * li[role=menuitem] > button` for actions, `> input[type=checkbox] + label` * for the checkable column toggles, and dividers as `.has-divider` on the * PRECEDING item (7.css draws them as an ::after bevel, not as a node). Get * any of that wrong and 7.css silently styles nothing: an earlier version * rendered div > button, concluded "7.css has no menu component", and * hand-built a lookalike. It does have one; the selectors just never matched. * * Keyboard follows the platform: arrows move REAL focus between items (7.css * styles :focus-visible identically to :hover), Enter/Space on a button * activate it natively; only labels need a synthetic click. */ @Component({ selector: 'app-context-menu', 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'); protected readonly menuWidth = MENU_W; /** Model items → rendered rows: a separator becomes has-divider on its predecessor. */ readonly rows = computed(() => { const items = this.menu.request()?.items ?? []; const rows: MenuRow[] = []; for (const item of items) { if (item.type === 'separator') { if (rows.length) rows[rows.length - 1].hasDivider = true; } else { rows.push({ item, hasDivider: false }); } } return rows; }); /** 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 rows = this.rows(); const height = rows.length * ITEM_H + rows.filter((r) => r.hasDivider).length * DIVIDER_H + 6; 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.focus.bump(); } pick(item: MenuItem, event?: Event): void { // labels: don't let the click also toggle the real checkbox — the fresh // `checked` state comes back through settings when the menu reopens event?.preventDefault(); 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 { switch (event.key) { case 'Escape': event.preventDefault(); event.stopPropagation(); this.close(); break; case 'ArrowDown': event.preventDefault(); this.step(1); break; case 'ArrowUp': event.preventDefault(); this.step(-1); break; case 'Enter': case ' ': { // buttons activate natively from their own focus; labels don't, so // synthesize the click (which routes through pick via the template) const active = document.activeElement; if (active instanceof HTMLLabelElement && this.menuEl()?.nativeElement.contains(active)) { event.preventDefault(); active.click(); } break; } } } /** * Move REAL focus by one item, wrapping. 7.css styles :focus-visible the * same as :hover, and keyboard-initiated programmatic focus counts as * focus-visible, so the highlight comes from the framework for free. */ private step(delta: number): void { const root = this.menuEl()?.nativeElement; if (!root) return; const targets = Array.from(root.querySelectorAll('li > button, li > label')); if (!targets.length) return; const current = targets.indexOf(document.activeElement as HTMLElement); const next = current < 0 && delta < 0 ? targets.length - 1 : (current + delta + targets.length) % targets.length; targets[next].focus(); } }