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.
The floating toggle on a plain document was appended straight to
document.body, outside every landmark. With role="switch" that trips
axe's region rule ("all page content contained by landmarks"): the rule
exempts role=button but not switch, so giving the toggle a name traded
the 4.1.2 link-name failure for a new best-practice finding on the exact
document shape from the issue.
The toggle now gets a container of its own, always a fresh direct child
of body. It is deliberately not moved into page furniture that the theme
or the author controls, because the toggle is positioned against the page
and any ancestor that establishes a containing block captures it. The
resulting shift is silent and visual. #title-block-header is both
positioned and a grid, so appending the toggle there draws it 258px from
the right edge instead of 17px. position: fixed does not make that safe
either: transform, filter, will-change and contain on an ancestor all
reintroduce the same failure, and theme or user CSS can set any of them.
A direct child of body has none of these to inherit.
Only the landmark type varies: a <header> (banner), or a labelled
div[role=region] when the page already has a banner. ARIA states that a
document should have at most one banner, and axe's
landmark-no-duplicate-banner counts banners without considering their
labels, so a second labelled banner would still be reported.
Detecting that existing banner needs care. A header is a banner wherever
it sits, unless it is inside sectioning content, and a plain div ancestor
does not strip the role: with `page-layout: custom` the title block
renders as body > div.page-layout-custom > header#title-block-header. So
the probe looks for a header anywhere, keeps an explicit role="banner"
unconditionally, and otherwise keeps a header only when it has no
sectioning-content ancestor.
The structural claims live in the Playwright spec rather than the smoke
test, and are asserted through the computed role with
getByRole("banner"). Asserting them through the selector the
implementation probes with cannot fail when that probe is wrong, and
asserting the selector text in the smoke test only pins the wording.
Verified with `quarto call axe` over three document shapes x 2 viewports
x light/dark: no banner or region findings. Toggle geometry is unchanged
in every shape (17px/17px, matching the pre-change position). The website
and dashboard placements are untouched, since the fallback only runs when
no toggle exists at all.
The custom-layout fixture still reports landmark-one-main and region.
Both come from `page-layout: custom` emitting no <main>: the same
document rendered from main reports both, plus the link-name violation
that this branch fixes.
Also renames the created element from `a` to `toggle`, left over from
when it was an anchor.
The fixture declared a tool with icon: and no text:, which renders as title="" aria-label="" and reads as an unnamed link. That is existing behavior rather than something this PR changes, but the fixture should not bake in an example of it -- `quarto call axe` reported it as a serious link-name violation on the fixture's own output. The empty aria-label on icon-only navbar tools deserves its own issue.
|
Good catch, and no, I had not looked at that case. Fixed now — I took the named landmark option. The fix. The toggle gets a landmark container of its own, always a fresh direct child of
Two implementation details worth knowing:
Tests. New Verified with Your two smaller points. Both done — the created element is Follow-up, not for this PR. #14838: the floating toggle is last in reading and focus order while drawn first — predates this change. |
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