Skip to content

Make dark/light mode toggle an accessible switch - #14739

Open
cwickham wants to merge 5 commits into
mainfrom
fix/13463-color-scheme-toggle-switch
Open

Make dark/light mode toggle an accessible switch#14739
cwickham wants to merge 5 commits into
mainfrom
fix/13463-color-scheme-toggle-switch

Conversation

@cwickham

@cwickham cwickham commented Jul 29, 2026

Copy link
Copy Markdown
Member

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 axe link-name violation reported in #13463), a link role for a button-like control (so Space doesn't activate it), and mode state conveyed only by a background-image swap 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 localized toggle-dark-mode string as aria-label — in all three places the control is created:

  • website navbar/sidebar (navdarktoggle.ejs; keeps its existing title tooltip)
  • plain documents with a light/dark theme pair (the floating fallback injected by quarto-html-after-body.ejs)
  • multi-page dashboards (format-dashboard-page.ts, which now receives format.language)

Why <button> rather than ARIA on the existing <a>: role="switch" is permitted on a[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 empty href would 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 targeting a.quarto-color-scheme-toggle should now target the button).

setColorSchemeToggle() keeps aria-checked in sync alongside the existing .alternate class, 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-checked line.

Verified with axe-core (the link-name violation clears; no new violations) and the Chrome accessibility tree (role=switch, name, and checked round-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: with dark: listed first), for respect-user-color-scheme: true under 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):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

cwickham added 2 commits July 29, 2026 10:45
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).
@posit-snyk-bot

posit-snyk-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

cwickham added 2 commits July 29, 2026 14:08
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.
@cwickham
cwickham marked this pull request as ready for review July 30, 2026 15:39
@cwickham
cwickham requested a review from cderv July 30, 2026 15:40

@cderv cderv left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ejs the created element is still named a now that it is a button.
  • The new website fixture declares a navbar tool with icon: and no text:, which renders as title="" 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. Adding text: there would keep the fixture clean, and the empty aria-label probably deserves its own issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a11y: dark-mode vs light-mode button violates axe-core

3 participants