From d35c8e14930b35743d5e2ea911cec4fcc791423e Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 15 Nov 2026 02:50:53 +0000 Subject: [PATCH] fix(a11y): mark the active language with aria-current, not aria-pressed (#414) Replayed onto live main 4b481f05: public/app.css took a 3-way apply because #726 added the admin-hub rules to the same file; the change itself is the one line that moves the active-language selector from aria-pressed to aria-current. --- changelog.d/414-language-switch-aria.fixed.md | 1 + public/app.css | 2 +- src/views/layout.ts | 2 +- test/layout.test.ts | 28 +++++++++++++++++-- 4 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 changelog.d/414-language-switch-aria.fixed.md diff --git a/changelog.d/414-language-switch-aria.fixed.md b/changelog.d/414-language-switch-aria.fixed.md new file mode 100644 index 00000000..78ddb71e --- /dev/null +++ b/changelog.d/414-language-switch-aria.fixed.md @@ -0,0 +1 @@ +The language switch now marks the active language with `aria-current="true"` and no longer carries `aria-pressed` on its links. `aria-pressed` is defined for buttons, so screen readers either ignored it or announced a toggle the link cannot honour; the highlight that marked the current language moved to the new state, so nothing visible changes. diff --git a/public/app.css b/public/app.css index a3897e5f..add6689a 100644 --- a/public/app.css +++ b/public/app.css @@ -348,7 +348,7 @@ p { margin: 0 0 12px; } color: var(--muted-2); font-size: 12px; font-weight: 700; letter-spacing: 0.02em; } .lang a:hover { color: var(--text); text-decoration: none; } -.lang a[aria-pressed="true"] { background: rgba(123, 149, 255, 0.16); color: var(--accent); } +.lang a[aria-current="true"] { background: rgba(123, 149, 255, 0.16); color: var(--accent); } /* Issue 682: the theme choice is one 44px icon button with a small menu, not three text labels that read as "AutoLightDark" and consume a whole row segment. The summary shows the saved mode through `data-theme`; the menu options are plain diff --git a/src/views/layout.ts b/src/views/layout.ts index 0134854d..26f9e6dd 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -56,7 +56,7 @@ export interface LayoutOptions { function languageSwitch(locale: Locale, path: string): string { const encoded = encodeURIComponent(path) const item = (value: Locale, label: string) => - `${label}` + `${label}` return `
${item('zh', '中')}${item('en', 'EN')}
` } diff --git a/test/layout.test.ts b/test/layout.test.ts index 134b4ec0..80f0ee07 100644 --- a/test/layout.test.ts +++ b/test/layout.test.ts @@ -15,10 +15,10 @@ const headerHeights = JSON.parse(readFileSync(new URL('./fixtures/header-heights renders: { breakpoint: string; media: string | null; viewport: string; measured: number }[] } -function render(path: string, options: { admin?: boolean; active?: string } = {}): string { +function render(path: string, options: { admin?: boolean; active?: string; locale?: 'zh' | 'en' } = {}): string { const store = new Store(':memory:') const user = makeUser(store, { admin: options.admin === true }) - return layout({ title: 'x', viewer: { user, csrfToken: 'csrf-token' }, body: '', config: testConfig(), locale: 'en', path, active: options.active }) + return layout({ title: 'x', viewer: { user, csrfToken: 'csrf-token' }, body: '', config: testConfig(), locale: options.locale ?? 'en', path, active: options.active }) } function navAnchor(html: string, href: string): string { @@ -355,3 +355,27 @@ test('a tooltip is revealed by its own expanded state once the script runs, and assert.match(state?.body ?? '', /opacity:\s*1/, 'the state rule must set opacity') assert.match(jsText(), /dataset\.js\s*=\s*['"]1['"]/, 'the script must mark that it runs, or the fallback never hands the tooltip over to the state') }) + +// #414: `aria-pressed` is defined for `role="button"`, so on the language links +// assistive technology either ignores it or announces a toggle the link cannot +// honour — `aria-allowed-attr` in axe. The active language is already carried by +// `aria-current="true"`; the highlight has to be keyed on that state instead. +test('the language switch announces the active language with aria-current, not aria-pressed', () => { + const zh = render('/dashboard', { locale: 'zh' }) + assert.doesNotMatch(zh, /]*aria-pressed/, 'aria-pressed is a button state; a link must not claim it') + assert.match(zh, /]*aria-current="true"/, 'the active language must still be announced') + + const en = render('/dashboard') + assert.doesNotMatch(en, /]*aria-pressed/, 'the inactive link must not claim the button state either') + assert.match(en, /]*aria-current="true"/, 'the active language must still be announced') + + // The highlight is the only mark of the current language in the stylesheet, so + // dropping the attribute without moving the selector would silently lose it. + const rules = parseRules(cssText(), 'modern') + const current = declarationsFor(rules, '.lang a[aria-current="true"]', 1280) + const background = current.find((declaration) => declaration.property === 'background') + const color = current.find((declaration) => declaration.property === 'color') + assert.ok(background !== undefined, '.lang a[aria-current="true"] must carry the highlight background') + assert.ok(color !== undefined, '.lang a[aria-current="true"] must carry the accent colour') + assert.equal(lastValue(rules, '.lang a[aria-pressed="true"]', 'background', 1280), null, 'no rule may keep keying the highlight on the button state') +})