From f7f02083fe777e80ebe9087154aefa2f33de6fb6 Mon Sep 17 00:00:00 2001 From: Jokin Date: Wed, 23 Sep 2026 16:22:30 +0200 Subject: [PATCH] Carry over what only the win7 tree had: middle-click close, ARIA, menu fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/app/core/menu-nav.spec.ts | 27 +++++++++++++++++++ ui/src/app/core/menu-nav.ts | 15 +++++++++++ ui/src/app/skins/shadlike/context-menu.ts | 20 +++++--------- ui/src/app/skins/shadlike/tab-bar/tab-bar.ts | 23 ++++++++++++++++ .../shadlike/transfer-bar/transfer-bar.html | 9 ++++++- 5 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 ui/src/app/core/menu-nav.spec.ts create mode 100644 ui/src/app/core/menu-nav.ts diff --git a/ui/src/app/core/menu-nav.spec.ts b/ui/src/app/core/menu-nav.spec.ts new file mode 100644 index 0000000..a2e4d9c --- /dev/null +++ b/ui/src/app/core/menu-nav.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test'; +import { stepIndex } from './menu-nav'; + +// item, separator, item, disabled, item — the shape of a real row menu +const menu = [true, false, true, false, true]; + +test('a fresh menu: down lands on the first entry, up on the last', () => { + expect(stepIndex(menu, -1, 1)).toBe(0); + expect(stepIndex(menu, -1, -1)).toBe(4); +}); + +test('separators and disabled entries are skipped', () => { + expect(stepIndex(menu, 0, 1)).toBe(2); + expect(stepIndex(menu, 2, 1)).toBe(4); + expect(stepIndex(menu, 4, -1)).toBe(2); +}); + +test('wraps both ways', () => { + expect(stepIndex(menu, 4, 1)).toBe(0); + expect(stepIndex(menu, 0, -1)).toBe(4); +}); + +test('nothing selectable leaves the highlight alone', () => { + expect(stepIndex([false, false], -1, 1)).toBe(-1); + expect(stepIndex([false, false], -1, -1)).toBe(-1); + expect(stepIndex([], -1, -1)).toBe(-1); +}); diff --git a/ui/src/app/core/menu-nav.ts b/ui/src/app/core/menu-nav.ts new file mode 100644 index 0000000..17bfd70 --- /dev/null +++ b/ui/src/app/core/menu-nav.ts @@ -0,0 +1,15 @@ +/** + * Next highlighted menu index: skips unselectable entries and wraps. From -1 + * (nothing highlighted yet), down lands on the first selectable entry and up + * on the LAST — stepping up from -1 directly would land one short of it. + * Returns `from` unchanged when nothing is selectable. + */ +export function stepIndex(selectable: boolean[], from: number, delta: 1 | -1): number { + const count = selectable.length; + let index = from < 0 && delta < 0 ? 0 : from; + for (let hops = 0; hops < count; hops++) { + index = (index + delta + count) % count; + if (selectable[index]) return index; + } + return from; +} diff --git a/ui/src/app/skins/shadlike/context-menu.ts b/ui/src/app/skins/shadlike/context-menu.ts index a1c8c2a..4ff40d9 100644 --- a/ui/src/app/skins/shadlike/context-menu.ts +++ b/ui/src/app/skins/shadlike/context-menu.ts @@ -10,6 +10,7 @@ import { 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 @@ -38,6 +39,7 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size #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" @@ -50,6 +52,7 @@ const EDGE_GAP = 6; // px on purpose: a viewport-edge margin, not a UI size