Skip to content

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

Draft
cwickham wants to merge 4 commits into
mainfrom
fix/13463-color-scheme-toggle-switch
Draft

Make dark/light mode toggle an accessible switch#14739
cwickham wants to merge 4 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.
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

2 participants