Files
yellowjacket/frontend/test/components/settings-reach.test.ts
T
logan 24887d6840 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.
2026-08-12 12:12:33 -04:00

106 lines
3.7 KiB
TypeScript

/**
* Settings is reachable, and Downloads' tabs are tabs.
*
* `a11y.1` is the last Critical in the accessibility audit: every
* `config-section` header was a bare `<div @click>` with no tabindex,
* no role and no `aria-expanded`, and every section defaults to
* collapsed — so every setting in the app sat behind a control that
* could not be tabbed to. `a11y.2` is the same bug one page over, in
* Downloads' two `<div class="tab">`s.
*
* Reproduced in the running app before either was fixed: seven
* sections, seven `DIV`s, `tabindex` and `role` null on all of them.
*/
import { describe, expect, it } from 'vitest';
import '@components/config-page/config-section';
import '@components/downloads-view/downloads-view';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
describe('<config-section> disclosure', () => {
it('is a button that reports its state', async () => {
const el = await fixture('config-section', { heading: 'Libraries' });
const header = shadow(el, '.header');
expect(header?.tagName).toBe('BUTTON');
expect(header?.getAttribute('aria-expanded')).toBe('false');
});
it('points aria-controls at a body that exists while collapsed', async () => {
const el = await fixture('config-section', { heading: 'Theme' });
const id = shadow(el, '.header')?.getAttribute('aria-controls');
const body = shadow(el, `#${id}`);
// The body renders unconditionally and is toggled with `hidden`:
// aria-controls has to name an element that is in the DOM, and the
// slot's light-DOM children exist either way.
expect(body).toBeTruthy();
expect((body as HTMLElement).hidden).toBe(true);
});
it('expands on activation and says so', async () => {
const el = await fixture('config-section', { heading: 'Theme' });
shadow<HTMLButtonElement>(el, '.header')?.click();
await update(el, {});
expect(shadow(el, '.header')?.getAttribute('aria-expanded')).toBe('true');
expect((shadow(el, '.body') as HTMLElement).hidden).toBe(false);
});
it('starts expanded when the host asks it to', async () => {
const el = await fixture('config-section', { heading: 'Libraries', open: true });
expect(shadow(el, '.header')?.getAttribute('aria-expanded')).toBe('true');
});
});
describe('<downloads-view> tabs', () => {
it('is a tablist of tabs owning a panel', async () => {
const el = await fixture('downloads-view');
const tabs = shadowAll<HTMLButtonElement>(el, '[role="tab"]');
const panel = shadow(el, '[role="tabpanel"]');
expect(shadow(el, '[role="tablist"]')).toBeTruthy();
expect(tabs).toHaveLength(2);
expect(tabs.map((t) => t.getAttribute('aria-selected'))).toEqual([
'true',
'false',
]);
expect(tabs[0]!.getAttribute('aria-controls')).toBe(panel?.id);
});
it('carries a roving tab stop, not two', async () => {
const el = await fixture('downloads-view');
const tabs = shadowAll<HTMLButtonElement>(el, '[role="tab"]');
expect(tabs.map((t) => t.tabIndex)).toEqual([0, -1]);
});
it('moves and activates on ArrowRight, wrapping', async () => {
const el = await fixture('downloads-view');
const tablist = shadow<HTMLElement>(el, '[role="tablist"]')!;
tablist.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
await update(el, {});
expect(
shadowAll(el, '[role="tab"]').map((t) => t.getAttribute('aria-selected')),
).toEqual(['false', 'true']);
expect(shadow(el, '[role="tabpanel"]')?.id).toBe('panel-downloads');
tablist.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
await update(el, {});
expect(shadow(el, '[role="tabpanel"]')?.id).toBe('panel-requests');
});
});