Carry over what only the win7 tree had: middle-click close, ARIA, menu fix

The deleted win7 tree held a few behaviours the modern tree never got:

- Context menu: the first ArrowUp in a fresh menu skipped the last item
  (Delete in the row menu, Disconnect in the tab menu) — stepping from -1
  landed one short. The index math moves to core/menu-nav.ts stepIndex(),
  checked by menu-nav.spec.ts (bun test; verified to fail on the old logic).
  Items get ids and the menu exposes aria-activedescendant, so screen
  readers follow the arrow keys while focus stays on the menu.
- Middle-click closes a tab, as in browsers. auxclick also fires for the
  right button, so only button 1 counts; closeWorkspace already refuses the
  last tab. A middle press is swallowed so WebView2 doesn't start autoscroll
  over the scrollable strip. The hover ✕ stays.
- ARIA: the tab strip is a tablist of role=tab buttons with aria-selected
  (the in-tab ✕ is taken out of the a11y tree and tab order — Ctrl/Cmd+W and
  the tab menu remain the keyboard paths); transfer bars are progressbars
  with value and a name.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:22:30 +02:00
co-authored by Claude
parent 2bac7678ca
commit f7f02083fe
5 changed files with 80 additions and 14 deletions
@@ -49,6 +49,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
>
<div
#tabScroll
role="tablist"
class="flex min-w-0 items-center gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
@for (ws of panels.workspaces(); track ws.id; let i = $index) {
@@ -59,6 +60,8 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
[class.opacity-50]="draggedId() === ws.id"
[style.box-shadow]="dropIndicatorShadow(i)"
(contextmenu)="onTabContextMenu($event, ws, i)"
(mousedown)="onTabMouseDown($event)"
(auxclick)="onAuxClick($event, ws.id)"
draggable="true"
(dragstart)="onDragStart($event, ws.id)"
(dragover)="onDragOver($event, i)"
@@ -66,6 +69,8 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
(dragend)="onDragEnd()"
>
<button
role="tab"
[attr.aria-selected]="ws.id === panels.activeWorkspaceId()"
(click)="panels.setActiveWorkspace(ws.id)"
class="flex min-w-0 items-center gap-1.5 py-1.5 pr-1 pl-2.5 text-xs"
[class.text-foreground]="ws.id === panels.activeWorkspaceId()"
@@ -83,6 +88,8 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
@if (panels.workspaces().length > 1) {
<button
(click)="close($event, ws.id)"
tabindex="-1"
aria-hidden="true"
[title]="i18n.t('tabBar.closeTab', { mod: modLabel })"
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
>
@@ -484,6 +491,22 @@ export class TabBar {
this.panels.closeWorkspace(id);
}
/**
* Middle-click closes the tab, as in browsers. `auxclick` also fires for the
* right button, hence the button check; the last tab is refused by
* closeWorkspace itself.
*/
onAuxClick(event: MouseEvent, id: number): void {
if (event.button !== 1) return;
event.preventDefault();
this.close(event, id);
}
/** A middle press over the scrollable strip would start WebView2's autoscroll. */
onTabMouseDown(event: MouseEvent): void {
if (event.button === 1) event.preventDefault();
}
/**
* Right-click a tab. The menu targets THIS tab, not the active one — its
* actions all carry the id — so right-clicking a background tab does not