Skip to content
Open
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.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions src/format/html/format-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a href="#cbN-n"></a> 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 <code>; 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)) {
Expand Down
13 changes: 13 additions & 0 deletions tests/docs/playwright/html/axe-code-line-numbers.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
format:
html:
axe:
output: document
---

# Numbered code

```{.r .numberLines}
x <- 1
y <- 2
```
13 changes: 13 additions & 0 deletions tests/docs/playwright/revealjs/axe-code-line-numbers.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
format:
revealjs:
axe:
output: document
---

## Numbered code

```{.r .numberLines}
x <- 1
y <- 2
```
Original file line number Diff line number Diff line change
@@ -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
```
28 changes: 28 additions & 0 deletions tests/integration/playwright/tests/axe-accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
Loading