From 24887d684032f6a4f2b16d560660910a03de474d Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 12:12:33 -0400 Subject: [PATCH] fix(a11y): make Settings and the Downloads tabs keyboard-reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a11y.1 is the audit's last Critical and reproduced exactly: seven config-section headers, seven bare `
`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 `
`s. Both now follow patterns the app already had: a real ` +
+
+ +
- ${this.expanded - ? html` -
-
- -
-
- ` - : nothing}
`; } diff --git a/frontend/src/components/downloads-view/downloads-view.ts b/frontend/src/components/downloads-view/downloads-view.ts index 893d56c..1566beb 100644 --- a/frontend/src/components/downloads-view/downloads-view.ts +++ b/frontend/src/components/downloads-view/downloads-view.ts @@ -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.

-
-
(this.tab = 'requests')} - > - Requests -
-
(this.tab = 'downloads')} - > - Downloads -
+
+ ${DownloadsView.TABS.map( + ([id, label]) => html` + + `, + )}
- ${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()} +
+ ${this.tab === 'requests' ? this.renderRequests() : this.renderDownloads()} +
`; } + /** + * 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 `
`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 = [ + ['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(`#tab-${this.tab}`) + ?.focus(); + }); + }; + // ----------------------------------------------------------------- // Requests tab // ----------------------------------------------------------------- diff --git a/frontend/src/services/keyboard-shortcut-service.ts b/frontend/src/services/keyboard-shortcut-service.ts index e271274..63f85dd 100644 --- a/frontend/src/services/keyboard-shortcut-service.ts +++ b/frontend/src/services/keyboard-shortcut-service.ts @@ -411,11 +411,9 @@ async function dispatch(action: string): Promise { ); break; - case 'tracklist.delete': - document.dispatchEvent( - new CustomEvent('shortcut:tracklist-delete'), - ); - break; + // No `tracklist.delete`: it dispatched an event nothing + // listened for, from a binding Settings advertised as + // configurable. See backend/shortcuts/config.go. // Panel-specific: autotag review. The view listens for these // while it is the view on screen, and for nothing while it is diff --git a/frontend/test/components/settings-reach.test.ts b/frontend/test/components/settings-reach.test.ts new file mode 100644 index 0000000..063fcbe --- /dev/null +++ b/frontend/test/components/settings-reach.test.ts @@ -0,0 +1,105 @@ +/** + * 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 `
` 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 `
`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(' 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(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(' tabs', () => { + it('is a tablist of tabs owning a panel', async () => { + const el = await fixture('downloads-view'); + + const tabs = shadowAll(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(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(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'); + }); +});