Make dark/light mode toggle an accessible switch - #14739
Conversation
The toggle was an <a href=""> with a CSS-drawn icon: no accessible name (axe link-name, WCAG 2.4.4/4.1.2), link role for a button-like control (Space did not activate it), and mode state conveyed only by a background-image swap that never reaches the accessibility tree. Now a <button type="button" role="switch" aria-checked> with the localized toggle-dark-mode string as aria-label, in all three places the control is created: website navbar/sidebar (navdarktoggle.ejs), plain-document fallback (quarto-html-after-body.ejs), and multi-page dashboards (format-dashboard-page.ts). setColorSchemeToggle() keeps aria-checked in sync alongside the existing .alternate class.
Positive: the toggle renders as button[role=switch][aria-checked] with a localized aria-label on websites (static), multi-page dashboards (static), and plain documents (script-text regex, since the fallback toggle is injected at DOMContentLoaded). Guard rails: light-only documents still emit no toggle markup at all, and the reader-mode toggle and navbar tool links keep their existing anchor shape. All fail against the pre-fix source except the light-only guard, which passes on both (verified by stashing the fix).
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
The toggle is now a <button role="switch"> instead of an <a>, so locators using a.quarto-color-scheme-toggle no longer match. Verified locally: all 36 tests across the five affected specs pass.
cderv
left a comment
There was a problem hiding this comment.
Thanks, this is thorough. I went through the three creation paths on my side and the behavior holds up: the accessibility tree reports switch "Toggle dark mode" [checked=false] in the navbar, in the floating fallback and in the dashboard navbar, aria-checked follows the active scheme (including on a dark-by-default document, where it correctly starts at true), Space activates, and the icon geometry is unchanged next to the other navbar tools. Dropping the href="" also removes a small hazard, since quarto-nav.js was rewriting it into an absolute URL.
One thing I found that I think is worth deciding before merge. On a plain document with a light/dark theme pair, our own axe checker now reports a different violation rather than none:
---
title: Axe check
format:
html:
axe:
output: document
theme:
light: flatly
dark: darkly
---
Body text.
On main the report is Serious · WCAG 2.0 A (2.4.4, 4.1.2): Ensure links have discernible text on a. With this branch it becomes Moderate · Best Practice: Ensure all page content is contained by landmarks on button.
The cause is role="switch", not the button element. axe's region rule exempts elements whose role is button or link, but not switch or checkbox, and the fallback toggle is appended straight to document.body, outside any landmark. Keeping the button and dropping the role reports nothing at all, and swapping in role="checkbox" reports the same thing. The navbar and sidebar placements are fine since they sit inside nav, and the dashboards already have region hits of their own.
It is a best-practice rule and not a WCAG failure, so trading 4.1.2 for it is still a clear win. But it is the exact document shape from #13463, best-practice rules run by default when standard: is unset, and the description says no new violations. We could wrap the floating toggle in a named landmark, or keep it as is and note the trade in the description. Did you look at that case maybe ?
Two smaller things:
- In
quarto-html-after-body.ejsthe created element is still namedanow that it is a button. - The new website fixture declares a navbar tool with
icon:and notext:, which renders astitle="" aria-label=""and shows up as an unnamed link in the accessibility tree. That is existing behavior and not something this PR changes, but the fixture bakes in an example of it. Addingtext:there would keep the fixture clean, and the emptyaria-labelprobably deserves its own issue.
Closes #13463.
Description
The dark/light mode toggle was an
<a href="">with a CSS-drawn icon, which fails all three prongs of WCAG 4.1.2 Name, Role, Value: no accessible name (the axelink-nameviolation reported in #13463), a link role for a button-like control (so Space doesn't activate it), and mode state conveyed only by abackground-imageswap that never reaches the accessibility tree — a screen-reader user gets no feedback on which mode is active, before or after toggling.This PR makes the toggle a WAI-ARIA APG switch —
<button type="button" role="switch" aria-checked>with the existing localizedtoggle-dark-modestring asaria-label— in all three places the control is created:navdarktoggle.ejs; keeps its existingtitletooltip)quarto-html-after-body.ejs)format-dashboard-page.ts, which now receivesformat.language)Why
<button>rather than ARIA on the existing<a>:role="switch"is permitted ona[href], but ARIA only changes what's announced, not how the element behaves: Space would still scroll the page instead of activating (links respond to Enter only), the emptyhrefwould still navigate/reload if JS hasn't bound yet, and link affordances (middle-click, "Open in New Tab", status-bar URL) would still apply. Making it a real<button>gets correct focus and Enter+Space activation natively, at the one-time cost of a selector change (the five Playwright specs updated here; custom CSS targetinga.quarto-color-scheme-toggleshould now target thebutton).setColorSchemeToggle()keepsaria-checkedin sync alongside the existing.alternateclass, so screen readers announce "Toggle dark mode, switch, off/on" and announce the state change on activation. A small SCSS reset (button.quarto-color-scheme-toggle) neutralizes UA button styling; all existing toggle styling is class-based, so visuals are unchanged (verified by screenshot in navbar, dashboard navbar, and floating placements).Behavior checked as unchanged: light-only documents still emit no toggle at all; the reader-mode toggle and navbar tool links keep their existing markup; the theme-switching JS itself is untouched apart from the one
aria-checkedline.Verified with axe-core (the
link-nameviolation clears; no new violations) and the Chrome accessibility tree (role=switch, name, andcheckedround-tripping on activation) for all three creation paths. Also verified with a manual VoiceOver pass across all placements — website navbar, website sidebar, dashboard navbar, and the floating fallback — plus the light-only control document (no toggle announced).Dark-by-default behavior verified with VoiceOver: the toggle announces the correct on/off state on load and after activation for author-dark-default (
theme:withdark:listed first), forrespect-user-color-scheme: trueunder both OS appearance settings, and when a previously chosen scheme is restored on reload.No documentation update needed: the quarto.org docs describe the toggle at the author-facing level (placement and behavior, both unchanged) and never reference its markup.
Checklist
I have (if applicable):
AI-assisted PR