fix(a11y): make Settings and the Downloads tabs keyboard-reachable
a11y.1 is the audit's last Critical and reproduced exactly: seven config-section headers, seven bare `<div @click>`s with no tabindex, no role and no aria-expanded, and every section collapsed by default — so every setting in the app was behind a control that could not be tabbed to. a11y.2 is the same bug in Downloads' two `<div class=tab>`s. Both now follow patterns the app already had: a real `<button aria-expanded aria-controls>` (explore-artist-details has five), and a role=tablist/tab/tabpanel with a roving tab stop and Left/Right/Home/End. The section body renders unconditionally and is toggled with `hidden`, because aria-controls has to name an element that exists and the slot's light-DOM children exist either way. H-22's reorder ships with them: Libraries is first and the only expanded section, Search Index — configured once, if ever — is second to last. The Playback/Audio section H-22 also asks for is deliberately not here: there is no output-device, gapless, crossfade or replay-gain setting in backend/config to expose, and a section of controls that do nothing is worse than admitting it does not exist. Settings also stops advertising `tracklist.delete`, which was bound to Delete and configurable in the UI while nothing listened for the event it dispatched.
This commit is contained in:
@@ -98,10 +98,18 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
@@ -261,25 +269,79 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
everything on the list immediately.
|
||||
</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div
|
||||
class="tab ${this.tab === 'requests' ? 'active' : ''}"
|
||||
@click=${() => (this.tab = 'requests')}
|
||||
>
|
||||
Requests
|
||||
</div>
|
||||
<div
|
||||
class="tab ${this.tab === 'downloads' ? 'active' : ''}"
|
||||
@click=${() => (this.tab = 'downloads')}
|
||||
>
|
||||
Downloads
|
||||
</div>
|
||||
<div
|
||||
class="tabs"
|
||||
role="tablist"
|
||||
aria-label="Downloads sections"
|
||||
@keydown=${this.onTabKeydown}
|
||||
>
|
||||
${DownloadsView.TABS.map(
|
||||
([id, label]) => html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id=${`tab-${id}`}
|
||||
class="tab ${this.tab === id ? 'active' : ''}"
|
||||
aria-selected=${this.tab === id ? 'true' : 'false'}
|
||||
aria-controls=${`panel-${id}`}
|
||||
tabindex=${this.tab === id ? 0 : -1}
|
||||
@click=${() => (this.tab = id)}
|
||||
>
|
||||
${label}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()}
|
||||
<div
|
||||
role="tabpanel"
|
||||
id=${`panel-${this.tab}`}
|
||||
aria-labelledby=${`tab-${this.tab}`}
|
||||
>
|
||||
${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tabs, in order, so the markup and the keyboard model read
|
||||
* the same list rather than each spelling it out (a11y.2: these
|
||||
* were two `<div @click>`s with no roles, no tabindex and no
|
||||
* keyboard path at all, which made the Downloads half of the
|
||||
* Downloads view mouse-only).
|
||||
*/
|
||||
private static readonly TABS: ReadonlyArray<readonly [Tab, string]> = [
|
||||
['requests', 'Requests'],
|
||||
['downloads', 'Downloads'],
|
||||
];
|
||||
|
||||
/**
|
||||
* A tablist moves with Left/Right/Home/End and activates as it
|
||||
* moves — the panel is already rendered, so there is nothing to
|
||||
* defer. Focus follows, which is what makes the roving tabindex
|
||||
* mean anything.
|
||||
*/
|
||||
private onTabKeydown = (e: KeyboardEvent): void => {
|
||||
const ids = DownloadsView.TABS.map(([id]) => id);
|
||||
const at = ids.indexOf(this.tab);
|
||||
|
||||
let next: number | null = null;
|
||||
if (e.key === 'ArrowRight') next = (at + 1) % ids.length;
|
||||
else if (e.key === 'ArrowLeft') next = (at - 1 + ids.length) % ids.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = ids.length - 1;
|
||||
|
||||
if (next === null) return;
|
||||
|
||||
e.preventDefault();
|
||||
this.tab = ids[next]!;
|
||||
void this.updateComplete.then(() => {
|
||||
this.shadowRoot
|
||||
?.querySelector<HTMLButtonElement>(`#tab-${this.tab}`)
|
||||
?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Requests tab
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user