Skip to content
3 changes: 3 additions & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ All changes included in 1.10:
- ([#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.
- ([#14677](https://github.com/quarto-dev/quarto-cli/pull/14677)): The `axe` option now uses a copy of axe-core bundled with Quarto instead of loading it from the Skypack CDN in the reader's browser. Accessibility checking now works offline, and viewing a rendered document no longer triggers a request to `cdn.skypack.dev`. The axe-core version is unchanged (4.10.3), so scan results are identical.
- ([#14680](https://github.com/quarto-dev/quarto-cli/pull/14680)): Fix the `axe` accessibility report overlay inheriting page-level text centering (e.g. from the `jolla` about template) in HTML output; the overlay is now always left-aligned.
- ([#14680](https://github.com/quarto-dev/quarto-cli/pull/14680)): Fix hovering a selector in the `axe` accessibility report overlay scrolling the highlighted element underneath the overlay; the element now settles in the viewport area above the report.
- ([#14680](https://github.com/quarto-dev/quarto-cli/pull/14680)): Fix the `axe` accessibility report overlay in HTML output failing axe's own `scrollable-region-focusable` rule: a report long enough to scroll could not be scrolled by keyboard. The overlay is now a focusable, labeled region.

## Formats

Expand Down
3 changes: 3 additions & 0 deletions src/format/html/format-html-axe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ body div.quarto-axe-report {
color: var(--r-main-color, #222);
max-height: 50vh;
overflow-y: auto;
/* The overlay lives inside <main>, so it inherits page-level centering
(e.g. the jolla about template centers main). Pin it left. */
text-align: left;
}

.quarto-axe-violation-help { padding-left: 0.5rem; }
Expand Down
57 changes: 57 additions & 0 deletions src/resources/formats/html/axe/axe-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,38 @@ export function axeConformanceLevel(tags) {
return obsolete ? `Obsolete ${label}` : label;
}

// Margin kept between the viewport top and an element too tall to center in
// the band above the report overlay.
const OVERLAY_SCROLL_MARGIN = 16;

// Compute the document scrollTop that positions an element in the viewport band
// the fixed report overlay does NOT cover, so scrolling a highlighted element
// into view can't park it underneath the report. The overlay is bottom-anchored,
// so that band runs from the viewport top down to the overlay's top edge.
// Returns null when default `block: "center"` scrolling can't be obscured (no
// horizontal overlap with the overlay) or when the overlay leaves no usable
// band. All inputs are viewport-relative rects plus scroll state, so this stays
// a pure function (unit-testable without a DOM, like axeConformanceLevel).
export function overlayAwareScrollTop(
Comment thread
cderv marked this conversation as resolved.
elementRect,
overlayRect,
viewportHeight,
scrollY,
) {
// Element entirely left of the overlay: centering can't obscure it.
if (elementRect.right <= overlayRect.left) return null;

const bandHeight = Math.min(overlayRect.top, viewportHeight);
if (bandHeight <= OVERLAY_SCROLL_MARGIN * 2) return null;

const offset = elementRect.height > bandHeight
// Too tall to fit in the band: align its start just inside the top.
? OVERLAY_SCROLL_MARGIN
// Otherwise center it within the band.
: bandHeight / 2 - elementRect.height / 2;
return Math.max(0, scrollY + elementRect.top - offset);
}

class QuartoAxeReporter {
constructor(axeResult, options) {
this.axeResult = axeResult;
Expand Down Expand Up @@ -102,6 +134,22 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter {
element.scrollIntoView({ behavior: "smooth", block: "center" });
}
} else {
// Only the plain-HTML fixed overlay can obscure a centered element:
// Reveal navigates slides, and the dashboard offcanvas is a static,
// full-height side panel that vertical scrolling can't dodge.
const report = document.querySelector(".quarto-axe-report");
if (report && getComputedStyle(report).position === "fixed") {
const top = overlayAwareScrollTop(
element.getBoundingClientRect(),
report.getBoundingClientRect(),
window.innerHeight,
window.scrollY,
);
if (top !== null) {
window.scrollTo({ top, behavior: "smooth" });
return;
}
}
element.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
Expand Down Expand Up @@ -168,6 +216,15 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter {

createReportOverlay() {
const reportElement = this.createReportElement();
// The fixed overlay scrolls (max-height: 50vh + overflow-y: auto), so it
// must be keyboard-focusable or it fails axe's own
// scrollable-region-focusable rule once the report overflows. The role and
// label give the resulting tab stop an accessible name. The reveal slide
// and dashboard offcanvas variants don't scroll the report element itself,
// so they skip the extra tab stop.
reportElement.tabIndex = 0;
reportElement.setAttribute("role", "region");
reportElement.setAttribute("aria-label", "Accessibility report");
(document.querySelector("main") || document.body).appendChild(reportElement);
}

Expand Down
50 changes: 50 additions & 0 deletions tests/docs/playwright/html/axe-overlay-scroll.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
format:
html:
axe:
output: document
---

## Long Document

The dozen violations below inflate the report to its `max-height: 50vh`, so the
fixed overlay really covers the lower half of the viewport — with only one
violation the report is short and even default centered scrolling clears it.

[low contrast 1]{#c1 style="color: #eee; background: #fff;"}

[low contrast 2]{#c2 style="color: #eee; background: #fff;"}

[low contrast 3]{#c3 style="color: #eee; background: #fff;"}

[low contrast 4]{#c4 style="color: #eee; background: #fff;"}

[low contrast 5]{#c5 style="color: #eee; background: #fff;"}

[low contrast 6]{#c6 style="color: #eee; background: #fff;"}

[low contrast 7]{#c7 style="color: #eee; background: #fff;"}

[low contrast 8]{#c8 style="color: #eee; background: #fff;"}

[low contrast 9]{#c9 style="color: #eee; background: #fff;"}

[low contrast 10]{#c10 style="color: #eee; background: #fff;"}

[low contrast 11]{#c11 style="color: #eee; background: #fff;"}

[low contrast 12]{#c12 style="color: #eee; background: #fff;"}

Filler so the page scrolls well past one viewport; the hovered violation sits
near the bottom where centering it would put it under the report overlay.

::: {style="height: 3000px;"}
:::

::: {#bottom-contrast style="color: #eee; background: #fff;"}
This full-width block near the bottom of the page violates contrast rules, so
its bounding box overlaps the report overlay horizontally.
:::

::: {style="height: 400px;"}
:::
70 changes: 70 additions & 0 deletions tests/integration/playwright/tests/axe-accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ test.describe('Axe accessibility checking', () => {
await expect(axeReport).toHaveCSS('z-index', '9999');
await expect(axeReport).toHaveCSS('overflow-y', 'auto');

// Overlay must not inherit page-level centering (e.g. about
// templates center <main>, which the overlay is appended into)
await expect(axeReport).toHaveCSS('text-align', 'left');

// The overlay scrolls when the report overflows, so it must be a
// focusable, labeled region or it fails axe's own
// scrollable-region-focusable rule
await expect(axeReport).toHaveAttribute('tabindex', '0');
await expect(axeReport).toHaveAttribute('role', 'region');
await expect(axeReport).toHaveAttribute('aria-label', 'Accessibility report');

// Background must not be transparent
await expect(axeReport).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
}
Expand Down Expand Up @@ -338,6 +349,65 @@ test.describe('HTML axe — hover interaction and highlight', () => {
});
});

test.describe('HTML axe — hover scrolls element clear of the report overlay', () => {
test('highlighted element settles above the overlay, not under it', async ({ page }) => {
await page.goto('/html/axe-overlay-scroll.html', { waitUntil: 'networkidle' });

const axeReport = page.locator('.quarto-axe-report');
await expect(axeReport).toBeVisible({ timeout: 10000 });

// Hover the selector for the full-width violation near the page bottom.
// Default block:center scrolling would land it under the bottom-right
// fixed overlay; the overlay-aware scroll targets the band above it.
const target = axeReport.locator('.quarto-axe-violation-target', {
hasText: '#bottom-contrast',
});
await target.hover();

// axe targets the text-bearing element (the <p> inside #bottom-contrast,
// e.g. "#bottom-contrast > p"), so locate the highlight by class rather
// than assuming the exact selector.
const element = page.locator('.quarto-axe-hover-highlight');
await expect(element).toBeAttached({ timeout: 3000 });

// Poll until the smooth scroll settles with the element fully inside the
// viewport band above the overlay.
await expect.poll(async () => {
const elementBox = await element.boundingBox();
const overlayBox = await axeReport.boundingBox();
if (!elementBox || !overlayBox) return 'missing bounding box';
if (elementBox.y < 0) return `element top ${elementBox.y} above viewport`;
if (elementBox.y + elementBox.height > overlayBox.y) {
return `element bottom ${elementBox.y + elementBox.height} below overlay top ${overlayBox.y}`;
}
return 'clear of overlay';
}, { timeout: 5000 }).toBe('clear of overlay');
});
});

test.describe('HTML axe — the report overlay passes its own scan', () => {
test('overlay has no axe-core violations', async ({ page }) => {
// The overlay is injected after the page scan runs, so it never audits
// itself. Scan it here with the same vendored axe-core build the page
// already loaded (window.axe), on the long-report page where the overlay
// is scrollable — the state that tripped scrollable-region-focusable.
await page.goto('/html/axe-overlay-scroll.html', { waitUntil: 'networkidle' });

const axeReport = page.locator('.quarto-axe-report');
await expect(axeReport).toBeVisible({ timeout: 10000 });

const violations = await page.evaluate(async () => {
const axe = (window as any).axe;
const result = await axe.run(document.querySelector('.quarto-axe-report'));
return result.violations.map((v: { id: string; nodes: { target: string[] }[] }) => ({
id: v.id,
targets: v.nodes.map((n) => n.target),
}));
});
expect(violations).toEqual([]);
});
});

test.describe('Dashboard axe — re-scan on visibility change', () => {
const pagesUrl = '/dashboard/axe-accessibility-pages.html';

Expand Down
112 changes: 112 additions & 0 deletions tests/unit/axe-overlay-scroll.test.ts
Comment thread
cderv marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* axe-overlay-scroll.test.ts
*
* Tests the scroll-target computation the axe document reporter uses to keep a
* highlighted element from landing underneath the fixed report overlay.
*
* `overlayAwareScrollTop` is a pure function of viewport-relative rects and
* scroll state, so these tests need no DOM: each case hand-constructs the rects
* and asserts either the returned scrollTop or null (which tells the caller to
* keep the default `scrollIntoView({block: "center"})` behavior).
*
* Copyright (C) 2020-2025 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assertEquals } from "testing/asserts";
import { overlayAwareScrollTop } from "../../src/resources/formats/html/axe/axe-check.js";

// A bottom-right overlay in a 1280x720 viewport: top edge at y=400, left edge
// at x=800. The unobscured band above it is [0, 400], centered at y=200.
const overlay = { top: 400, left: 800 };
const viewportHeight = 720;

unitTest(
"overlayAwareScrollTop - no horizontal overlap returns null",
// deno-lint-ignore require-await
async () => {
// Element's right edge (700) is left of the overlay's left edge (800), so
// default centering can't put it under the overlay.
assertEquals(
overlayAwareScrollTop(
{ top: 600, right: 700, height: 100 },
overlay,
viewportHeight,
1000,
),
null,
);
},
);

unitTest(
"overlayAwareScrollTop - overlapping element is centered in the band above the overlay",
// deno-lint-ignore require-await
async () => {
// Element at viewport y=600, height 100. Centering it at the band's center
// (y=200) means its top must land at y=150, i.e. scroll down 450 more:
// 1000 + 600 - (400/2 - 100/2) = 1450.
assertEquals(
overlayAwareScrollTop(
{ top: 600, right: 1100, height: 100 },
overlay,
viewportHeight,
1000,
),
1450,
);
},
);

unitTest(
"overlayAwareScrollTop - element taller than the band is top-aligned with a margin",
// deno-lint-ignore require-await
async () => {
// Height 500 exceeds the 400px band, so align its start just inside the
// viewport top instead of centering: 1000 + 600 - 16 = 1584.
assertEquals(
overlayAwareScrollTop(
{ top: 600, right: 1100, height: 500 },
overlay,
viewportHeight,
1000,
),
1584,
);
},
);

unitTest(
"overlayAwareScrollTop - scrollTop is clamped at the document top",
// deno-lint-ignore require-await
async () => {
// Centering would need scrollTop 0 + 50 - 150 = -100; clamp to 0.
assertEquals(
overlayAwareScrollTop(
{ top: 50, right: 1100, height: 100 },
overlay,
viewportHeight,
0,
),
0,
);
},
);

unitTest(
"overlayAwareScrollTop - degenerate band (overlay covers the viewport) returns null",
// deno-lint-ignore require-await
async () => {
// Overlay top edge nearly at the viewport top leaves no usable band to
// scroll the element into; fall back to default centering.
assertEquals(
overlayAwareScrollTop(
{ top: 600, right: 1100, height: 100 },
{ top: 20, left: 100 },
viewportHeight,
1000,
),
null,
);
},
);
Loading