Compare commits

...
4 Commits
Author SHA1 Message Date
logan c13a920487 test(e2e): freeze the context menu's keyboard model
Build & publish Arch package / arch-package (push) Successful in 2m4s
CI / check (push) Successful in 2m29s
Search index maintenance / maintain-index (push) Successful in 6s
CI / e2e (push) Failing after 4m19s
Two of the three things that made it work are invisible to a component
test against hand-built markup: the real wa-dropdown-items have not set
their role when the host finishes updating, and the real wa-popup has
not positioned itself, so focus() on an item is a silent no-op. Both
produced a menu that opened and refused to take focus.
2026-08-12 11:15:04 -04:00
logan a3b35a4dab fix(a11y): express the type scale in rem so text resize does something
--yj-text-xs..xl were hardcoded px and are consumed by essentially
every component, so raising the OS or browser font size changed nothing
anywhere (WCAG 1.4.4, a11y.19). The values are identical at the default
16px root, and all six ui-visual baselines pass unchanged.

Verified in the running app rather than assumed: at a 24px root a track
cell goes 12px to 18px and a nav item 16px to 24px.

The same check confirms a11y.20, which is left unfixed and now
documented where the coupling lives: the row stays 33px while its text
grows to 18px, because four virtualized lists duplicate their row height
as the layout's _itemSize hint and carry contain: strict, which clips
rather than reflows. Fixing that means deriving _itemSize from a
measured row — a change to the scroll maths of four lists, not to a type
scale.
2026-08-12 11:09:25 -04:00
logan 1ed4167634 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.
2026-08-12 11:07:34 -04:00
logan 7912cdf23f fix(a11y): make the five hand-rolled dialogs real dialogs
Four autotag dialogs and the remove-library confirmation rendered a
plain overlay div: no role, no aria-modal, no focus trap, no focus
restore. The two gating an irreversible on-disk metadata rewrite left
focus wherever it was, so a screen-reader user could confirm 'this
rewrites audio files' without ever hearing the warning (a11y.4, a11y.16).

Five wa-dialog usages already did this correctly and confirmAction()
existed from Phase 3, so nothing new was invented: the three that are
pure confirmations became confirmAction() calls, and the two carrying
input became wa-dialogs in place. Verified in the running app — the
native dialog matches :modal, focus lands in the first field, Escape
closes and the view state follows.

autotag-view's last document keydown listener goes with them. It existed
only because its dialogs could not close themselves.
2026-08-12 11:07:23 -04:00
20 changed files with 1491 additions and 373 deletions

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import { test, expect } from '../support/fixtures.js';
/**
* The context menu is reachable, navigable and escapable without a
* mouse.
*
* `a11y.3`: the menu is the only route to Play, Add to Queue, Play Next,
* Add to Playlist, Favourite and Track Details, and it opened on
* `contextmenu` alone. The panel had no `role="menu"`, so the six
* `role="menuitem"`s Web Awesome sets were orphaned; nothing moved focus
* into it, nothing handled Arrow or Escape, and nothing restored focus.
*
* Frozen here because two of the three things that made it work are
* invisible to a component test running against hand-built markup: the
* real `wa-dropdown-item`s do not have their `role` yet when the host
* finishes updating, and the real `wa-popup` has not positioned itself,
* so `focus()` on an item is a silent no-op. Both produced a menu that
* opened and refused to take focus, which is exactly the bug this spec
* exists to catch.
*/
test.describe('the context menu without a mouse', () => {
/** The deep-focused element, described the way an AT would see it. */
const focused = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return {
role: el?.getAttribute('role') ?? '',
text: el?.textContent?.trim().slice(0, 40) ?? '',
};
});
/** The menu panel's own semantics, or null when it is not rendered. */
const panel = (page: import('@playwright/test').Page) =>
page.evaluate(() => {
const el = document
.querySelector('track-list')
?.shadowRoot?.querySelector('.context-menu-panel');
if (!el) return null;
return {
role: el.getAttribute('role'),
label: el.getAttribute('aria-label'),
items: el.querySelectorAll('[role="menuitem"]').length,
};
});
/** Tab to the track list's single roving stop. */
async function focusARow(app: import('@playwright/test').Page) {
for (let i = 0; i < 25; i += 1) {
await app.keyboard.press('Tab');
if ((await focused(app)).role === 'row') return;
}
throw new Error('never reached a track row');
}
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test('Shift+F10 on a row opens a menu and focuses its first item', async ({
app,
}) => {
await focusARow(app);
await app.keyboard.press('Shift+F10');
await expect.poll(() => panel(app)).toMatchObject({
role: 'menu',
label: 'Track actions',
});
// The row is where the key came from; the menu is where focus went.
await expect.poll(async () => (await focused(app)).role).toBe('menuitem');
});
test('the arrows move through the items and wrap', async ({ app }) => {
await focusARow(app);
await app.keyboard.press('Shift+F10');
await expect.poll(async () => (await focused(app)).role).toBe('menuitem');
const first = (await focused(app)).text;
await app.keyboard.press('ArrowDown');
const second = (await focused(app)).text;
expect(second).not.toBe(first);
await app.keyboard.press('ArrowUp');
expect((await focused(app)).text).toBe(first);
// Up from the first item wraps to the last, which is what a menu
// does and what a list does not.
await app.keyboard.press('ArrowUp');
expect((await focused(app)).text).not.toBe(first);
await app.keyboard.press('Escape');
});
test('Escape closes the menu and gives the row its focus back', async ({
app,
}) => {
await focusARow(app);
const row = (await focused(app)).text;
await app.keyboard.press('Shift+F10');
await expect.poll(async () => (await focused(app)).role).toBe('menuitem');
await app.keyboard.press('Escape');
await expect.poll(() => panel(app)).toBeNull();
await expect.poll(async () => (await focused(app)).role).toBe('row');
expect((await focused(app)).text).toBe(row);
});
});
@@ -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`
<div
class="context-menu-panel"
role="menu"
aria-label="Artist actions"
>
<wa-dropdown-item
@click=${() =>
@@ -1442,6 +1471,9 @@ export class ArtistsView
@keydown=${this.roving.handleKeydown}
>
<lit-virtualizer
role="listbox"
aria-label="Artists"
aria-multiselectable="true"
.items=${entries}
.renderItem=${(entry: ArtistEntry) => this.renderArtistCard(entry)}
.keyFunction=${(entry: ArtistEntry) => entry.artist.ID}
@@ -26,6 +26,8 @@ import { libraryStore } from '../../store/library-store';
import { notificationStore } from '../../store/notification-store';
import { describeError, explainError } from '../../utils/describe-error';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
type PendingItem = autotagservice.PendingItem;
type ScoreView = autotagservice.ScoreView;
@@ -1026,44 +1028,19 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
border: 1px solid currentColor;
}
/* ── Dialogs (carried over) ── */
/* ── Dialogs ──
The frame, the backdrop, the focus trap and the Escape
handling belong to wa-dialog; what is left here is the
content these two put inside it. */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
.dialog {
background: var(--yj-bg-surface, #222);
padding: 1.25rem;
border-radius: 6px;
min-width: 420px;
max-width: 560px;
border: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.1));
}
wa-dialog p { margin: 0 0 1rem 0; font-size: 0.9rem; }
.dialog-header { display: flex; align-items: center; margin-bottom: 0.75rem; }
.dialog-header h3 { margin: 0; }
.dialog-close {
margin-left: auto;
background: transparent;
color: var(--yj-text-secondary, #b3b3b3);
border: 0;
font-size: 1.2rem;
cursor: pointer;
padding: 0 0.25rem;
}
.dialog-close:hover { color: var(--yj-text-primary, #fff); }
.dialog p { margin: 0 0 1rem 0; font-size: 0.9rem; }
.dialog input[type="url"] {
.url-input {
width: 100%;
padding: 0.4rem;
font: inherit;
@@ -1074,16 +1051,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
box-sizing: border-box;
}
.dialog .row {
.row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* ── In-app search dialog ── */
.search-dialog { min-width: 480px; }
.search-dialog::part(dialog) { min-width: 480px; }
.search-kind {
display: flex;
@@ -1119,8 +1095,15 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
.search-result {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: inherit;
font: inherit;
padding: 0.45rem 0.5rem;
cursor: pointer;
border: 0;
border-bottom: 1px solid var(--yj-bg-overlay, rgba(255, 255, 255, 0.05));
border-radius: 4px;
}
@@ -1166,7 +1149,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
@state() private loading = false;
@state() private foldersLoading = false;
@state() private errorMessage = '';
@state() private dialog: 'none' | 'paste' | 'warning' | 'leave' | 'search' = 'none';
// Two of the four dialogs this view used to hand-roll were plain
// confirmations and are `confirmAction()` calls now; the two that
// remain carry input and so are `<wa-dialog>`s in the template.
@state() private dialog: 'none' | 'paste' | 'search' = 'none';
@state() private pasteURL = '';
// In-app MusicBrainz search ("suggest a candidate") dialog state.
@@ -1175,6 +1161,9 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
@state() private searchArtist = '';
@state() private searchResults: SearchHitView[] = [];
@state() private searchLoading = false;
/** Whether a search has actually been run, as opposed to the fields
* merely being seeded from the current folder. */
@state() private searchRan = false;
@state() private searchError = '';
@state() private queueMenuOpen = false;
// Collapsible sidebar sections. Pending stays expanded so the
@@ -1197,7 +1186,11 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
* when it is disconnected — which never happens, because the view
* is cached (see utils/view-lifecycle.ts). */
protected override onViewActivate(): void {
this.listenWhileActive(document, 'keydown', this.onKeydown as EventListener);
// This view owned one last document keydown listener, for Escape,
// because its dialogs were hand-rolled and nothing else would
// close them. They are `wa-dialog`s now and close themselves, so
// the shortcut service is the only thing on this page deciding
// what a key means — which is what Phase 1 was for.
this.listenWhileActive(
document,
'mousedown',
@@ -1391,49 +1384,42 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private onPasteKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') this.onPasteSubmit();
};
private onWarningCancel = () => { this.dialog = 'none'; };
private onWarningContinue = async () => {
if (!this.current) return;
/** Acknowledge the once-per-library "this rewrites files" warning,
* then apply. Returns without applying if the user backed out. */
private async confirmWarningThenApply(): Promise<void> {
const current = this.current;
this.markWarningAcked(this.current.libraryId);
if (!current) return;
// Unwrapped, a rejection here meant the lines after it — the
// one that closes the dialog — never ran, and the dialog sat
// there forever (errors.m6).
const library = current.libraryName || `library #${current.libraryId}`;
const ok = await confirmAction({
title: 'Heads up: this rewrites audio files',
message: `Applying autotag writes new metadata directly to every track in ${current.albumName}. `
+ 'The change lands on disk and is not automatically reversible. '
+ 'Files outside this library are not touched.',
impact: `This warning shows once per library. Continue to acknowledge for ${library}.`,
confirmLabel: 'Continue',
});
if (!ok) return;
this.markWarningAcked(current.libraryId);
// Unwrapped, a rejection here meant the lines after it never ran
// and the dialog sat there forever (errors.m6). The dialog closes
// itself now, but the apply still has to happen either way.
try {
await AckLibraryWarning(this.current.libraryId);
await AckLibraryWarning(current.libraryId);
} catch (err) {
console.error('autotag: could not record the warning ack', err);
this.errorMessage = describeError(
err,
'That acknowledgement could not be saved.',
);
} finally {
this.dialog = 'none';
}
await this.executeApply();
};
private onLeaveCancel = () => { this.dialog = 'none'; };
private onLeaveConfirm = async () => {
if (!this.current) return;
this.dialog = 'none';
try {
await LeaveAsIs(this.current.groupKey);
} catch (err) {
console.error('autotag: leave-as-is failed', err);
this.errorMessage = describeError(
err,
'That folder could not be left as it is.',
);
return;
}
await this.refreshAfterAction();
};
}
/** Open the in-app MB search dialog, seeding the query fields from
* the current folder's album/artist so the common case is one
@@ -1444,6 +1430,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
this.searchArtist = this.current?.albumArtist ?? '';
this.searchResults = [];
this.searchError = '';
// The query is seeded from the folder, so "no results" was true of
// a search nobody had run yet — visible the moment the dialog
// opens, under the fields still being filled in.
this.searchRan = false;
this.dialog = 'search';
}
@@ -1454,6 +1444,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
if (!query) return;
this.searchLoading = true;
this.searchError = '';
this.searchRan = true;
try {
this.searchResults = await SearchCandidates(
this.searchKind, query, this.searchArtist.trim(),
@@ -1734,9 +1725,11 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private async onApply(): Promise<void> {
if (!this.current || !this.score || this.score.candidates.length === 0) return;
if (!this.hasLibraryWarningBeenAcked(this.current.libraryId)) {
this.dialog = 'warning';
await this.confirmWarningThenApply();
return;
}
await this.executeApply();
}
@@ -1803,14 +1796,27 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private async onLeave(): Promise<void> {
if (!this.current) return;
if (this.topScore() < CONFIDENT_SCORE) {
this.dialog = 'leave';
const groupKey = this.current.groupKey;
return;
if (this.topScore() < CONFIDENT_SCORE) {
const top = Math.round(this.topScore() * 100);
const ok = await confirmAction({
title: 'Keep the current tags?',
message: `No candidate scored high enough to trust automatically (top is ${top}%). `
+ `“Leave as-is” marks the local tags on ${this.current.albumName} as correct `
+ 'and removes this folder from the review queue.',
impact: 'If the local tags are wrong, prefer Skip (S) instead — '
+ 'that leaves the folder pending for later review.',
confirmLabel: 'Mark as correct',
});
// The confirmation is modal, but it is still an await: the
// folder we asked about has to be the folder we act on.
if (!ok || this.current?.groupKey !== groupKey) return;
}
try {
await LeaveAsIs(this.current.groupKey);
await LeaveAsIs(groupKey);
} catch (err) {
console.error('autotag: leave-as-is failed', err);
this.errorMessage = describeError(
@@ -1858,18 +1864,6 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
}
/** Escape closes this view's hand-rolled dialogs. Everything else
* the page binds is a registered shortcut in the `autotag` panel
* scope, so the shortcut service is the only thing deciding what a
* key means — including on the pages this view is not on. */
private onKeydown = (e: KeyboardEvent): void => {
if (e.key !== 'Escape' || this.dialog === 'none') return;
e.preventDefault();
this.dialog = 'none';
this.pasteURL = '';
};
/* ── Version clustering ── */
/**
@@ -2948,112 +2942,41 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private renderPasteDialog() {
return html`
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
if (e.target === e.currentTarget) this.onPasteCancel();
}}>
<div class="dialog">
<div class="dialog-header">
<h3>Paste MusicBrainz URL</h3>
<button class="dialog-close" aria-label="Close"
@click=${this.onPasteCancel}>×</button>
</div>
<p>
Paste a MusicBrainz release or release-group URL.
Tracks are aligned automatically when it loads.
</p>
<input type="url"
placeholder="https://musicbrainz.org/release/..."
.value=${this.pasteURL}
@input=${this.onPasteInput}
@keydown=${this.onPasteKeydown}
autofocus>
<div class="row">
<button class="secondary" @click=${this.onPasteCancel}>Cancel</button>
<button @click=${this.onPasteSubmit}>Load</button>
</div>
<wa-dialog
label="Paste MusicBrainz URL"
data-testid="autotag-paste-dialog"
?open=${this.dialog === 'paste'}
@wa-hide=${this.onPasteCancel}
>
<p>
Paste a MusicBrainz release or release-group URL.
Tracks are aligned automatically when it loads.
</p>
<input type="url"
class="url-input"
aria-label="MusicBrainz release URL"
placeholder="https://musicbrainz.org/release/..."
.value=${this.pasteURL}
@input=${this.onPasteInput}
@keydown=${this.onPasteKeydown}
autofocus>
<div class="row" slot="footer">
<button class="secondary" @click=${this.onPasteCancel}>Cancel</button>
<button @click=${this.onPasteSubmit}>Load</button>
</div>
</div>
`;
}
private renderWarningDialog() {
if (!this.current) return nothing;
return html`
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
if (e.target === e.currentTarget) this.onWarningCancel();
}}>
<div class="dialog">
<div class="dialog-header">
<h3>Heads up: this rewrites audio files</h3>
<button class="dialog-close" aria-label="Close"
@click=${this.onWarningCancel}>×</button>
</div>
<p>
Applying autotag writes new metadata directly to every
track in <strong>${this.current.albumName}</strong>.
The change lands on disk and is not automatically
reversible. Files outside this library are not
touched.
</p>
<p>
This warning shows once per library. Click Continue to
acknowledge for
<strong>${this.current.libraryName || `library #${this.current.libraryId}`}</strong>.
</p>
<div class="row">
<button class="secondary" @click=${this.onWarningCancel}>Cancel</button>
<button @click=${this.onWarningContinue}>Continue</button>
</div>
</div>
</div>
`;
}
private renderLeaveDialog() {
if (!this.current) return nothing;
const top = this.score?.candidates[0];
const topPct = top ? Math.round(top.score * 100) : 0;
return html`
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
if (e.target === e.currentTarget) this.onLeaveCancel();
}}>
<div class="dialog">
<div class="dialog-header">
<h3>Keep the current tags?</h3>
<button class="dialog-close" aria-label="Close"
@click=${this.onLeaveCancel}>×</button>
</div>
<p>
No candidate scored high enough to trust automatically
(top is ${topPct}%). "Leave as-is" marks the local
tags on <strong>${this.current.albumName}</strong> as
correct and removes this folder from the review queue.
</p>
<p>
If the local tags are wrong, prefer <kbd>S</kbd> (Skip)
instead — that leaves the folder pending for later
review.
</p>
<div class="row">
<button class="secondary" @click=${this.onLeaveCancel}>Cancel</button>
<button @click=${this.onLeaveConfirm}>Mark as correct</button>
</div>
</div>
</div>
</wa-dialog>
`;
}
private renderSearchDialog() {
return html`
<div class="dialog-overlay" @click=${(e: MouseEvent) => {
if (e.target === e.currentTarget) this.onSearchCancel();
}}>
<div class="dialog search-dialog">
<div class="dialog-header">
<h3>Search MusicBrainz</h3>
<button class="dialog-close" aria-label="Close"
@click=${this.onSearchCancel}>×</button>
</div>
<wa-dialog
label="Search MusicBrainz"
class="search-dialog"
data-testid="autotag-search-dialog"
?open=${this.dialog === 'search'}
@wa-hide=${this.onSearchCancel}
>
<div class="search-kind">
<label>
<input type="radio" name="searchKind" value="releasegroup"
@@ -3069,55 +2992,54 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
</label>
</div>
<input type="text" class="search-input"
aria-label=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
placeholder=${this.searchKind === 'recording' ? 'Track title' : 'Album name'}
.value=${this.searchQuery}
@input=${(e: Event) => { this.searchQuery = (e.target as HTMLInputElement).value; }}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}
autofocus>
<input type="text" class="search-input"
aria-label="Artist (optional)"
placeholder="Artist (optional)"
.value=${this.searchArtist}
@input=${(e: Event) => { this.searchArtist = (e.target as HTMLInputElement).value; }}
@keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') void this.runSearch(); }}>
<div class="row">
<button class="secondary" @click=${this.onSearchCancel}>Cancel</button>
<button @click=${() => { void this.runSearch(); }}
?disabled=${this.searchLoading || this.searchQuery.trim() === ''}>
${this.searchLoading ? 'Searching…' : 'Search'}
</button>
</div>
${this.searchError
? html`<div class="error" style="margin-top:0.75rem;">${this.searchError}</div>`
: nothing}
${this.searchResults.length > 0 ? html`
<div class="search-results">
<div class="search-results" role="list">
${this.searchResults.map((hit) => html`
<div class="search-result"
<button type="button" class="search-result" role="listitem"
@click=${() => { void this.pickSearchResult(hit); }}>
<div class="sr-title">${hit.title}</div>
<div class="sr-sub">
<span>${hit.artist || '—'}</span>
${hit.detail ? html`<span class="sr-detail">${hit.detail}</span>` : nothing}
</div>
</div>
</button>
`)}
</div>
` : this.searchLoading || this.searchError || this.searchQuery.trim() === ''
? nothing
: html`<div class="search-empty">No results — try dropping the artist or switching Album/Track.</div>`}
</div>
</div>
` : this.searchRan && !this.searchLoading && !this.searchError
? html`<div class="search-empty">No results — try dropping the artist or switching Album/Track.</div>`
: nothing}
<div class="row" slot="footer">
<button class="secondary" @click=${this.onSearchCancel}>Cancel</button>
<button @click=${() => { void this.runSearch(); }}
?disabled=${this.searchLoading || this.searchQuery.trim() === ''}>
${this.searchLoading ? 'Searching…' : 'Search'}
</button>
</div>
</wa-dialog>
`;
}
// Both dialogs render unconditionally so `wa-dialog` owns opening and
// closing (and therefore the focus trap and the focus restore); the
// `open` property is what says which — mounting one on demand would
// put the element and its `showModal()` in the same update.
private renderDialog() {
switch (this.dialog) {
case 'paste': return this.renderPasteDialog();
case 'warning': return this.renderWarningDialog();
case 'leave': return this.renderLeaveDialog();
case 'search': return this.renderSearchDialog();
default: return nothing;
}
return html`${this.renderPasteDialog()}${this.renderSearchDialog()}`;
}
override render() {
@@ -38,6 +38,7 @@ import './config-field';
import './config-section';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
@@ -222,9 +223,10 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@state() private libraries: library.Info[] = [];
@state() private editingLibraryId: number | null = null;
@state() private editingName = '';
/** The library a removal is in flight for. The impact is computed
* before the confirmation rather than held here, so this is now
* "which row is busy" and nothing else. */
@state() private removingLibraryId: number | null = null;
@state() private removalImpact: library.RemovalImpact | null = null;
@state() private isRemoving = false;
@state() private activeMenuId: number | null = null;
@state() private concurrencyMode = 'auto';
@state() private indexStatus: explore.IndexStatus | null = null;
@@ -629,51 +631,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
);
}
/* Cancel confirmation dialog */
.cancel-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.cancel-dialog {
background: var(
--yj-bg-surface,
#2a2a2a
);
border: 1px solid
var(--yj-border, #444);
border-radius: 8px;
padding: 24px;
max-width: 420px;
width: 90%;
}
.cancel-dialog-title {
font-size: var(--yj-text-lg, 18px);
font-weight: 600;
margin-bottom: 12px;
}
.cancel-dialog-message {
font-size: var(--yj-text-sm, 14px);
color: var(
--yj-text-secondary,
#aaa
);
margin-bottom: 20px;
}
.cancel-dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn-primary {
background: var(
--yj-accent,
@@ -1158,34 +1115,58 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
this.editingName = (e.target as HTMLInputElement).value;
};
/**
* Say what will happen, then ask — through the one confirmation the
* app has, which is a `wa-dialog` and so brings the focus trap, the
* Escape handler and the focus restore the hand-rolled overlay this
* replaces had none of (a11y.16).
*/
private handleRemoveClick = async (id: number): Promise<void> => {
this.activeMenuId = null;
try {
const impact = await GetRemovalImpact(id);
this.removalImpact = impact;
this.removingLibraryId = id;
} catch (err) {
console.error('Failed to get removal impact:', err);
}
};
private handleConfirmRemove = async (): Promise<void> => {
if (this.removingLibraryId === null) return;
const id = this.removingLibraryId;
const lib = this.libraries.find((l) => l.id === id);
const libName = lib?.name ?? 'Library';
let impact: library.RemovalImpact | null = null;
this.isRemoving = true;
try {
impact = await GetRemovalImpact(id);
} catch (err) {
// Asking without the impact is worse than not asking at all,
// so this is a failure the user has to see rather than a
// confirmation with a blank consequence.
console.error('Failed to get removal impact:', err);
notificationStore.persistent({
key: 'library-remove',
title: 'Library not removed',
text: `Could not work out what removing “${libName}” would delete. ${describeError(err)}`,
detail: String(err),
});
return;
}
const ok = await confirmAction({
title: 'Remove library',
message: `Remove “${libName}”?`,
impact: `This deletes ${impact.trackCount} tracks, affects `
+ `${impact.playlistsAffected} playlists and removes `
+ `${impact.queueItemCount} queue items.`,
confirmLabel: 'Remove',
danger: true,
});
if (!ok) return;
await this.removeLibrary(id, libName);
};
private async removeLibrary(id: number, libName: string): Promise<void> {
this.removingLibraryId = id;
try {
const summary = await RemoveLibrary(id);
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
notificationStore.transient({
tone: 'success',
key: 'library-remove',
@@ -1193,9 +1174,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
});
void this.loadLibraries();
} catch (err) {
this.isRemoving = false;
this.removingLibraryId = null;
this.removalImpact = null;
console.error('Failed to remove library:', err);
notificationStore.persistent({
key: 'library-remove',
@@ -1204,13 +1183,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
detail: String(err),
});
}
};
private handleCancelRemove = (): void => {
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
};
}
private toggleOverflowMenu = (id: number, e: Event): void => {
e.stopPropagation();
@@ -2057,10 +2030,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Library section ---
private renderLibrarySection() {
const removingLib = this.libraries.find(
(l) => l.id === this.removingLibraryId,
);
return html`
<config-section
heading="Libraries"
@@ -2109,11 +2078,15 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${lib.trackCount} tracks
${this.removingLibraryId === lib.id
? 'Removing…'
: html`${lib.trackCount} tracks`}
</span>
<div class="overflow-wrapper">
<button
class="overflow-btn"
aria-label=${`Actions for ${lib.name}`}
?disabled=${this.removingLibraryId === lib.id}
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
>
\u22EF
@@ -2173,51 +2146,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@config-change=${this.handleConcurrencyChange}
></config-field>
${this.removingLibraryId !== null && this.removalImpact
? html`
<div
class="cancel-dialog-overlay"
@click=${this.handleCancelRemove}
>
<div
class="cancel-dialog"
@click=${(e: Event) => e.stopPropagation()}
>
<div class="cancel-dialog-title">
Remove Library
</div>
<div class="cancel-dialog-message">
Remove '${removingLib?.name}'?
This will delete
${this.removalImpact.trackCount}
tracks, affect
${this.removalImpact.playlistsAffected}
playlists, and remove
${this.removalImpact.queueItemCount}
queue items.
</div>
<div class="cancel-dialog-actions">
<button
class="btn-ghost"
?disabled=${this.isRemoving}
@click=${this.handleCancelRemove}
>
Cancel
</button>
<button
class="btn-danger"
?disabled=${this.isRemoving}
@click=${this.handleConfirmRemove}
>
${this.isRemoving
? html`<span class="spinner"></span> Removing\u2026`
: 'Remove'}
</button>
</div>
</div>
</div>
`
: nothing}
</config-section>
`;
}
@@ -46,7 +46,10 @@ import {
emitDragActive,
} from '@utils/drag-controller';
import type { DragPayload } from '@utils/drag-controller';
import { ContextMenuController } from '@utils/context-menu-controller.js';
import {
ContextMenuController,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
@@ -1050,6 +1053,26 @@ export class CoverGrid
private onGridAlbumKeydown = (
e: KeyboardEvent,
) => {
if (isContextMenuKey(e)) {
const target = this.resolveAlbumFromEvent(e);
const card =
e.composedPath().find(
(el): el is HTMLElement =>
el instanceof HTMLElement &&
el.classList.contains('album-card'),
);
if (!target || !card) return;
e.preventDefault();
e.stopPropagation();
this.contextMenuAlbumId = target.album.ID;
this.contextMenuTarget = { kind: 'album' };
this.ctxMenu.openFrom(card);
return;
}
if (e.key !== 'Enter' && e.key !== ' ') return;
const hit = this.resolveAlbumFromEvent(e);
@@ -1679,7 +1702,8 @@ export class CoverGrid
class=${classes}
tabindex=${this.roving.tabIndexFor(index)}
@focus=${() => this.roving.noteFocus(index)}
role="button"
role="option"
aria-selected=${this.selectedAlbums.has(album.ID)}
data-index=${index}
aria-label="${album.Name} by ${album.ArtistName}"
draggable="true"
@@ -1780,6 +1804,9 @@ export class CoverGrid
return html`
<lit-virtualizer
id="grid-single"
role="listbox"
aria-label="Albums"
aria-multiselectable="true"
.items=${this.buildGridEntries()}
.renderItem=${this.renderGridEntry}
.keyFunction=${(entry: GridEntry) => entry.album.ID}
@@ -1877,7 +1904,7 @@ export class CoverGrid
>
${ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<div class="context-menu-panel" role="menu" aria-label="Album actions">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
import { libraryStore } from '../../store/library-store';
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '../../store/explore-cache';
@@ -156,6 +157,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
static override styles = [
designTokens,
srOnly,
exploreLinkStyles,
css`
:host {
@@ -1319,6 +1321,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<page-header heading="Explore"></page-header>
<div class="sr-only" role="status" aria-live="polite">
${this.liveStatus()}
</div>
${this.renderSearchInput()}
${this.loading
? html`<div class="loading-indicator">Searching\u2026</div>`
@@ -1333,6 +1338,26 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
`;
}
/** "Searching…" and the error block were both silent (a11y.12). */
private liveStatus(): string {
if (this.error) return this.error;
if (this.loading) return 'Searching…';
const results = this.results;
if (!results) return '';
const count =
(results.artists?.length ?? 0)
+ (results.releaseGroups?.length ?? 0)
+ (results.recordings?.length ?? 0);
return count === 0
? 'No results.'
: `${count} result${count === 1 ? '' : 's'}.`;
}
private renderSearchInput() {
const placeholder =
this.searchMode === 'lyrics'
@@ -21,6 +21,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';
@@ -959,6 +960,21 @@ export class GenresView
);
};
/** Shift+F10 / ContextMenu on a focused card. */
private openGenreMenuFromKey(
e: KeyboardEvent,
genre: Genre,
): void {
const card = e.currentTarget as HTMLElement | null;
if (!card) return;
e.preventDefault();
e.stopPropagation();
this.contextMenuGenreName = genre.name;
this.ctxMenu.openFrom(card);
}
private async onContextMenuAction(
action: string,
) {
@@ -1036,7 +1052,7 @@ export class GenresView
data-index=${index}
tabindex=${this.roving.tabIndexFor(index)}
@focus=${() => this.roving.noteFocus(index)}
role="button"
role="option"
aria-label="${genre.name}"
aria-selected="${isSelected}"
style="
@@ -1055,6 +1071,15 @@ export class GenresView
genre,
)}
@keydown=${(e: KeyboardEvent) => {
if (isContextMenuKey(e)) {
this.openGenreMenuFromKey(
e,
genre,
);
return;
}
if (
e.key === 'Enter' ||
e.key === ' '
@@ -1109,6 +1134,8 @@ export class GenresView
? html`
<div
class="context-menu-panel"
role="menu"
aria-label="Genre actions"
>
<wa-dropdown-item
@click=${() =>
@@ -1295,6 +1322,9 @@ export class GenresView
@keydown=${this.roving.handleKeydown}
>
<lit-virtualizer
role="listbox"
aria-label="Genres"
aria-multiselectable="true"
.items=${entries}
.renderItem=${(entry: GenreEntry) => this.renderGenreCard(entry)}
.keyFunction=${(entry: GenreEntry) => entry.genre.name}
+38 -18
View File
@@ -3,6 +3,7 @@ import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
@@ -45,6 +46,7 @@ export class JobIndicator extends LitElement {
static override styles = [
designTokens,
srOnly,
jobStateStyles,
css`
:host {
@@ -337,36 +339,48 @@ export class JobIndicator extends LitElement {
`;
}
private renderTrigger() {
/**
* What the trigger says. Extracted so the live region can announce
* the same sentence — it swung between "Scanning Music", "3
* background jobs" and "Finished" in silence (a11y.12).
*/
private triggerLabel(): string {
const job = this.primaryJob;
const activeCount = jobStore.activeJobs.length;
if (activeCount > 1) return `${activeCount} background jobs`;
if (job && job.state === 'running') return job.title;
// "Scanning Music" would be a lie for a job that is paused or
// queued, so lead with the state instead.
if (job) return `${stateLabel(job)} · ${job.title}`;
return 'Finished';
}
private renderTrigger() {
const job = this.primaryJob;
const hasFailure = jobStore.failedJobs.length > 0;
let label: string;
if (activeCount > 1) {
label = `${activeCount} background jobs`;
} else if (job && job.state === 'running') {
label = job.title;
} else if (job) {
// "Scanning Music" would be a lie for a job that is paused
// or queued, so lead with the state instead.
label = `${stateLabel(job)} · ${job.title}`;
} else {
label = 'Finished';
}
const label = this.triggerLabel();
return html`
<button
class="trigger"
aria-haspopup="dialog"
aria-haspopup="true"
aria-expanded=${this.popoverOpen}
title="Background jobs"
@click=${this.onTriggerClick}
>
${this.renderRing(job)}
<span class="label">${label}</span>
${hasFailure ? html`<span class="alert-dot"></span>` : nothing}
${hasFailure
? html`<span
class="alert-dot"
role="img"
aria-label="A background job failed"
></span>`
: nothing}
</button>
`;
}
@@ -375,7 +389,10 @@ export class JobIndicator extends LitElement {
const finished = jobStore.finishedJobs;
return html`
<div class="panel" role="dialog" aria-label="Background jobs">
<!-- Not role="dialog": nothing moves focus into this, traps
Tab or handles Escape, so announcing a dialog that never
receives focus was a promise it does not keep (a11y.17). -->
<div class="panel" role="group" aria-label="Background jobs">
<div class="panel-header">
<span>Background jobs</span>
${finished.length > 0
@@ -415,6 +432,9 @@ export class JobIndicator extends LitElement {
override render() {
return html`
<div class="sr-only" role="status" aria-live="polite">
${this.triggerLabel()}
</div>
<wa-popup
placement="bottom-end"
distance="8"
@@ -11,6 +11,7 @@ import {
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
// H-17: at 200 px the artist truncated to "The Orchestra Of" while
// ~400 px of empty space sat between it and the transport controls.
@@ -74,7 +75,7 @@ export class NowPlaying extends LitElement {
private lastGeometryKey = '';
private geometryDirty = true;
static override styles = [designTokens, exploreLinkStyles, css`
static override styles = [designTokens, srOnly, exploreLinkStyles, css`
:host {
display: block;
position: relative;
@@ -287,9 +288,16 @@ export class NowPlaying extends LitElement {
override render() {
const track = this.player.currentTrack;
// Auto-advance changes the track with no announcement of any
// kind (a11y.12). The region is in both branches because it has
// to already exist when the *first* track arrives.
const announcement = track
? `Now playing: ${track.title}${track.artist ? ` by ${track.artist}` : ''}`
: '';
if (!track) {
return html`
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art">
<div class="cover-placeholder"><wa-icon name="music"></wa-icon></div>
@@ -311,6 +319,7 @@ export class NowPlaying extends LitElement {
const artistScrolling = this.shouldScroll('artist');
return html`
<div class="sr-only" role="status" aria-live="polite">${announcement}</div>
<div class="now-playing">
<div class="cover-art-wrapper">
<div
@@ -31,8 +31,10 @@ import type { SelectionHost } from '@utils/selection-controller';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
@@ -342,6 +344,51 @@ export class PlaylistDetails
);
}
/** The row holding the roving tab stop. Rows had no keyboard path at
* all before this: not focusable, so Shift+F10 had nowhere to fire
* from and the menu was right-click only (a11y.3). */
@state() private focusedIndex = 0;
private onRowKeydown(e: KeyboardEvent, trackIndex: number): void {
const count = this.tracks.length;
if (count === 0) return;
const row = e.currentTarget as HTMLElement | null;
if (isContextMenuKey(e) && row) {
e.preventDefault();
e.stopPropagation();
this.selection.handleContextMenu(String(trackIndex));
this.ctxMenu.openFrom(row);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
this.handleTrackDblClick(trackIndex);
return;
}
const next = nextRovingIndex(e.key, this.focusedIndex, count);
if (next === null) return;
e.preventDefault();
e.stopPropagation();
this.focusedIndex = next;
void focusRovingRow(
this,
this.virtualizer,
next,
(i) => `.track-item[data-index="${i}"]`,
);
}
private handleTrackDblClick(trackIndex: number) {
this.selection.clear();
@@ -1364,6 +1411,9 @@ export class PlaylistDetails
</div>
<lit-virtualizer
class="track-scroller"
role="listbox"
aria-label="Playlist tracks"
aria-multiselectable="true"
.items=${visibleTracks}
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
@@ -1397,6 +1447,12 @@ export class PlaylistDetails
return html`
<div
class=${classes}
role="option"
aria-selected=${selected}
data-index=${trackIndex}
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
draggable=${isPhantom
? 'false'
: 'true'}
@@ -1526,7 +1582,7 @@ export class PlaylistDetails
${this.ctxMenu.contextMenuOpen
? this.isPhantomSelection()
? html`
<div class="context-menu-panel">
<div class="context-menu-panel" role="menu" aria-label="Track actions">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
@@ -1556,7 +1612,7 @@ export class PlaylistDetails
</div>
`
: html`
<div class="context-menu-panel">
<div class="context-menu-panel" role="menu" aria-label="Track actions">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
@@ -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,
@@ -25,8 +25,10 @@ import type { SelectionHost } from '@utils/selection-controller';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
hasTrackPayload,
@@ -157,6 +159,9 @@ export class QueuePanel
}
};
/** The row holding the roving tab stop. */
@state() private focusedIndex = 0;
private panelWidth = DEFAULT_WIDTH;
private scrollbarDragging = false;
@@ -569,6 +574,7 @@ export class QueuePanel
virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu);
virtEl.addEventListener('dragstart', this.onDelegatedDragStart);
virtEl.addEventListener('dragend', this.onTrackDragEnd);
virtEl.addEventListener('keydown', this.onDelegatedKeydown);
this.delegationAttached = true;
}
@@ -651,6 +657,7 @@ export class QueuePanel
virtEl.removeEventListener('contextmenu', this.onDelegatedContextMenu);
virtEl.removeEventListener('dragstart', this.onDelegatedDragStart);
virtEl.removeEventListener('dragend', this.onTrackDragEnd);
virtEl.removeEventListener('keydown', this.onDelegatedKeydown);
}
this.delegationAttached = false;
}
@@ -871,6 +878,55 @@ export class QueuePanel
this.ctxMenu.openAt(e.clientX, e.clientY);
}
/**
* The queue's rows had no keyboard path at all: not focusable, so
* neither Enter nor Shift+F10 had anywhere to fire from, and the
* menu — which is the only way to reach most of what a queue row can
* do — was right-click only (a11y.3).
*/
private onDelegatedKeydown = (e: KeyboardEvent): void => {
const count = this.queue.tracks.length;
if (count === 0) return;
const row = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'.track-item',
);
if (isContextMenuKey(e) && row) {
e.preventDefault();
e.stopPropagation();
this.selection.handleContextMenu(String(this.focusedIndex));
this.ctxMenu.openFrom(row);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
this.selection.clear();
this.queue.playAtIndex(this.focusedIndex);
return;
}
const next = nextRovingIndex(e.key, this.focusedIndex, count);
if (next === null) return;
e.preventDefault();
e.stopPropagation();
this.focusedIndex = next;
this.selection.handleContextMenu(String(next));
void focusRovingRow(
this,
this.virtualizer,
next,
(i) => `.track-item[data-index="${i}"]`,
);
};
private onContextMenuAction(action: string) {
const indices =
this.selection.getSelectedIndices();
@@ -1473,6 +1529,10 @@ export class QueuePanel
data-testid="queue-row"
data-file-path=${track.filePath}
draggable="true"
role="option"
aria-selected=${selected}
aria-current=${active ? 'true' : 'false'}
tabindex=${index === this.focusedIndex ? 0 : -1}
>
<span class="track-position">
${index + 1}
@@ -1584,6 +1644,9 @@ export class QueuePanel
: html`
<lit-virtualizer
scroller
role="listbox"
aria-label="Queue"
aria-multiselectable="true"
.items=${tracks}
.renderItem=${this.renderTrackItem}
.keyFunction=${(track: QueueTrack) => track.id}
@@ -1602,7 +1665,7 @@ export class QueuePanel
>
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<div class="context-menu-panel" role="menu" aria-label="Queue track actions">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
@@ -22,8 +22,10 @@ import type { SelectionHost } from '@utils/selection-controller';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { focusRovingRow, nextRovingIndex } from '@utils/roving-rows';
import { FavoritesController } from '@store/controllers/favorites-controller';
import {
setDragPayload,
@@ -817,6 +819,51 @@ export class SmartPlaylistDetails
);
}
/** The row holding the roving tab stop. Rows had no keyboard path at
* all before this: not focusable, so Shift+F10 had nowhere to fire
* from and the menu was right-click only (a11y.3). */
@state() private focusedIndex = 0;
private onRowKeydown(e: KeyboardEvent, trackIndex: number): void {
const count = this.tracks.length;
if (count === 0) return;
const row = e.currentTarget as HTMLElement | null;
if (isContextMenuKey(e) && row) {
e.preventDefault();
e.stopPropagation();
this.selection.handleContextMenu(String(trackIndex));
this.ctxMenu.openFrom(row);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
this.handleTrackDblClick(trackIndex);
return;
}
const next = nextRovingIndex(e.key, this.focusedIndex, count);
if (next === null) return;
e.preventDefault();
e.stopPropagation();
this.focusedIndex = next;
void focusRovingRow(
this,
this.virtualizer,
next,
(i) => `.track-item[data-index="${i}"]`,
);
}
private handleTrackDblClick(trackIndex: number) {
this.selection.clear();
@@ -1264,6 +1311,9 @@ export class SmartPlaylistDetails
<div class="header-cell col-duration">Duration</div>
</div>
<lit-virtualizer
role="listbox"
aria-label="Smart playlist tracks"
aria-multiselectable="true"
.items=${visibleTracks}
.renderItem=${this.renderRow}
.keyFunction=${this.rowKey}
@@ -1297,6 +1347,12 @@ export class SmartPlaylistDetails
return html`
<div
class=${classes}
role="option"
aria-selected=${selected}
data-index=${trackIndex}
tabindex=${trackIndex === this.focusedIndex ? 0 : -1}
@keydown=${(e: KeyboardEvent) =>
this.onRowKeydown(e, trackIndex)}
draggable=${isPhantom ? 'false' : 'true'}
@click=${(e: MouseEvent) =>
this.handleTrackClick(
@@ -1372,7 +1428,7 @@ export class SmartPlaylistDetails
>
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<div class="context-menu-panel" role="menu" aria-label="Track actions">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
@@ -1,6 +1,7 @@
import { library } from '@go/models';
import { LitElement, html, svg, css, nothing } from 'lit';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import {
customElement,
property,
@@ -13,6 +14,7 @@ import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
@@ -231,6 +233,24 @@ export class TrackList
let next = this.focusedIndex;
// The menu is most of what a row can do, and it was reachable
// only by right-click (a11y.3).
if (isContextMenuKey(e)) {
const track = this.cachedSortedTracks[this.focusedIndex];
const row = e.target instanceof HTMLElement
? e.target.closest<HTMLElement>('[role="row"]')
: null;
if (!track || !row) return;
e.preventDefault();
e.stopPropagation();
this.selection.handleContextMenu(track.FilePath);
this.ctxMenu.openFrom(row);
return;
}
switch (e.key) {
case 'ArrowDown':
next = Math.min(this.focusedIndex + 1, last);
@@ -877,7 +897,7 @@ export class TrackList
this.requestUpdate();
};
static override styles = [designTokens, contextMenuStyles, exploreLinkStyles, css`
static override styles = [designTokens, srOnly, contextMenuStyles, exploreLinkStyles, css`
:host {
display: flex;
flex-direction: column;
@@ -1316,6 +1336,28 @@ export class TrackList
}
}
/**
* What a screen reader is told about this list, in a sentence.
*
* Loading, failed, empty and "n results for a search" were all
* silent — the list said them in text nobody was watching (a11y.12).
*/
private liveStatus(visible: number): string {
if (this.loadError) return this.loadError;
if (this.loadingTracks) return 'Loading tracks…';
if (this.tracks.length === 0) return 'No tracks.';
const term = this.searchCtrl.term.trim();
if (term === '') return '';
return visible === 0
? `No tracks match “${term}”.`
: `${visible} track${visible === 1 ? '' : 's'} match “${term}”.`;
}
/** Loading / failed / genuinely empty, said apart. */
private renderPlaceholder() {
if (this.loadError) {
@@ -1858,6 +1900,9 @@ export class TrackList
return html`
${this.renderPageHeader()}
<div class="sr-only" role="status" aria-live="polite">
${this.liveStatus(visibleTracks.length)}
</div>
${this.tracks.length === 0
? this.renderPlaceholder()
: html`
@@ -1866,19 +1911,31 @@ export class TrackList
role="grid"
aria-label="Tracks"
aria-rowcount=${visibleTracks.length}
aria-busy=${this.loadingTracks}
@keydown=${this.onListKeydown}
>
<div class="header-row" role="row">
<div role="columnheader"></div>
<div role="columnheader" aria-label="Favourite"></div>
${cols.map(
(col) => html`
<div
role="columnheader"
tabindex="0"
aria-sort=${this.sortField === col.id
? (this.sortDirection === 'asc' ? 'ascending' : 'descending')
: 'none'}
class="header-cell ${col.align === 'right' ? 'cell-right' : ''}"
@click=${() =>
this.onHeaderCellClick(
col.id,
)}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
e.stopPropagation();
this.onHeaderCellClick(col.id);
}}
>
<span>${col.label}</span>
${this.sortField === col.id
@@ -1930,7 +1987,7 @@ export class TrackList
>
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<div class="context-menu-panel" role="menu" aria-label="Track actions">
<wa-dropdown-item
@click=${() => this.onContextMenuAction('play')}
@mouseenter=${() => this.ctxMenu.closePlaylistSubmenu()}
+29
View File
@@ -0,0 +1,29 @@
import { css } from 'lit';
/**
* The visually-hidden class, and with it the rule for using one.
*
* A live region has to be **in the DOM before the text it announces
* is**: most screen readers announce a *change* to a region they are
* already watching, and ignore a region that appears with its content
* already in it. So these regions render unconditionally and empty, and
* only their text changes — which is why they are a class rather than a
* component that mounts on demand.
*
* `clip-path` rather than `display: none` or `visibility: hidden`, both
* of which take the element out of the accessibility tree along with the
* layout, which would defeat the point.
*/
export const srOnly = css`
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
`;
+24 -6
View File
@@ -14,11 +14,29 @@ export const designTokens = css`
--yj-icon-md: 18px;
--yj-icon-lg: 24px;
/* ── Type scale ── */
--yj-text-xs: 11px;
--yj-text-sm: 12px;
--yj-text-md: 13px;
--yj-text-lg: 15px;
--yj-text-xl: 18px;
/* ── Type scale ──
In rem, so it tracks the root font size: the scale was
hardcoded px and consumed by essentially every component, so
raising the OS or browser font size changed nothing anywhere
(WCAG 1.4.4, a11y.19). The values are the same at the default
16px root — 11/16, 12/16, 13/16, 15/16, 18/16 — so nothing
moves until someone asks it to.
The scale is coupled to four virtualized lists and does not
reach them (a11y.20). track-list's rows are 33px, queue-panel's
49px and both playlist detail views' 45px, each duplicated as
the layout's _itemSize hint so the scroll maths agrees with
the DOM; all of them also carry contain: strict, which clips
overflow rather than growing the row. So larger text reflows
the app but crops those rows, and fixing that means deriving
_itemSize from a measured row rather than from a constant.
Deliberately not done here: it is a change to the scroll maths
of four lists, not a change to a type scale. */
--yj-text-xs: 0.6875rem;
--yj-text-sm: 0.75rem;
--yj-text-md: 0.8125rem;
--yj-text-lg: 0.9375rem;
--yj-text-xl: 1.125rem;
}
`;
+235 -3
View File
@@ -33,6 +33,195 @@ export interface ContextMenuHost
/** Submenu close delay in milliseconds. */
const SUBMENU_CLOSE_DELAY = 150;
/** A menu item, focusable and clickable. Web Awesome sets `role` itself. */
type MenuItem = HTMLElement & { active?: boolean; disabled?: boolean };
/**
* Whether a keypress is the conventional "open the context menu" one.
* Shift+F10 is the long-standing binding; `ContextMenu` is the dedicated
* key on keyboards that have one.
*/
export function isContextMenuKey(e: KeyboardEvent): boolean {
return e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10');
}
/**
* The keyboard model for an open menu panel: focus the first item,
* Arrow/Home/End to move, Enter/Space to activate, Escape/Tab to close,
* and focus back where it came from.
*
* It is a standalone class rather than part of `ContextMenuController`
* because `playlist-view` renders a menu without using that controller,
* and the one thing worse than a menu with no keyboard model is two
* menus with two different ones.
*/
export class MenuKeyboard {
private panel: HTMLElement | null = null;
private restoreFocusTo: HTMLElement | null = null;
constructor(private readonly onClose: () => void) {}
/** Bind to a freshly-opened panel and focus its first item. */
open(panel: HTMLElement | null, opener?: HTMLElement | null): void {
if (!panel || this.panel === panel) return;
this.detach();
this.panel = panel;
this.restoreFocusTo = opener ?? deepActiveElement();
panel.addEventListener('keydown', this.onKeydown);
void this.focusFirstItem(panel);
}
/**
* Focus the first item, once the items are items.
*
* The host's `updateComplete` resolves before the `wa-dropdown-item`s
* inside the panel have run their own first update — and `role` is
* one of the things they set there. Querying by role at that moment
* finds nothing, which reads exactly like a menu that opened and
* refused to take focus.
*/
private async focusFirstItem(panel: HTMLElement): Promise<void> {
const candidates = [
...panel.querySelectorAll<MenuItem & { updateComplete?: Promise<boolean> }>(
'wa-dropdown-item, [role^="menuitem"]',
),
];
await Promise.all(candidates.map((el) => el.updateComplete ?? null));
// …and once the popup has positioned itself. `wa-popup` places the
// panel on an animation frame, and `focus()` on a not-yet-shown
// element is a silent no-op — which looks identical to a menu
// that opened and refused to take focus.
for (let attempt = 0; attempt < 3; attempt++) {
// Bail if the menu closed while we waited.
if (this.panel !== panel) return;
const first = this.items()[0];
this.focusItem(first);
if (first && panel.contains(deepActiveElement())) return;
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}
/**
* Unbind, and give focus back if the menu had it. A click elsewhere
* closes the menu too, and yanking focus back to the row the user
* right-clicked a moment ago is worse than leaving it alone.
*/
close(): void {
const restoreTo = this.restoreFocusTo;
const hadFocus = this.panel?.contains(deepActiveElement()) ?? false;
this.detach();
if (hadFocus && restoreTo?.isConnected) restoreTo.focus();
}
private detach(): void {
this.panel?.removeEventListener('keydown', this.onKeydown);
this.panel = null;
this.restoreFocusTo = null;
}
/** The enabled items, in DOM order. */
private items(): MenuItem[] {
if (!this.panel) return [];
return [
...this.panel.querySelectorAll<MenuItem>(
'wa-dropdown-item, [role^="menuitem"]',
),
].filter(
(item) =>
!item.disabled && item.getAttribute('aria-disabled') !== 'true',
);
}
private focusItem(item: MenuItem | undefined): void {
if (!item) return;
// `active` is what Web Awesome keys an item's tabindex and its
// highlight off, so moving focus without it leaves the highlight
// on whichever item the mouse last touched.
for (const other of this.items()) other.active = other === item;
item.tabIndex = 0;
item.focus();
}
private onKeydown = (e: KeyboardEvent): void => {
const items = this.items();
if (items.length === 0) return;
const current = items.findIndex(
(item) => item === e.target || item.contains(e.target as Node),
);
const move = (next: number): void => {
e.preventDefault();
e.stopPropagation();
this.focusItem(items[(next + items.length) % items.length]);
};
switch (e.key) {
case 'ArrowDown':
move(current + 1);
break;
case 'ArrowUp':
move(current - 1);
break;
case 'Home':
move(0);
break;
case 'End':
move(items.length - 1);
break;
case 'Escape':
case 'Tab':
// Tab closes rather than moving through the menu: the panel
// is a bare popup in the host's shadow root, so tabbing out
// of it lands in the page behind with the menu still open.
e.preventDefault();
e.stopPropagation();
this.onClose();
break;
case 'Enter':
case ' ':
// These items are in a `wa-popup`, not a `wa-dropdown`, so
// nothing upstream turns a keypress into an activation.
e.preventDefault();
e.stopPropagation();
items[current]?.click();
break;
default:
break;
}
};
}
/** The focused element, resolved through shadow roots. */
function deepActiveElement(): HTMLElement | null {
let el = document.activeElement as HTMLElement | null;
while (el?.shadowRoot?.activeElement) {
el = el.shadowRoot.activeElement as HTMLElement;
}
return el;
}
/**
* Reusable context menu controller that manages the open/close
* state of a wa-popup context menu with an optional playlist
@@ -116,6 +305,7 @@ export class ContextMenuController
hostDisconnected(): void {
this.detach();
this.keyboard.close();
this.clearSubmenuCloseTimer();
}
@@ -162,8 +352,13 @@ export class ContextMenuController
/**
* Open the context menu at the given screen
* coordinates using a virtual anchor.
*
* `opener` is where focus goes back to on close. It defaults to
* whatever was focused when the menu opened, which is right for a
* right-click (usually nothing) and for a keyboard open (the row).
*/
openAt(clientX: number, clientY: number): void {
openAt(clientX: number, clientY: number, opener?: HTMLElement | null): void {
this.pendingOpener = opener ?? deepActiveElement();
this.contextMenuOpen = true;
this.host.requestUpdate();
@@ -184,9 +379,27 @@ export class ContextMenuController
},
};
popup.active = true;
this.bindKeyboard();
});
}
/**
* Open the menu from an element rather than from a pointer — the
* Shift+F10 / ContextMenu-key path. Anchors to the element's own box
* so the menu appears where the thing it acts on is, and restores
* focus there on close.
*/
openFrom(el: HTMLElement): void {
const rect = el.getBoundingClientRect();
this.openAt(rect.left + 16, rect.top + rect.height / 2, el);
}
// =================================================================
// KEYBOARD
// =================================================================
/**
* Close the context menu and playlist submenu.
* Notifies the host via `onContextMenuClose()` so
@@ -195,12 +408,12 @@ export class ContextMenuController
close(): void {
if (!this.contextMenuOpen) return;
this.keyboard.close();
this.closePlaylistSubmenu();
this.contextMenuOpen = false;
this.playlistFilePaths = [];
const popup =
this.host.getContextMenuPopup();
const popup = this.host.getContextMenuPopup();
if (popup) {
popup.active = false;
@@ -210,6 +423,25 @@ export class ContextMenuController
this.host.requestUpdate();
}
/** The menu's keyboard model, shared with the one host that renders
* a context menu without this controller. */
private keyboard = new MenuKeyboard(() => this.close());
/** The element focus returns to, captured at open and handed to the
* keyboard model once the panel exists. */
private pendingOpener: HTMLElement | null = null;
private get panel(): HTMLElement | null {
const popup = this.host.getContextMenuPopup();
return popup?.querySelector('.context-menu-panel') ?? null;
}
private bindKeyboard(): void {
this.keyboard.open(this.panel, this.pendingOpener);
this.pendingOpener = null;
}
// =================================================================
// PLAYLIST SUBMENU
// =================================================================
+70
View File
@@ -0,0 +1,70 @@
/**
* A roving tab stop over a virtualized list of rows.
*
* Three lists needed the same thing at once — the queue panel and both
* playlist detail views — because a context menu opened with Shift+F10
* needs a focused row to open *from*, and none of the three had one:
* their rows were plain `<div>`s with no `tabindex` and no `role`.
*
* `track-list` deliberately does not use this. Its equivalent predates
* it, carries selection semantics (shift-extend, ctrl-toggle) that the
* other three do not have, and is pinned by its own tests; converting it
* would be a rewrite of the one list that already worked.
*
* Two things here are not optional:
*
* - **The virtualizer is told the index changed.** Rows come from the
* `virtualize` directive, which re-renders on the virtualizer's *own*
* properties — a host re-render does not move a `tabindex`.
* - **Focus is taken after the update.** The row for an index that was
* off-screen does not exist until the virtualizer has scrolled to it.
*/
import type { LitVirtualizer } from '@lit-labs/virtualizer';
export interface RovingRowsHost {
requestUpdate(): void;
updateComplete: Promise<boolean>;
}
/** The keys this handles, and what they mean given a row count. */
export function nextRovingIndex(
key: string,
current: number,
count: number,
): number | null {
switch (key) {
case 'ArrowDown':
return Math.min(current + 1, count - 1);
case 'ArrowUp':
return Math.max(current - 1, 0);
case 'Home':
return 0;
case 'End':
return count - 1;
default:
return null;
}
}
/**
* Move the tab stop to `index` and put focus on it.
*
* `rowSelector` receives the index and must return a selector matching
* that row inside the virtualizer's light DOM.
*/
export async function focusRovingRow(
host: RovingRowsHost,
virtualizer: LitVirtualizer | undefined,
index: number,
rowSelector: (index: number) => string,
): Promise<void> {
host.requestUpdate();
virtualizer?.requestUpdate();
virtualizer?.scrollToIndex(index, 'nearest');
await host.updateComplete;
virtualizer
?.querySelector<HTMLElement>(rowSelector(index))
?.focus();
}
+193
View File
@@ -0,0 +1,193 @@
/**
* The ARIA tail of `a11y.md`, pinned.
*
* Every assertion here is a finding that was reproduced in the running
* app first. Three of them are the kind that no other tier can see: a
* grid that sorts and never says so, a list whose loading/empty/failed
* states are text nobody is watching, and `aria-selected` on
* `role="button"`, which is not merely useless but *invalid* — the
* attribute is dropped, so the state the whole ctrl/shift interaction
* exists to produce was invisible.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/track-list/track-list';
import '@components/artists-view/artists-view';
import '@components/genres-view/genres-view';
import { emit, stub, flush, resetHarness } from '@test/support/harness';
import { Events } from '../../src/events';
import { fixture, shadow, shadowAll } from '@test/support/render';
import { searchStore } from '@store/search-store';
/**
* The searchable columns' accessors read these fields and call
* `.toLowerCase()` on the result, so a sparse fixture throws inside the
* ranker rather than failing an assertion. Real tracks always carry
* them; a fixture has to as well.
*/
const TRACKS = [
{
FilePath: '/m/a.mp3',
TrackName: 'Departure',
ArtistName: 'Aurora Fields',
Album: 'Glass Harbour',
AlbumArtist: 'Aurora Fields',
Composer: '',
Genre: ['Ambient'],
TrackLength: 4000,
},
{
FilePath: '/m/b.mp3',
TrackName: 'Tideline',
ArtistName: 'Aurora Fields',
Album: 'Glass Harbour',
AlbumArtist: 'Aurora Fields',
Composer: '',
Genre: ['Ambient'],
TrackLength: 6000,
},
];
const ARTISTS = [
{ ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 },
{ ID: 2, Name: 'Beta', AlbumCount: 1, TrackCount: 4 },
];
const GENRES = [
{ name: 'Ambient', count: 12 },
{ name: 'Doom', count: 3 },
];
function sized(el: HTMLElement): void {
el.style.display = 'block';
el.style.height = '600px';
el.style.width = '900px';
}
async function settle(el: LitElement): Promise<void> {
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 80));
}
describe('the track list says how it is sorted', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
emit(Events.LibraryScanComplete);
});
it('gives every column an aria-sort, defaulting to none', async () => {
const el = await fixture<LitElement>('track-list');
sized(el);
await settle(el);
const sorts = shadowAll(el, '.header-cell[role="columnheader"]').map((h) =>
h.getAttribute('aria-sort'),
);
expect(sorts.length, 'no sortable column headers').toBeGreaterThan(0);
// The list opens in file order — `sortField` is null — so no column
// claims to be the sort until one is chosen.
expect(sorts.every((s) => s === 'none')).toBe(true);
});
it('changes aria-sort when a column is activated from the keyboard', async () => {
const el = await fixture<LitElement>('track-list');
sized(el);
await settle(el);
const title = shadowAll(el, '.header-cell[role="columnheader"]').find((h) =>
/track name/i.test(h.textContent ?? ''),
);
expect(title, 'no Track Name column header').toBeTruthy();
// Reading the DOM synchronously after this would report the state
// *before* Lit rendered, which is how a fix for nothing gets shipped.
title!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }),
);
await settle(el);
const after = shadowAll(el, '.header-cell[role="columnheader"]').find((h) =>
/track name/i.test(h.textContent ?? ''),
);
expect(after?.getAttribute('aria-sort')).toBe('ascending');
});
});
describe('the track list has a voice for its own state', () => {
beforeEach(() => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllAlbums', []);
});
it('announces the result of a search that matches nothing', async () => {
stub('library.Library.GetAllTracks', TRACKS);
emit(Events.LibraryScanComplete);
const el = await fixture<LitElement>('track-list');
sized(el);
await settle(el);
const live = shadow(el, '[role="status"][aria-live="polite"]');
// The region exists *before* it has anything to say — a region that
// appears with its text already in it is not announced by most
// screen readers.
expect(live, 'no live region on the track list').toBeTruthy();
searchStore.setCurrentView('tracks');
searchStore.setTerm('nothing matches this');
await settle(el);
expect(shadow(el, '[role="status"]')?.textContent?.trim()).toMatch(
/No tracks match/i,
);
});
});
describe('a selectable grid is a listbox, not a row of buttons', () => {
beforeEach(() => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllArtists', ARTISTS);
stub('library.Library.GetAllGenresWithCounts', GENRES);
stub('library.Library.GetAllTracks', []);
stub('library.Library.GetAllAlbums', []);
emit(Events.LibraryScanComplete);
});
it.each([
['artists-view', '.artist-card'],
['genres-view', '.genre-card'],
])('%s cards are options carrying aria-selected', async (tag, cardSelector) => {
const el = await fixture<LitElement>(tag);
sized(el);
await settle(el);
const card = shadowAll(el, cardSelector)[0];
expect(card, `no cards rendered in ${tag}`).toBeTruthy();
// role=button + aria-selected is invalid: the attribute is dropped,
// and the selection is invisible to anything but a sighted user.
expect(card!.getAttribute('role')).toBe('option');
expect(card!.hasAttribute('aria-selected')).toBe(true);
const list = shadow(el, '[role="listbox"]');
expect(list, `${tag} has options with no listbox`).toBeTruthy();
expect(list!.getAttribute('aria-multiselectable')).toBe('true');
});
});
@@ -0,0 +1,176 @@
/**
* The context menu has a keyboard model, and it is one model.
*
* `a11y.3`: the panel is a bare `wa-popup` holding `wa-dropdown-item`s.
* Web Awesome gives each item `role="menuitem"`, but nothing gave the
* container `role="menu"`, nothing moved focus into it, nothing handled
* Arrow/Escape, and nothing restored focus. Play, Add to Queue, Play
* Next, Add to Playlist, Favourite and Track Details — most of which
* have no other route — were mouse-only.
*
* `MenuKeyboard` is that model, standalone rather than part of
* `ContextMenuController`, because `playlist-view` renders a menu
* without the controller and two menus with two keyboard models is the
* thing this is meant to prevent.
*
* The two non-obvious parts are pinned below, both of which cost a cycle
* when they were wrong:
*
* - The items are not items yet when the host's `updateComplete`
* resolves. `wa-dropdown-item` sets its `role` in its *own* first
* update, so a `[role^="menuitem"]` query at that moment finds
* nothing and the menu opens without taking focus.
* - Focus is only taken back on close if the menu had it. A click
* elsewhere closes the menu too, and pulling focus to the row the
* user right-clicked a moment ago would be worse than leaving it.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { MenuKeyboard, isContextMenuKey } from '@utils/context-menu-controller';
/** A panel of plain elements carrying the roles Web Awesome would set. */
function panelWith(labels: string[]): HTMLElement {
const panel = document.createElement('div');
panel.className = 'context-menu-panel';
panel.setAttribute('role', 'menu');
for (const label of labels) {
const item = document.createElement('div');
item.setAttribute('role', 'menuitem');
item.textContent = label;
panel.append(item);
}
document.body.append(panel);
return panel;
}
function press(target: EventTarget, key: string): void {
target.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true }),
);
}
/** The focused element, resolved the way the app resolves it. */
function active(): Element | null {
let el = document.activeElement;
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
return el;
}
describe('the context menu key', () => {
it('is Shift+F10 and the ContextMenu key, and nothing else', () => {
expect(isContextMenuKey(new KeyboardEvent('keydown', { key: 'ContextMenu' }))).toBe(
true,
);
expect(
isContextMenuKey(new KeyboardEvent('keydown', { key: 'F10', shiftKey: true })),
).toBe(true);
// F10 alone is a menu-bar convention we do not own.
expect(isContextMenuKey(new KeyboardEvent('keydown', { key: 'F10' }))).toBe(false);
expect(isContextMenuKey(new KeyboardEvent('keydown', { key: 'Enter' }))).toBe(false);
});
});
describe('MenuKeyboard', () => {
let panel: HTMLElement;
let opener: HTMLButtonElement;
let closed: number;
let keyboard: MenuKeyboard;
beforeEach(async () => {
closed = 0;
opener = document.createElement('button');
opener.textContent = 'The row';
document.body.append(opener);
opener.focus();
panel = panelWith(['Play', 'Add to Queue', 'Track Details']);
keyboard = new MenuKeyboard(() => {
closed++;
keyboard.close();
});
keyboard.open(panel, opener);
// Focus is taken across at least one frame, because a popup that has
// not positioned itself yet cannot be focused.
await new Promise((r) => requestAnimationFrame(r));
});
afterEach(() => {
keyboard.close();
panel.remove();
opener.remove();
});
it('focuses the first item on open', () => {
expect(active()?.textContent).toBe('Play');
});
it('moves with the arrows and wraps', () => {
press(active()!, 'ArrowDown');
expect(active()?.textContent).toBe('Add to Queue');
press(active()!, 'ArrowUp');
expect(active()?.textContent).toBe('Play');
// Up from the first item wraps to the last, which is what a menu
// does and what a listbox does not.
press(active()!, 'ArrowUp');
expect(active()?.textContent).toBe('Track Details');
});
it('goes to the ends with Home and End', () => {
press(active()!, 'End');
expect(active()?.textContent).toBe('Track Details');
press(active()!, 'Home');
expect(active()?.textContent).toBe('Play');
});
it('activates the focused item with Enter', () => {
let clicked = '';
for (const item of panel.querySelectorAll('[role="menuitem"]')) {
item.addEventListener('click', () => {
clicked = item.textContent ?? '';
});
}
press(active()!, 'ArrowDown');
press(active()!, 'Enter');
expect(clicked).toBe('Add to Queue');
});
it('closes on Escape and gives focus back to the opener', () => {
press(active()!, 'Escape');
expect(closed).toBe(1);
expect(active()).toBe(opener);
});
it('closes on Tab rather than letting focus escape the panel', () => {
press(active()!, 'Tab');
expect(closed).toBe(1);
expect(active()).toBe(opener);
});
it('leaves focus alone when the menu did not have it', () => {
const elsewhere = document.createElement('button');
document.body.append(elsewhere);
elsewhere.focus();
keyboard.close();
expect(active()).toBe(elsewhere);
elsewhere.remove();
});
});