From bdc04d93ac1ff518b0dd6379a73ee1864a5b49ff Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Mon, 13 Jul 2026 10:32:53 -0700 Subject: [PATCH 1/8] Left-align the axe report overlay instead of inheriting page centering The overlay is appended into
, so page-level centering (e.g. the jolla about template's 'div.quarto-about-jolla main { text-align: center }') centered the report text. The reveal report slide already pins text-align: left; do the same in the base overlay rule. --- src/format/html/format-html-axe.ts | 3 +++ 1 file changed, 3 insertions(+) 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; } From b016240e42a4c8f1418faddbf9fe51af6ebc68f4 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Mon, 13 Jul 2026 10:32:53 -0700 Subject: [PATCH 2/8] Scroll highlighted elements clear of the axe report overlay Hovering a selector in the report scrolled the target element to the viewport center with scrollIntoView({block: 'center'}), where the fixed bottom-right overlay (max-height: 50vh) could cover it. Compute the scroll target with a new exported pure helper, overlayAwareScrollTop, that centers the element in the viewport band above the overlay instead; elements with no horizontal overlap (or when the overlay leaves no usable band) keep the default centering. Only the plain-HTML fixed overlay takes this path: reveal navigates slides and the dashboard offcanvas is a static full-height panel that vertical scrolling can't dodge. The overlay itself never moves or fades, so the mouse stays inside it during the scroll and no mouseleave -> unhighlight flicker can fire. Unit tests cover the helper's geometry; a Playwright test hovers a violation near the bottom of a long page (with enough violations to inflate the report to its max-height, without which default centering already clears the overlay and the test has no teeth) and asserts the highlighted element settles fully above the overlay. The spec also gains a text-align: left assertion on the HTML overlay for the left-alignment fix. --- src/resources/formats/html/axe/axe-check.js | 48 ++++++++ .../playwright/html/axe-overlay-scroll.qmd | 50 ++++++++ .../tests/axe-accessibility.spec.ts | 40 +++++++ tests/unit/axe-overlay-scroll.test.ts | 112 ++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 tests/docs/playwright/html/axe-overlay-scroll.qmd create mode 100644 tests/unit/axe-overlay-scroll.test.ts diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index f62f2fd9b2..3b44470dbb 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" }); } } 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..8a56ec32a8 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -162,6 +162,10 @@ 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'); + // Background must not be transparent await expect(axeReport).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); } @@ -338,6 +342,42 @@ 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('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, + ); + }, +); From 702a7e850d22a5f4c8964230354773aa10aa3ca5 Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Mon, 13 Jul 2026 10:32:53 -0700 Subject: [PATCH 3/8] Add changelog entries for axe overlay polish --- news/changelog-1.10.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index eae5df492c..a445b87521 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -20,6 +20,8 @@ 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. +- 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. +- 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. ## Formats From 1f9dce14c982a76796928b66061338a00e40699a Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Mon, 13 Jul 2026 10:33:58 -0700 Subject: [PATCH 4/8] Add PR link to changelog entries --- news/changelog-1.10.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index a445b87521..8c81270cfe 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -20,8 +20,8 @@ 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. -- 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. -- 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 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. ## Formats From 7ea8e767ef5b42f069e8398d6ad6624fcc384c6a Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Mon, 13 Jul 2026 10:47:28 -0700 Subject: [PATCH 5/8] Make the fixed axe overlay a focusable region so it can scroll by keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay scrolls once the report exceeds max-height: 50vh, but wasn't keyboard-focusable — failing axe's own scrollable-region-focusable rule (serious, WCAG 2.1.1) when scanned directly. Add tabindex=0 plus role/aria-label in createReportOverlay only: the reveal slide and dashboard offcanvas variants don't scroll the report element itself, so they don't get the extra tab stop. Verified by running the pinned axe-core build against the rendered overlay on the long-report test page: scrollable-region-focusable before the change, no violations after. Note the violation-target spans remain mouse-only; full keyboard operability of the report is tracked separately. --- news/changelog-1.10.md | 1 + src/resources/formats/html/axe/axe-check.js | 9 +++++++++ .../playwright/tests/axe-accessibility.spec.ts | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 8c81270cfe..f43cd5346b 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -22,6 +22,7 @@ All changes included in 1.10: - ([#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/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 3b44470dbb..ae7b898910 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -216,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/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index 8a56ec32a8..b38bc45326 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -166,6 +166,13 @@ test.describe('Axe accessibility checking', () => { // 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)'); } From 268b1164e13e2c17ea3938285b3889fdf156edda Mon Sep 17 00:00:00 2001 From: Charlotte Wickham Date: Mon, 13 Jul 2026 10:56:23 -0700 Subject: [PATCH 6/8] Add regression test that the report overlay passes axe-core's own scan The overlay is injected after the page scan, so it never audits itself and nothing else guards against new violations as the report UI grows. Scan it with the vendored axe-core build the page already loaded (window.axe), on the long-report page where the overlay is scrollable. Requires the vendored axe-core from #14677, which this branch is now stacked on. Verified the test fails with scrollable-region-focusable when the tabindex fix is removed. --- .../tests/axe-accessibility.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/integration/playwright/tests/axe-accessibility.spec.ts b/tests/integration/playwright/tests/axe-accessibility.spec.ts index b38bc45326..a9cb223e27 100644 --- a/tests/integration/playwright/tests/axe-accessibility.spec.ts +++ b/tests/integration/playwright/tests/axe-accessibility.spec.ts @@ -385,6 +385,29 @@ test.describe('HTML axe — hover scrolls element clear of the report 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'; From 3dc213376ceee9d040a806e7f1feecdf5b54f7b0 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 20 Jul 2026 12:11:31 +0200 Subject: [PATCH 7/8] Act on a single scroll target per axe report node hover A violation node's target array is one element's cross-frame selector chain, not a set of distinct elements. Registering the hover/click listeners inside the per-selector span loop attached one listener set per selector, so a single hover over a multi-selector node fired one competing window.scrollTo per selector (last-wins jank). Split selector-span rendering (still shows the full chain) from listener registration, which now iterates a pure nodeScrollTargets() helper that returns the primary target only. Single-selector nodes -- the common case -- are unchanged. The helper is DOM-free like overlayAwareScrollTop, so the one-scroll-per-hover decision is unit-tested directly. --- src/resources/formats/html/axe/axe-check.js | 14 ++++++++++++++ tests/unit/axe-overlay-scroll.test.ts | 21 ++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index ae7b898910..4401e1f682 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -67,6 +67,15 @@ export function overlayAwareScrollTop( return Math.max(0, scrollY + elementRect.top - offset); } +// Which of a violation node's target selectors a single hover/click should act on. +// A node.target with several selectors is one element's cross-frame selector chain, +// not distinct elements, so a hover must act on the primary (first) target only — +// otherwise one hover fires one competing window.scrollTo per selector (last-wins +// jank). Pure and DOM-free, like overlayAwareScrollTop, so it is unit-testable. +export function nodeScrollTargets(node) { + return node.target.slice(0, 1); +} + class QuartoAxeReporter { constructor(axeResult, options) { this.axeResult = axeResult; @@ -176,11 +185,16 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { for (const node of violation.nodes) { const nodeElement = document.createElement("div"); nodeElement.className = "quarto-axe-violation-selector"; + // Render a span for every selector so the full cross-frame chain is shown. for (const target of node.target) { const targetElement = document.createElement("span"); targetElement.className = "quarto-axe-violation-target"; targetElement.innerText = target; nodeElement.appendChild(targetElement); + } + // Register hover/click on the node's scroll target(s) only — see + // nodeScrollTargets — so one hover fires one scroll, not one per selector. + for (const target of nodeScrollTargets(node)) { if (typeof Reveal !== "undefined") { // RevealJS: click navigates to the slide and highlights briefly nodeElement.addEventListener("click", () => { diff --git a/tests/unit/axe-overlay-scroll.test.ts b/tests/unit/axe-overlay-scroll.test.ts index faa0c717b4..955630472f 100644 --- a/tests/unit/axe-overlay-scroll.test.ts +++ b/tests/unit/axe-overlay-scroll.test.ts @@ -14,7 +14,7 @@ import { unitTest } from "../test.ts"; import { assertEquals } from "testing/asserts"; -import { overlayAwareScrollTop } from "../../src/resources/formats/html/axe/axe-check.js"; +import { nodeScrollTargets, 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. @@ -110,3 +110,22 @@ unitTest( ); }, ); + +unitTest( + "nodeScrollTargets - a multi-selector node acts on a single (primary) target", + // deno-lint-ignore require-await + async () => { + // Several selectors are one element's cross-frame selector chain, not distinct + // elements. Registering a hover listener per selector fired one competing + // window.scrollTo per selector; a hover must act on the primary target only. + assertEquals(nodeScrollTargets({ target: ["#frame", "#inner"] }), ["#frame"]); + }, +); + +unitTest( + "nodeScrollTargets - a single-selector node is unchanged", + // deno-lint-ignore require-await + async () => { + assertEquals(nodeScrollTargets({ target: ["#only"] }), ["#only"]); + }, +); From 044702a438a6ec7ed3ab7027ebd7d48aeb1ef66f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Mon, 20 Jul 2026 12:48:46 +0200 Subject: [PATCH 8/8] Revert "Act on a single scroll target per axe report node hover" This reverts commit 3dc213376ceee9d040a806e7f1feecdf5b54f7b0. --- src/resources/formats/html/axe/axe-check.js | 14 -------------- tests/unit/axe-overlay-scroll.test.ts | 21 +-------------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/src/resources/formats/html/axe/axe-check.js b/src/resources/formats/html/axe/axe-check.js index 4401e1f682..ae7b898910 100644 --- a/src/resources/formats/html/axe/axe-check.js +++ b/src/resources/formats/html/axe/axe-check.js @@ -67,15 +67,6 @@ export function overlayAwareScrollTop( return Math.max(0, scrollY + elementRect.top - offset); } -// Which of a violation node's target selectors a single hover/click should act on. -// A node.target with several selectors is one element's cross-frame selector chain, -// not distinct elements, so a hover must act on the primary (first) target only — -// otherwise one hover fires one competing window.scrollTo per selector (last-wins -// jank). Pure and DOM-free, like overlayAwareScrollTop, so it is unit-testable. -export function nodeScrollTargets(node) { - return node.target.slice(0, 1); -} - class QuartoAxeReporter { constructor(axeResult, options) { this.axeResult = axeResult; @@ -185,16 +176,11 @@ class QuartoAxeDocumentReporter extends QuartoAxeReporter { for (const node of violation.nodes) { const nodeElement = document.createElement("div"); nodeElement.className = "quarto-axe-violation-selector"; - // Render a span for every selector so the full cross-frame chain is shown. for (const target of node.target) { const targetElement = document.createElement("span"); targetElement.className = "quarto-axe-violation-target"; targetElement.innerText = target; nodeElement.appendChild(targetElement); - } - // Register hover/click on the node's scroll target(s) only — see - // nodeScrollTargets — so one hover fires one scroll, not one per selector. - for (const target of nodeScrollTargets(node)) { if (typeof Reveal !== "undefined") { // RevealJS: click navigates to the slide and highlights briefly nodeElement.addEventListener("click", () => { diff --git a/tests/unit/axe-overlay-scroll.test.ts b/tests/unit/axe-overlay-scroll.test.ts index 955630472f..faa0c717b4 100644 --- a/tests/unit/axe-overlay-scroll.test.ts +++ b/tests/unit/axe-overlay-scroll.test.ts @@ -14,7 +14,7 @@ import { unitTest } from "../test.ts"; import { assertEquals } from "testing/asserts"; -import { nodeScrollTargets, overlayAwareScrollTop } from "../../src/resources/formats/html/axe/axe-check.js"; +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. @@ -110,22 +110,3 @@ unitTest( ); }, ); - -unitTest( - "nodeScrollTargets - a multi-selector node acts on a single (primary) target", - // deno-lint-ignore require-await - async () => { - // Several selectors are one element's cross-frame selector chain, not distinct - // elements. Registering a hover listener per selector fired one competing - // window.scrollTo per selector; a hover must act on the primary target only. - assertEquals(nodeScrollTargets({ target: ["#frame", "#inner"] }), ["#frame"]); - }, -); - -unitTest( - "nodeScrollTargets - a single-selector node is unchanged", - // deno-lint-ignore require-await - async () => { - assertEquals(nodeScrollTargets({ target: ["#only"] }), ["#only"]); - }, -);