diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 35f6ee3a280..d9e675fb394 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -1,5 +1,9 @@ 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. + ## Engines ### `knitr` diff --git a/src/format/dashboard/format-dashboard-page.ts b/src/format/dashboard/format-dashboard-page.ts index 6fc36bb9245..9e2336a8405 100644 --- a/src/format/dashboard/format-dashboard-page.ts +++ b/src/format/dashboard/format-dashboard-page.ts @@ -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 { @@ -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) { @@ -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); diff --git a/src/format/dashboard/format-dashboard.ts b/src/format/dashboard/format-dashboard.ts index 87776f5f39f..f639b6348f0 100644 --- a/src/format/dashboard/format-dashboard.ts +++ b/src/format/dashboard/format-dashboard.ts @@ -23,6 +23,7 @@ import { DependencyHtmlFile, Format, FormatExtras, + FormatLanguage, kDependencies, kHtmlPostprocessors, kSassBundles, @@ -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 || {}; @@ -317,6 +318,7 @@ registerWriterFormatHandler((format) => { function dashboardHtmlPostProcessor( dashboardMeta: DashboardMeta, + language: FormatLanguage, ) { return (doc: Document): Promise => { const result: HtmlPostProcessResult = { @@ -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); diff --git a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss index 8c041fd8c71..d346cb20f0e 100644 --- a/src/resources/formats/html/bootstrap/_bootstrap-rules.scss +++ b/src/resources/formats/html/bootstrap/_bootstrap-rules.scss @@ -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; diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 6e7adb4d0af..602f2540eb5 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -10,10 +10,13 @@ // 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'); + const a = window.document.createElement('button'); + a.type = "button"; + a.setAttribute("role", "switch"); + a.setAttribute("aria-checked", "false"); a.classList.add('top-right'); a.classList.add('quarto-color-scheme-toggle'); - a.href = ""; + a.setAttribute("aria-label", "<%- language['toggle-dark-mode'] %>"); a.onclick = function() { try { window.quartoToggleColorScheme(); } catch {} return false; }; const i = window.document.createElement("i"); diff --git a/src/resources/formats/html/templates/quarto-html-before-body.ejs b/src/resources/formats/html/templates/quarto-html-before-body.ejs index a81090e2bfa..ff86c012a94 100644 --- a/src/resources/formats/html/templates/quarto-html-before-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-before-body.ejs @@ -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 { diff --git a/src/resources/projects/website/templates/navdarktoggle.ejs b/src/resources/projects/website/templates/navdarktoggle.ejs index 180882d3c9f..fcdf59c00c1 100644 --- a/src/resources/projects/website/templates/navdarktoggle.ejs +++ b/src/resources/projects/website/templates/navdarktoggle.ejs @@ -1 +1 @@ - + diff --git a/tests/docs/smoke-all/dark-mode/color-scheme-toggle-dashboard.qmd b/tests/docs/smoke-all/dark-mode/color-scheme-toggle-dashboard.qmd new file mode 100644 index 00000000000..571a91be8f8 --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/color-scheme-toggle-dashboard.qmd @@ -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. diff --git a/tests/docs/smoke-all/dark-mode/color-scheme-toggle-fallback.qmd b/tests/docs/smoke-all/dark-mode/color-scheme-toggle-fallback.qmd new file mode 100644 index 00000000000..6c78604e3bf --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/color-scheme-toggle-fallback.qmd @@ -0,0 +1,24 @@ +--- +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' + - + - 'a\.href = ""' +--- + +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, and the old nameless-anchor creation is gone. diff --git a/tests/docs/smoke-all/dark-mode/no-toggle-without-dark.qmd b/tests/docs/smoke-all/dark-mode/no-toggle-without-dark.qmd new file mode 100644 index 00000000000..30f59ae6e57 --- /dev/null +++ b/tests/docs/smoke-all/dark-mode/no-toggle-without-dark.qmd @@ -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. diff --git a/tests/docs/smoke-all/website/color-scheme-toggle/.gitignore b/tests/docs/smoke-all/website/color-scheme-toggle/.gitignore new file mode 100644 index 00000000000..0b3bf772477 --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle/.gitignore @@ -0,0 +1,5 @@ +/.quarto/ +*.html +*.json +site_libs/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/website/color-scheme-toggle/_quarto.yml b/tests/docs/smoke-all/website/color-scheme-toggle/_quarto.yml new file mode 100644 index 00000000000..de63c8051d2 --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle/_quarto.yml @@ -0,0 +1,20 @@ +project: + type: website + output-dir: . + +website: + title: "Website" + reader-mode: true + navbar: + left: + - href: index.qmd + text: Home + tools: + - icon: github + href: https://github.com/quarto-dev/quarto-cli + +format: + html: + theme: + light: flatly + dark: darkly diff --git a/tests/docs/smoke-all/website/color-scheme-toggle/index.qmd b/tests/docs/smoke-all/website/color-scheme-toggle/index.qmd new file mode 100644 index 00000000000..377b071a051 --- /dev/null +++ b/tests/docs/smoke-all/website/color-scheme-toggle/index.qmd @@ -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. diff --git a/tests/integration/playwright/tests/html-dark-mode-defaultdark.spec.ts b/tests/integration/playwright/tests/html-dark-mode-defaultdark.spec.ts index a21c808674f..bd59886b32d 100644 --- a/tests/integration/playwright/tests/html-dark-mode-defaultdark.spec.ts +++ b/tests/integration/playwright/tests/html-dark-mode-defaultdark.spec.ts @@ -10,13 +10,13 @@ async function check_backgrounds(page, class_, primary, secondary) { const locatr = await page.locator('body').first(); await expect(locatr).toHaveClass(`fullcontent ${class_}`); await expect(locatr).toHaveCSS('background-color', primary); - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); const locatr2 = await page.locator('body').first(); await expect(locatr2).toHaveCSS('background-color', secondary); } async function check_toggle(page, alternate) { - const locatr = await page.locator("a.quarto-color-scheme-toggle"); + const locatr = await page.locator("button.quarto-color-scheme-toggle"); await expect(locatr).toHaveClass(`top-right quarto-color-scheme-toggle${alternate?" alternate":""}`) } diff --git a/tests/integration/playwright/tests/html-dark-mode-defaultlight.spec.ts b/tests/integration/playwright/tests/html-dark-mode-defaultlight.spec.ts index 3fef8fbc605..1a86a589dc7 100644 --- a/tests/integration/playwright/tests/html-dark-mode-defaultlight.spec.ts +++ b/tests/integration/playwright/tests/html-dark-mode-defaultlight.spec.ts @@ -4,14 +4,14 @@ async function check_backgrounds(page, class_, primary, secondary) { const locatr = await page.locator('body').first(); await expect(locatr).toHaveClass(`fullcontent ${class_}`); await expect(locatr).toHaveCSS('background-color', primary); - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); const locatr2 = await page.locator('body').first(); await expect(locatr2).toHaveCSS('background-color', secondary); } async function check_toggle(page, alternate) { - const locatr = await page.locator("a.quarto-color-scheme-toggle"); + const locatr = await page.locator("button.quarto-color-scheme-toggle"); await expect(locatr).toHaveClass(`top-right quarto-color-scheme-toggle${alternate?" alternate":""}`) } diff --git a/tests/integration/playwright/tests/html-dark-mode.spec.ts b/tests/integration/playwright/tests/html-dark-mode.spec.ts index 8df7c3601c2..fd8b6c24888 100644 --- a/tests/integration/playwright/tests/html-dark-mode.spec.ts +++ b/tests/integration/playwright/tests/html-dark-mode.spec.ts @@ -4,7 +4,7 @@ async function check_theme_overrides(page) { const locatr = await page.locator('body').first(); await expect(locatr).toHaveClass('fullcontent quarto-light'); await expect(locatr).toHaveCSS('background-color', 'rgb(252, 252, 252)'); - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); const locatr2 = await page.locator('body').first(); await expect(locatr2).toHaveCSS('background-color', 'rgb(6, 6, 6)'); } @@ -31,7 +31,7 @@ test('Brand false remove project brand', async ({ page }) => { await expect(locatr).toHaveClass('fullcontent quarto-light'); await expect(locatr).toHaveCSS('background-color', 'rgb(255, 255, 255)'); // no toggle - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(0); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(0); }); @@ -50,7 +50,7 @@ test('Syntax highlighting, a11y, with JS', async ({ page }) => { // light highlight stylesheet await expect(importKeyword).toHaveCSS('color', 'rgb(84, 84, 84)'); - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); // dark inline code await expect(pythonCode).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); @@ -75,7 +75,7 @@ test('Syntax highlighting, arrow, with JS', async ({ page }) => { const link = await page.locator('span.al').first(); await expect(link).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); // transparent - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); // dark inline code await expect(pythonCode).toHaveCSS('background-color', 'rgba(37, 41, 46, 0.65)'); await expect(pythonCode).toHaveCSS('color', 'rgb(122, 130, 136)'); diff --git a/tests/integration/playwright/tests/html-themes.spec.ts b/tests/integration/playwright/tests/html-themes.spec.ts index 147dd852b69..2e2bf83ec1d 100644 --- a/tests/integration/playwright/tests/html-themes.spec.ts +++ b/tests/integration/playwright/tests/html-themes.spec.ts @@ -7,7 +7,7 @@ test('Dark and light theme respect user themes', async ({ page }) => { await page.goto('./html/dark-light-theme-custom/'); const locatr = await page.locator('div').filter({ hasText: 'Quarto Playground' }).first() await expect(locatr).toHaveCSS('background-color', 'rgb(255, 0, 0)'); - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); const locatr2 = await page.locator('div').filter({ hasText: 'Quarto Playground' }).first() await expect(locatr2).toHaveCSS('background-color', 'rgb(255, 0, 0)'); }); @@ -17,7 +17,7 @@ test('Dark theming toggle change to dark background ', async ({ page }) => { const locatr = page.getByText('Quarto Playground This is a'); await expect(locatr).toHaveCSS('background-color', 'rgb(255, 255, 255)'); // switching to dark theme using toggle - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); const locatr2 = await page.locator('div').filter({ hasText: 'Quarto Playground' }).first() await expect(locatr2).toHaveCSS('background-color', 'rgb(255, 0, 0)'); }); diff --git a/tests/integration/playwright/tests/html-unified-brand.spec.ts b/tests/integration/playwright/tests/html-unified-brand.spec.ts index 65e8ce4ac87..aff5d87d1d1 100644 --- a/tests/integration/playwright/tests/html-unified-brand.spec.ts +++ b/tests/integration/playwright/tests/html-unified-brand.spec.ts @@ -18,7 +18,7 @@ async function check_link_colors(page, class_) { await expect(linkLocatr).toHaveCSS('background-color', expectedColors[class_]['background-color']); await expect(linkLocatr).toHaveCSS('color', expectedColors[class_].color); - await page.locator("a.quarto-color-scheme-toggle").click(); + await page.locator("button.quarto-color-scheme-toggle").click(); const otherClass_ = otherClass(class_); console.assert(otherClass_ && typeof otherClass_ === 'string'); await expect(linkLocatr).toHaveCSS('background-color', expectedColors[otherClass_!]['background-color']); @@ -27,53 +27,53 @@ async function check_link_colors(page, class_) { test('Light brand in file', async ({ page }) => { await page.goto('./html/unified-brand/light-brand-only-file.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(0); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(0); }); test('Light brand inline', async ({ page }) => { await page.goto('./html/unified-brand/light-brand-only.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(0); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(0); }); test('Light brand with unified entries only light', async ({ page }) => { await page.goto('./html/unified-brand/unified-light-only.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(0); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(0); }); test('Dark brand in file', async ({ page }) => { await page.goto('./html/unified-brand/dark-brand-only-file.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); }); test('Dark brand inline', async ({ page }) => { await page.goto('./html/unified-brand/dark-brand-only.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); }); test('Light and dark brand files', async ({ page }) => { await page.goto('./html/unified-brand/light-dark-brand-file.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-light'); }); test('Light and dark brands inline', async ({ page }) => { await page.goto('./html/unified-brand/light-dark-brand.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-light'); }); test('Dark and light brand files', async ({ page }) => { await page.goto('./html/unified-brand/dark-light-brand-file.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-dark'); }); test('Dark and light brands inline', async ({ page }) => { await page.goto('./html/unified-brand/dark-light-brand.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-dark'); }); @@ -81,21 +81,21 @@ test('Dark and light brands inline', async ({ page }) => { test('Unified light and dark brand file', async ({ page }) => { await page.goto('./html/unified-brand/unified-colors-file.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-light'); }); test('Unified light and dark brand inline', async ({ page }) => { await page.goto('./html/unified-brand/unified-colors.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-light'); }); test('Unified light and dark typography inline', async ({ page }) => { await page.goto('./html/unified-brand/unified-typography.html'); - expect(await page.locator('a.quarto-color-scheme-toggle').count()).toEqual(1); + expect(await page.locator('button.quarto-color-scheme-toggle').count()).toEqual(1); await check_link_colors(page, 'quarto-light'); });