Stage 2: move the win7 component tree into app/skins/7like/
All 14 components (including title-bar/, which has no modern counterpart)
move under app/skins/7like/. git records every one as a rename — no content
changed except main.ts.
The @core alias from stage 1 paid off exactly as intended: not one of the 67
service imports needed touching despite every file changing depth. The only
relative imports left inside the tree are skin-internal (app -> its dialogs,
connection-manager -> title-bar), and those survived because the move
preserved their relative structure.
Added skins/7like/index.ts exporting SEVEN_ROOTS { main, manager, editor }.
Every skin will export that same shape, so main.ts can pick a tree by skin
and then a root by APP_MODE without knowing either skin's internals. main.ts
selects from that barrel now, with the second axis still hardcoded until the
shadlike tree lands.
Verified: tsc clean, build:ui clean, opaque interior pixel-identical to the
pre-refactor baseline. That last check also proves Tailwind still scans the
templates from their new skins/** paths — a silent utility purge would have
collapsed the layout rather than reproducing it exactly.
The three structural 7.css contracts that a careless move breaks silently are
intact by construction, since no markup changed: app-title-bar is still a
direct child of .window.w7-frame, nothing new nests inside menu[role=tablist],
and the context menu still emits ul[role=menu] > li[role=menuitem] > button.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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';
|
||||
|
||||
/** 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 <li>: 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) {
|
||||
<!-- 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>
|
||||
<ul
|
||||
#menuEl
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
(keydown)="onKeydown($event)"
|
||||
[style.left.px]="position().x"
|
||||
[style.top.px]="position().y"
|
||||
[style.width.px]="menuWidth"
|
||||
class="fixed z-50 outline-none"
|
||||
>
|
||||
@for (row of rows(); track $index) {
|
||||
<li role="menuitem" [class.has-divider]="row.hasDivider" [title]="row.item.tooltip ?? ''">
|
||||
@if (row.item.checked !== undefined) {
|
||||
<input type="checkbox" [id]="'ctx-check-' + $index" [checked]="row.item.checked" tabindex="-1" />
|
||||
<label [for]="'ctx-check-' + $index" tabindex="-1" (click)="pick(row.item, $event)">
|
||||
{{ row.item.label }}
|
||||
</label>
|
||||
} @else {
|
||||
<button type="button" (click)="pick(row.item)">
|
||||
{{ row.item.label }}
|
||||
@if (row.item.accelerator) {
|
||||
<span>{{ row.item.accelerator }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
`,
|
||||
// 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<HTMLUListElement>>('menuEl');
|
||||
|
||||
protected readonly menuWidth = MENU_W;
|
||||
|
||||
/** Model items → rendered rows: a separator becomes has-divider on its predecessor. */
|
||||
readonly rows = computed<MenuRow[]>(() => {
|
||||
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<HTMLElement>('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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user