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();
}
}