diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 7e945c91fba..e10a2250d0a 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -17,6 +17,7 @@ All changes included in 1.10: - ([#14468](https://github.com/quarto-dev/quarto-cli/issues/14468)): The `axe` accessibility report UI (HTML overlay, revealjs report slide, dashboard offcanvas) now uses its own theme-independent colors instead of inheriting from `brand` or theme. Keeps the report readable regardless of page styling, and stops `axe` from clobbering brand colors set via `_brand.yml`. - ([#14602](https://github.com/quarto-dev/quarto-cli/pull/14602), [#14632](https://github.com/quarto-dev/quarto-cli/pull/14632)): Fix ORCID profile link having no accessible name for screen readers in HTML, Reveal.js, and ipynb title-block author metadata. (author: @mcanouil for #14602) - ([#14604](https://github.com/quarto-dev/quarto-cli/issues/14604)): The `axe` accessibility report UI now shows each violation's WCAG conformance level (e.g. `WCAG 2.0 AA (1.4.3)`) or `Best Practice`, derived from the violation's axe-core tags. +- ([#14655](https://github.com/quarto-dev/quarto-cli/issues/14655)): Add accessible names to code line-number links so screen readers and accessibility audits no longer report them as empty links. ## Formats diff --git a/src/format/html/format-html.ts b/src/format/html/format-html.ts index 942b74b66cf..cc70784bf12 100644 --- a/src/format/html/format-html.ts +++ b/src/format/html/format-html.ts @@ -732,6 +732,40 @@ function htmlFormatPostprocessor( for (let i = 0; i < codeBlocks.length; i++) { const code = codeBlocks[i] as Element; + // Give each line-number anchor an accessible name (#14655). Skylighting + // emits an empty as the first child of every line + // span and draws the visible number purely via a CSS counter on + // ::before, so axe-core reports these focusable links as having no + // discernible text (WCAG 2.4.4/4.1.2). Set aria-label to the visible + // line number. aria-hidden is not an option here: these anchors are + // focusable fragment links, and aria-hidden on a focusable element is + // itself an accessibility violation. + if (code.classList.contains("numberSource")) { + const codeEl = code.querySelector("code"); + // Custom start numbers surface as `counter-reset: source-line N` on + // the ; the first visible line is then N + 1. Default is 0. + let startZero = 0; + // Signed digits: startFrom="0" emits `counter-reset: source-line -1`, + // and negative starts are possible — a bare \d+ would drop the sign and + // mislabel. skylighting emits exactly one well-formed declaration, so + // multiple/malformed counter-reset is not a real input. + const counterMatch = codeEl?.getAttribute("style")?.match( + /counter-reset:\s*source-line\s+(-?\d+)/, + ); + if (counterMatch) { + startZero = parseInt(counterMatch[1], 10); + } + const lineAnchors = code.querySelectorAll( + "code > span > a:first-child", + ); + for (let j = 0; j < lineAnchors.length; j++) { + const anchor = lineAnchors[j] as Element; + if (!anchor.hasAttribute("aria-label")) { + anchor.setAttribute("aria-label", String(startZero + j + 1)); + } + } + } + // hoist hidden and cell-code to parent div const parentHoist = (clz: string) => { if (code.classList.contains(clz)) { diff --git a/tests/docs/playwright/html/axe-code-line-numbers.qmd b/tests/docs/playwright/html/axe-code-line-numbers.qmd new file mode 100644 index 00000000000..22deda8aaec --- /dev/null +++ b/tests/docs/playwright/html/axe-code-line-numbers.qmd @@ -0,0 +1,13 @@ +--- +format: + html: + axe: + output: document +--- + +# Numbered code + +```{.r .numberLines} +x <- 1 +y <- 2 +``` diff --git a/tests/docs/playwright/revealjs/axe-code-line-numbers.qmd b/tests/docs/playwright/revealjs/axe-code-line-numbers.qmd new file mode 100644 index 00000000000..533c0a391a4 --- /dev/null +++ b/tests/docs/playwright/revealjs/axe-code-line-numbers.qmd @@ -0,0 +1,13 @@ +--- +format: + revealjs: + axe: + output: document +--- + +## Numbered code + +```{.r .numberLines} +x <- 1 +y <- 2 +``` diff --git a/tests/docs/smoke-all/accessibility/code-line-numbers-aria-label.qmd b/tests/docs/smoke-all/accessibility/code-line-numbers-aria-label.qmd new file mode 100644 index 00000000000..6ce22f77400 --- /dev/null +++ b/tests/docs/smoke-all/accessibility/code-line-numbers-aria-label.qmd @@ -0,0 +1,77 @@ +--- +title: "code-line-numbers anchors have accessible names (#14655)" +format: + html: default + revealjs: default +_quarto: + tests: + html: + ensureHtmlElements: + - + - 'pre.numberSource a[aria-label="1"]' + - 'pre.numberSource a[aria-label="2"]' + - 'pre.sourceCode:not(.numberSource) a[aria-hidden="true"]' + - '#startfrom a[aria-label="100"]' + - '#startfrom a[aria-label="101"]' + - '#badstart a[aria-label="1"]' + - '#badstart a[aria-label="2"]' + - + - 'pre.numberSource a[href]:not([aria-label])' + - 'pre.numberSource a[href][aria-label=""]' + - 'pre.sourceCode:not(.numberSource) a[aria-label]' + revealjs: + ensureHtmlElements: + - + - 'pre.numberSource a[aria-label="1"]' + - 'pre.numberSource a[aria-label="2"]' + - + - 'pre.numberSource a[href]:not([aria-label])' +--- + +## Canonical numbered (row 2) + +```{.r .numberLines} +x <- 1 +y <- 2 +``` + +## Hyphen-synonym numbered (row 3) + +```{.python .number-lines} +a = 1 +b = 2 +``` + +## No-language numbered (row 8) + +```{.numberLines} +plain line one +plain line two +``` + +## lineAnchors only — must stay non-numbered (row 10) + +```{.r .lineAnchors} +z <- 3 +``` + +## Second numbered block, no id (row 11) + +```{.python .numberLines} +p = 1 +q = 2 +``` + +## Custom start (row 5) + +```{#startfrom .python .numberLines startFrom="100"} +a = 1 +b = 2 +``` + +## Invalid start falls back to 1 (row 6) + +```{#badstart .python .numberLines startFrom="notanumber"} +c = 3 +d = 4 +``` diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 14619746faa..4b83c3165f0 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -486,3 +486,31 @@ test.describe('Dashboard axe — re-scan on visibility change', () => { 'Tab A restored: #tab-a-contrast should be detected again').toBe(true); }); }); + +test.describe('Axe — code line-number anchors have accessible names (#14655)', () => { + test('html — numbered code block has no axe violations', async ({ page }) => { + await page.goto('/html/axe-code-line-numbers.html', { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + const axeReport = page.locator('.quarto-axe-report'); + await expect(axeReport).toBeVisible({ timeout: 10000 }); + await expect(axeReport.locator('.quarto-axe-no-violations')) + .toHaveText('No axe-core violations found.'); + }); + + test('revealjs — code line-number anchors are not reported for missing link text', async ({ page }) => { + await page.goto('/revealjs/axe-code-line-numbers.html', { waitUntil: 'networkidle' }); + await waitForAxeCompletion(page); + + const reportSlide = page.locator('section.quarto-axe-report-slide'); + await expect(reportSlide).toBeAttached({ timeout: 10000 }); + + // RevealJS chrome (.slide-menu-button) and its default viewport meta trip + // their own pre-existing, unrelated violations — out of scope for #14655. + // The regression guard here is narrower: no violation should target a + // code line-number anchor (id `cbN-n`). + const targets = await reportSlide.locator('.quarto-axe-violation-target').allTextContents(); + const codeLineTargets = targets.filter((t) => /#cb\d+-\d+/.test(t)); + expect(codeLineTargets, `Unexpected code-line-number targets: ${targets.join(', ')}`).toEqual([]); + }); +});