From 1ed4167634b4ba1d8445d553d35af34e3770e858 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 11:07:34 -0400 Subject: [PATCH] feat(a11y): give the context menu a keyboard, and the app a voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/artists-view/artists-view.ts | 34 ++- .../src/components/cover-grid/cover-grid.ts | 33 ++- .../components/explore-view/explore-view.ts | 25 ++ .../src/components/genres-view/genres-view.ts | 32 ++- frontend/src/components/jobs/job-indicator.ts | 56 +++-- .../src/components/now-playing/now-playing.ts | 11 +- .../playlist-details/playlist-details.ts | 60 ++++- .../components/playlist-view/playlist-view.ts | 52 +++- .../src/components/queue-panel/queue-panel.ts | 65 ++++- .../smart-playlist-details.ts | 58 ++++- .../src/components/track-list/track-list.ts | 63 ++++- frontend/src/styles/sr-only.css.ts | 29 +++ frontend/src/utils/context-menu-controller.ts | 238 +++++++++++++++++- frontend/src/utils/roving-rows.ts | 70 ++++++ frontend/test/components/aria-tail.test.ts | 193 ++++++++++++++ .../test/components/menu-keyboard.test.ts | 176 +++++++++++++ 16 files changed, 1160 insertions(+), 35 deletions(-) create mode 100644 frontend/src/styles/sr-only.css.ts create mode 100644 frontend/src/utils/roving-rows.ts create mode 100644 frontend/test/components/aria-tail.test.ts create mode 100644 frontend/test/components/menu-keyboard.test.ts diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index d697dbb..aba701b 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -24,6 +24,7 @@ import { queueStore } from '@store/queue-store'; import { ContextMenuController, contextMenuStyles, + isContextMenuKey, } from '@utils/context-menu-controller.js'; import type { ContextMenuHost } from '@utils/context-menu-controller.js'; import { FavoritesController } from '@store/controllers/favorites-controller'; @@ -948,6 +949,23 @@ export class ArtistsView ); }; + /** Shift+F10 / ContextMenu on a focused card, anchored to the card + * so the menu appears where the artist is and focus goes back + * there when it closes. */ + private openArtistMenuFromKey( + e: KeyboardEvent, + artist: library.Artist, + ): void { + const card = e.currentTarget as HTMLElement | null; + + if (!card) return; + + e.preventDefault(); + e.stopPropagation(); + this.contextMenuArtistId = artist.ID; + this.ctxMenu.openFrom(card); + } + private async onContextMenuAction( action: string, ) { @@ -1193,7 +1211,7 @@ export class ArtistsView data-index=${index} tabindex=${this.roving.tabIndexFor(index)} @focus=${() => this.roving.noteFocus(index)} - role="button" + role="option" aria-label="${artist.Name}" aria-selected="${isSelected}" style=" @@ -1212,6 +1230,15 @@ export class ArtistsView artist, )} @keydown=${(e: KeyboardEvent) => { + if (isContextMenuKey(e)) { + this.openArtistMenuFromKey( + e, + artist, + ); + + return; + } + if ( e.key === 'Enter' || e.key === ' ' @@ -1266,6 +1293,8 @@ export class ArtistsView ? html`