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')
+})