Files
miniSCP/ui/src/app/skins/shadlike/context-menu.ts
T
jgamboaandClaude 9bd237d89c Give the right-click menu the darkest surface and Oxide's selection
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>
2026-10-05 10:29:22 +02:00

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