The menu now sits on the darkest surface (the frame color) behind a faint border, and its highlighted item takes Oxide's selected style: a dark green fill (their surface-accent) with green text. Mouse and keyboard share that one highlight, so a hovered row and a keyboard-selected row can no longer light up at the same time. Co-Authored-By: Claude <noreply@anthropic.com>
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
import {
|
|
Component,
|
|
ElementRef,
|
|
computed,
|
|
effect,
|
|
inject,
|
|
signal,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { NgIcon } from '@ng-icons/core';
|
|
import { ContextMenuService, type MenuItem } from '@core/context-menu.service';
|
|
import { FocusService } from '@core/focus.service';
|
|
import { stepIndex } from '@core/menu-nav';
|
|
import { remPx } from '@core/metrics';
|
|
|
|
/** Rough per-item metrics in REM, used only to decide whether the menu must
|
|
* flip. Converted through remPx() at open time so the math tracks the same
|
|
* root font-size the CSS scales with — the old px constants (`MENU_W = 224`)
|
|
* were 16px-root mirrors of rem classes, so the box grew with the interface
|
|
* scale while the flip math still believed 224 and fired at the wrong edge. */
|
|
const ITEM_H_REM = 1.625; // px-3 py-1 row + --path-font line ≈ 26px @16
|
|
const SEPARATOR_H_REM = 0.4375; // my-1 + 1px border ≈ 7px @16
|
|
const MENU_W_REM = 14; // w-56
|
|
const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size
|
|
|
|
/**
|
|
* 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) {
|
|
<!-- click-outside catcher; mousedown so it beats the panel's own handlers -->
|
|
<div class="fixed inset-0 z-50" (mousedown)="close()" (contextmenu)="onBackdropContext($event)"></div>
|
|
<div
|
|
#menuEl
|
|
role="menu"
|
|
tabindex="-1"
|
|
[attr.aria-activedescendant]="activeIndex() >= 0 ? 'ctx-item-' + activeIndex() : null"
|
|
(keydown)="onKeydown($event)"
|
|
[style.left.px]="position().x"
|
|
[style.top.px]="position().y"
|
|
class="fixed z-50 w-56 border border-border/60 bg-chrome py-1 text-foreground shadow-xl outline-none"
|
|
>
|
|
@for (item of req.items; track $index) {
|
|
@if (item.type === 'separator') {
|
|
<div class="my-1 border-t border-border/60"></div>
|
|
} @else {
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
[attr.id]="'ctx-item-' + $index"
|
|
[title]="item.tooltip ?? ''"
|
|
[disabled]="!!item.disabled"
|
|
[class.bg-primary-surface]="$index === activeIndex() && !item.disabled"
|
|
[class.text-primary]="$index === activeIndex() && !item.disabled"
|
|
(mouseenter)="activeIndex.set($index)"
|
|
(click)="pick(item)"
|
|
class="flex w-full items-center gap-2 px-3 py-1 text-left text-(length:--path-font) disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
<span class="w-3.5 shrink-0">
|
|
@if (item.checked) {
|
|
<ng-icon name="tdesignCheck" size="0.85rem" class="text-primary" />
|
|
}
|
|
</span>
|
|
<span class="flex-1 truncate">{{ item.label }}</span>
|
|
@if (item.accelerator) {
|
|
<span class="shrink-0 text-muted-foreground">{{ item.accelerator }}</span>
|
|
}
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
`,
|
|
// 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<ElementRef<HTMLDivElement>>('menuEl');
|
|
|
|
readonly activeIndex = signal(-1);
|
|
|
|
/** Click point, flipped/clamped so the menu never overflows the viewport.
|
|
* All UI metrics convert through the live root font-size; window.inner* is
|
|
* viewport CSS px, so both sides of every comparison share units. */
|
|
readonly position = computed(() => {
|
|
const req = this.menu.request();
|
|
if (!req) return { x: 0, y: 0 };
|
|
const itemH = remPx(ITEM_H_REM);
|
|
const separatorH = remPx(SEPARATOR_H_REM);
|
|
const menuW = remPx(MENU_W_REM);
|
|
const height =
|
|
req.items.reduce((sum, i) => sum + (i.type === 'separator' ? separatorH : itemH), 0) +
|
|
remPx(0.5); // the menu's py-1 (0.25rem 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.activeIndex.set(-1); // a fresh menu starts with nothing highlighted
|
|
this.focus.bump();
|
|
}
|
|
|
|
pick(item: MenuItem): void {
|
|
// the keyboard path never consults the disabled attribute — guard here
|
|
if (item.disabled) return;
|
|
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 (see stepIndex). */
|
|
private step(items: MenuItem[], delta: 1 | -1): void {
|
|
const selectable = items.map((i) => i.type !== 'separator' && !i.disabled);
|
|
this.activeIndex.set(stepIndex(selectable, this.activeIndex(), delta));
|
|
}
|
|
}
|