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 news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All changes included in 1.11:

## Accessibility

- ([#13463](https://github.com/quarto-dev/quarto-cli/issues/13463)): The dark/light mode toggle is now a switch (`button` with `role="switch"`, `aria-checked`, and a localized `aria-label`) instead of a nameless link, in all three places it is created: website navbars/sidebars, plain documents with a light/dark theme pair, and multi-page dashboards. Screen readers now announce the control's name, role, and which mode is active, and Space activates it as well as Enter. Custom CSS targeting `a.quarto-color-scheme-toggle` should now target the `button` element instead.
- ([#14615](https://github.com/quarto-dev/quarto-cli/issues/14615)): Fix invalid `role="menu"` on the website navbar's collapse toggle button, flagged by axe-core (`aria-allowed-role`) and WAVE (`aria_menu_broken`) when the navbar collapses to the hamburger at narrow viewports.

## Engines
Expand Down
15 changes: 12 additions & 3 deletions src/format/dashboard/format-dashboard-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* Copyright (C) 2020-2022 Posit Software, PBC
*/

import { kToggleDarkMode } from "../../config/constants.ts";
import { FormatLanguage } from "../../config/types.ts";
import { Document, Element } from "../../core/deno-dom.ts";
import { recursiveApplyFillClasses } from "./format-dashboard-layout.ts";
import {
Expand All @@ -25,7 +27,11 @@ interface NavItem {
scrolling: boolean;
}

export function processPages(doc: Document, dashboardMeta: DashboardMeta) {
export function processPages(
doc: Document,
dashboardMeta: DashboardMeta,
language: FormatLanguage,
) {
// Find the pages, if any
const pageNodes = doc.querySelectorAll(`.${kPageClass}`);
if (pageNodes.length === 0) {
Expand Down Expand Up @@ -64,10 +70,13 @@ export function processPages(doc: Document, dashboardMeta: DashboardMeta) {
// Add a dark mode toggle if needed
// If dark and light themes are provided, inject a toggle into the correct spot
if (dashboardMeta.hasDarkMode) {
const toggleEl = makeEl("a", {
const toggleEl = makeEl("button", {
classes: ["quarto-color-scheme-toggle"],
attributes: {
href: "",
type: "button",
role: "switch",
"aria-checked": "false",
"aria-label": language[kToggleDarkMode] || "Toggle dark mode",
onclick: "window.quartoToggleColorScheme(); return false;",
},
}, doc);
Expand Down
6 changes: 4 additions & 2 deletions src/format/dashboard/format-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
DependencyHtmlFile,
Format,
FormatExtras,
FormatLanguage,
kDependencies,
kHtmlPostprocessors,
kSassBundles,
Expand Down Expand Up @@ -154,7 +155,7 @@ export function dashboardFormat() {
extras.html[kHtmlPostprocessors] = extras.html[kHtmlPostprocessors] ||
[];
extras.html[kHtmlPostprocessors].push(
dashboardHtmlPostProcessor(dashboard),
dashboardHtmlPostProcessor(dashboard, format.language),
);

extras.metadata = extras.metadata || {};
Expand Down Expand Up @@ -317,6 +318,7 @@ registerWriterFormatHandler((format) => {

function dashboardHtmlPostProcessor(
dashboardMeta: DashboardMeta,
language: FormatLanguage,
) {
return (doc: Document): Promise<HtmlPostProcessResult> => {
const result: HtmlPostProcessResult = {
Expand Down Expand Up @@ -391,7 +393,7 @@ function dashboardHtmlPostProcessor(
processNavigation(doc);

// Process pages that may be present in the document
processPages(doc, dashboardMeta);
processPages(doc, dashboardMeta, language);

// Process Navbar buttons
processNavButtons(doc, dashboardMeta);
Expand Down
7 changes: 7 additions & 0 deletions src/resources/formats/html/bootstrap/_bootstrap-rules.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,13 @@ div.callout.callout-style-default > .callout-header {
}

// dark mode
button.quarto-color-scheme-toggle {
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}

.quarto-reader-toggle .bi::before,
.quarto-color-scheme-toggle .bi::before {
display: inline-block;
Expand Down
51 changes: 43 additions & 8 deletions src/resources/formats/html/templates/quarto-html-after-body.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,52 @@

// Ensure there is a toggle, if there isn't float one in the top right
if (window.document.querySelector('.quarto-color-scheme-toggle') === null) {
const a = window.document.createElement('a');
a.classList.add('top-right');
a.classList.add('quarto-color-scheme-toggle');
a.href = "";
a.onclick = function() { try { window.quartoToggleColorScheme(); } catch {} return false; };
const toggle = window.document.createElement('button');
toggle.type = "button";
toggle.setAttribute("role", "switch");
toggle.setAttribute("aria-checked", "false");
toggle.classList.add('top-right');
toggle.classList.add('quarto-color-scheme-toggle');
toggle.setAttribute("aria-label", "<%- language['toggle-dark-mode'] %>");
toggle.onclick = function() { try { window.quartoToggleColorScheme(); } catch {} return false; };

const i = window.document.createElement("i");
i.classList.add('bi');
a.appendChild(i);

window.document.body.appendChild(a);
toggle.appendChild(i);

// Put the toggle in a landmark of its own, so that landmark navigation
// can reach it.
//
// The container is always a fresh direct child of body, and the toggle is
// never moved into page furniture that the theme or the author controls.
// The toggle is positioned against the page, so any ancestor that
// establishes a containing block captures it: a positioned element or a
// grid for position: absolute, and transform, filter, will-change or
// contain for position: fixed. A direct child of body has none of these
// to inherit, and the shift when one appears is silent and visual.
//
// ARIA says a document should have at most one banner, and axe reports a
// duplicate whether or not the banners carry distinct labels. So use a
// header when the page has no banner yet, and a named region otherwise.
//
// A header is a banner wherever it sits, unless it is inside sectioning
// content, so this cannot just look at the children of body: a custom
// page layout wraps the title block header in a plain div. An explicit
// role="banner" always counts.
const sectioning = 'article, aside, main, nav, section, [role="article"], [role="complementary"], [role="main"], [role="navigation"], [role="region"]';
const hasBanner = Array.from(
window.document.querySelectorAll('header:not([role]), [role="banner"]')
).some(function(el) {
return el.hasAttribute("role") || el.closest(sectioning) === null;
});
const container = window.document.createElement(hasBanner ? "div" : "header");
if (hasBanner) {
container.setAttribute("role", "region");
container.setAttribute("aria-label", "<%- language['toggle-dark-mode'] %>");
}
container.classList.add('quarto-color-scheme-toggle-container');
container.appendChild(toggle);
window.document.body.appendChild(container);
}
setColorSchemeToggle(hasAlternateSentinel())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
for (let i=0; i < toggles.length; i++) {
const toggle = toggles[i];
if (toggle) {
toggle.setAttribute("aria-checked", alternate ? "true" : "false");
if (alternate) {
toggle.classList.add("alternate");
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/resources/projects/website/templates/navdarktoggle.ejs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<a href="" class="quarto-color-scheme-toggle <%= classes %>" onclick="window.quartoToggleColorScheme(); return false;" title="<%- language['toggle-dark-mode'] %>"><i class="bi"></i></a>
<button type="button" role="switch" aria-checked="false" class="quarto-color-scheme-toggle <%= classes %>" onclick="window.quartoToggleColorScheme(); return false;" title="<%- language['toggle-dark-mode'] %>" aria-label="<%- language['toggle-dark-mode'] %>"><i class="bi"></i></button>
13 changes: 13 additions & 0 deletions tests/docs/playwright/html/color-scheme-toggle-banner.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: "Fallback color-scheme toggle with an existing banner (#13463)"
title-block-banner: true
format:
html:
theme:
light: flatly
dark: darkly
---

`title-block-banner` makes the title block a page level `banner`, so the
floating color-scheme toggle must go into a named region instead of a second
banner.
13 changes: 13 additions & 0 deletions tests/docs/playwright/html/color-scheme-toggle-custom-layout.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
title: "Fallback color-scheme toggle with a custom page layout (#13463)"
page-layout: custom
format:
html:
theme:
light: flatly
dark: darkly
---

`page-layout: custom` wraps the title block header in a plain `div`, which does
not strip its implicit `banner` role. The page therefore already has a banner,
even though the header is not a child of `body`.
11 changes: 11 additions & 0 deletions tests/docs/playwright/html/color-scheme-toggle-plain.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
title: "Fallback color-scheme toggle without a banner (#13463)"
format:
html:
theme:
light: flatly
dark: darkly
---

This document has no page level banner, so the floating color-scheme toggle
gets a `header` of its own to sit in.
25 changes: 25 additions & 0 deletions tests/docs/smoke-all/dark-mode/color-scheme-toggle-dashboard.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
title: "Dashboard color-scheme toggle is a named switch (#13463)"
format:
dashboard:
theme:
light: flatly
dark: darkly
_quarto:
tests:
dashboard:
ensureHtmlElements:
-
- 'button.quarto-color-scheme-toggle[type="button"][role="switch"][aria-checked="false"][aria-label="Toggle dark mode"]'
-
- 'a.quarto-color-scheme-toggle'
---

# Page A

Multi-page so `processPages()` injects the navbar toggle (single-page
dashboards get the JS-injected fallback toggle instead).

# Page B

Second page.
33 changes: 33 additions & 0 deletions tests/docs/smoke-all/dark-mode/color-scheme-toggle-fallback.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
title: "Fallback color-scheme toggle is a named switch (#13463)"
format:
html:
theme:
light: flatly
dark: darkly
_quarto:
tests:
html:
ensureFileRegexMatches:
-
- "createElement\\('button'\\)"
- 'setAttribute\("role", "switch"\)'
- 'setAttribute\("aria-label"'
- 'setAttribute\("aria-checked", alternate'
- 'createElement\(hasBanner \? "div" : "header"\)'
- 'setAttribute\("role", "region"\)'
-
- 'a\.href = ""'
- 'body\.appendChild\(toggle\)'
---

The toggle on a plain document (no navbar) is injected by the after-body
script at `DOMContentLoaded`, so these assertions check the script text: the
fallback creates a `button` switch with a name, `setColorSchemeToggle()` keeps
`aria-checked` in sync, the toggle goes into a container rather than straight
onto `body`, and the old nameless-anchor creation is gone.

Which landmark that container is, and the selector used to find an existing
banner, are deliberately not asserted here: those are structural claims about
the rendered DOM, and `html-color-scheme-toggle.spec.ts` checks them through
the computed role instead.
15 changes: 15 additions & 0 deletions tests/docs/smoke-all/dark-mode/no-toggle-without-dark.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
title: "No color-scheme toggle without a dark theme"
format:
html:
theme: flatly
_quarto:
tests:
html:
ensureFileRegexMatches:
- []
-
- 'quarto-color-scheme-toggle'
---

A light-only document must not emit any toggle markup or toggle script.
5 changes: 5 additions & 0 deletions tests/docs/smoke-all/website/color-scheme-toggle/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/.quarto/
*.html
*.json
site_libs/
**/*.quarto_ipynb
21 changes: 21 additions & 0 deletions tests/docs/smoke-all/website/color-scheme-toggle/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
project:
type: website
output-dir: .

website:
title: "Website"
reader-mode: true
navbar:
left:
- href: index.qmd
text: Home
tools:
- icon: github
text: Quarto on GitHub
href: https://github.com/quarto-dev/quarto-cli

format:
html:
theme:
light: flatly
dark: darkly
16 changes: 16 additions & 0 deletions tests/docs/smoke-all/website/color-scheme-toggle/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: "Website"
_quarto:
tests:
html:
ensureHtmlElements:
-
- 'button.quarto-color-scheme-toggle[type="button"][role="switch"][aria-checked="false"][aria-label="Toggle dark mode"][title="Toggle dark mode"]'
- 'a.quarto-reader-toggle[title]'
- 'a.quarto-navigation-tool[href="https://github.com/quarto-dev/quarto-cli"]'
-
- 'a.quarto-color-scheme-toggle'
---

The navbar color-scheme toggle is a named switch (#13463); the reader-mode
toggle and navbar tool links keep their existing shape.
Loading
Loading