diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 98675fbafd..1cdc8c1636 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -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 diff --git a/src/format/html/format-html-axe.ts b/src/format/html/format-html-axe.ts index facb321270..103c28d097 100644 --- a/src/format/html/format-html-axe.ts +++ b/src/format/html/format-html-axe.ts @@ -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
, 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; } diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index f62f2fd9b2..ae7b898910 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -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; @@ -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" }); } } @@ -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); } diff --git a/tests/docs/playwright/html/axe-overlay-scroll.qmd b/tests/docs/playwright/html/axe-overlay-scroll.qmd new file mode 100644 index 0000000000..539b091712 --- /dev/null +++ b/tests/docs/playwright/html/axe-overlay-scroll.qmd @@ -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;"} +::: diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 4b83c3165f..a9cb223e27 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -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
, 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)'); } @@ -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

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'; diff --git a/tests/unit/axe-overlay-scroll.test.ts b/tests/unit/axe-overlay-scroll.test.ts new file mode 100644 index 0000000000..faa0c717b4 --- /dev/null +++ b/tests/unit/axe-overlay-scroll.test.ts @@ -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, + ); + }, +);