import { Component, ElementRef, computed, effect, inject, signal, viewChild } from '@angular/core'; import { ContextMenuService, type MenuItem } from '@core/context-menu.service'; import { FocusService } from '@core/focus.service'; import { remPx } from '@core/metrics'; /** Rough per-item metrics in REM, used to size the menu and decide whether it * must flip. Rem because the interface scale is a root font-size lever: the * old `MENU_W = 200` was pinned into the DOM via [style.width.px], so the box * stayed 200px while the text inside grew with the scale and overflowed. * Values mirror the skinfix restatement of 7.css's menu metrics in 7like.css * (item padding 0.25rem x2 + the ~0.9375rem line). */ const ITEM_H_REM = 1.5; // 24px @16 const DIVIDER_H_REM = 0.4375; // 7px @16: 2px bevel + 3px/2px margins const MENU_W_REM = 12.5; // 200px @16 const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size /** 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'); /** Rendered width AND math input are the same rem value, so the flip point * and the box can never disagree — the shadlike menu's old bug. */ protected readonly menuWidthRem = MENU_W_REM; /** 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. * UI metrics convert through the live root font-size (remPx); the click * point and window.inner* are both viewport CSS px already. */ readonly position = computed(() => { const req = this.menu.request(); if (!req) return { x: 0, y: 0 }; const rows = this.rows(); const menuW = remPx(MENU_W_REM); const height = rows.length * remPx(ITEM_H_REM) + rows.filter((r) => r.hasDivider).length * remPx(DIVIDER_H_REM) + remPx(0.25); // ul padding 0.125rem x2 const x = req.x + menuW > window.innerWidth - EDGE_GAP ? Math.max(EDGE_GAP, req.x - menuW) : 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(); } }