Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/414-language-switch-aria.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/views/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface LayoutOptions {
function languageSwitch(locale: Locale, path: string): string {
const encoded = encodeURIComponent(path)
const item = (value: Locale, label: string) =>
`<a href="/lang/${value}?next=${encoded}" data-lang-btn="${value}" aria-pressed="${value === locale}"${value === locale ? ' aria-current="true"' : ''}>${label}</a>`
`<a href="/lang/${value}?next=${encoded}" data-lang-btn="${value}"${value === locale ? ' aria-current="true"' : ''}>${label}</a>`
return `<div class="lang" role="group" aria-label="Language">${item('zh', '中')}${item('en', 'EN')}</div>`
}

Expand Down
28 changes: 26 additions & 2 deletions test/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, /<a[^>]*aria-pressed/, 'aria-pressed is a button state; a link must not claim it')
assert.match(zh, /<a href="\/lang\/zh\?next=%2Fdashboard"[^>]*aria-current="true"/, 'the active language must still be announced')

const en = render('/dashboard')
assert.doesNotMatch(en, /<a[^>]*aria-pressed/, 'the inactive link must not claim the button state either')
assert.match(en, /<a href="\/lang\/en\?next=%2Fdashboard"[^>]*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')
})
Loading