feat(a11y): give the context menu a keyboard, and the app a voice

The context menu was the only route to Play, Add to Queue, Play Next,
Add to Playlist, Favourite and Track Details, and it opened on
right-click alone: the panel had no role=menu, so its six menuitems were
orphaned, nothing moved focus into it, and nothing handled arrows or
Escape (a11y.3). Phase 1 deferred this deliberately so it would land
with the dialogs, as one focus-management implementation.

MenuKeyboard is that model. It is standalone rather than part of
ContextMenuController because playlist-view renders a menu without the
controller, and the only thing worse than a menu with no keyboard model
is two menus with two of them. Shift+F10 and the ContextMenu key open it
from a focused row, anchored to that row, and focus returns there.

Three lists had no focused row to open it from, so they gained a roving
tab stop (utils/roving-rows.ts, written once rather than three times).
track-list keeps its own: it predates this, carries selection semantics
the other three do not have, and is pinned by its own tests.

Also the ARIA tail this is one story with: aria-sort on the column
headers (role=columnheader arrived in Phase 1 without it), listbox and
option on the four selectable grids — aria-selected on role=button is
invalid and was being dropped, so the state the whole ctrl/shift
interaction exists to produce was invisible — and live regions on the
four async surfaces that changed in silence.

Two things a reproduction taught that reading could not: the
wa-dropdown-items have not set their role when the host's updateComplete
resolves, so querying by role then finds nothing and the menu opens
without taking focus; and focus() on a popup that has not positioned
itself is a silent no-op.
This commit is contained in:
2026-08-12 11:07:34 -04:00
parent 7912cdf23f
commit 1ed4167634
16 changed files with 1160 additions and 35 deletions
@@ -25,7 +25,11 @@ import {
getActiveDragSource,
getActiveDragPlaylistId,
} from '@utils/drag-controller';
import { contextMenuStyles } from '@utils/context-menu-controller.js';
import {
MenuKeyboard,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
@@ -134,6 +138,13 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
@query('duplicate-tracks-dialog')
private duplicateDialog!: DuplicateTracksDialog;
/** This view renders its own context menu rather than using
* `ContextMenuController`, so it borrows just the keyboard model —
* which is the part that must not exist twice. */
private menuKeyboard = new MenuKeyboard(() =>
this.closePlaylistContextMenu(),
);
private closePlaylistCtxMenuHandler =
() => this.closePlaylistContextMenu();
@@ -1031,6 +1042,7 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
private handlePlaylistContextMenu = (
e: MouseEvent,
index: number,
opener?: HTMLElement,
) => {
e.preventDefault();
e.stopPropagation();
@@ -1061,13 +1073,39 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
},
};
popup.active = true;
this.menuKeyboard.open(
popup.querySelector('.context-menu-panel'),
opener,
);
}
});
};
/** Shift+F10 / ContextMenu on a focused playlist header. */
private handlePlaylistMenuKey(
e: KeyboardEvent,
index: number,
): void {
const header = e.currentTarget as HTMLElement | null;
if (!header) return;
const rect = header.getBoundingClientRect();
this.handlePlaylistContextMenu(
new MouseEvent('contextmenu', {
clientX: rect.left + 16,
clientY: rect.top + rect.height / 2,
}),
index,
header,
);
}
private closePlaylistContextMenu() {
if (!this.playlistContextMenuOpen) return;
this.menuKeyboard.close();
this.playlistContextMenuOpen = false;
this.playlistContextMenuIndex = -1;
@@ -1499,6 +1537,8 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
? html`
<div
class="context-menu-panel"
role="menu"
aria-label="Playlist actions"
>
${this.selectedPlaylists.size <= 1
? html`
@@ -1695,8 +1735,18 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
>
<div
class="playlist-header ${this.selectedPlaylists.has(index) ? 'selected' : ''}"
role="button"
tabindex="0"
aria-label=${`Playlist ${entry.summary.Name}`}
@click=${(e: MouseEvent) =>
this.handlePlaylistHeaderClick(e, index)}
@keydown=${(e: KeyboardEvent) => {
if (isContextMenuKey(e)) {
e.preventDefault();
e.stopPropagation();
this.handlePlaylistMenuKey(e, index);
}
}}
@contextmenu=${(e: MouseEvent) =>
this.handlePlaylistContextMenu(
e,