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
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(
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
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