Skip to content

a11y: RevealJS/Dashboard axe report scroll region lacks keyboard focus (scrollable-region-focusable) #14710

Description

@cderv

Found while adding automated Playwright self-scan coverage for the axe accessibility-checker's own report chrome (it audits the page, then injects a report UI that never gets audited itself unless a test scans it directly).

axe rule: scrollable-region-focusable · impact: serious · tag: wcag2a (2.1.1, 2.1.3)
Targets: the RevealJS report slide (section.quarto-axe-report-slide) and the Dashboard offcanvas body (.offcanvas-body inside #quarto-axe-offcanvas).

When the report grows past its container's height, both the reveal report slide and the dashboard offcanvas body become scrollable (overflow-y: auto), but neither element nor any descendant is keyboard-focusable, so keyboard-only users can't reach the rest of the violations.

The HTML overlay variant already handles this case:

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);
}

The comment there reasons the reveal/dashboard variants don't need the same tab stop because "they don't scroll the report element itself" — true of the inner .quarto-axe-report, but the scrolling ancestor (the reveal slide / .offcanvas-body) is a different element the comment doesn't account for:

createReportSlide() {
const slidesContainer = document.querySelector(".reveal .slides");
if (!slidesContainer) {
this.createReportOverlay();
return;
}
const section = document.createElement("section");
section.className = "slide quarto-axe-report-slide scrollable";
// Force a white slide background so the report stays readable regardless
// of the deck's brand/theme colors. Reveal applies this to the slide's
// generated `.slide-background` element during sync().
section.setAttribute("data-background-color", "#fff");
const title = document.createElement("h2");
title.textContent = "Accessibility Report";
section.appendChild(title);
section.appendChild(this.createReportElement());
slidesContainer.appendChild(section);
// sync() registers the new slide but doesn't update visibility classes.
// Re-navigating to the current slide triggers the visibility update so
// the report slide gets the correct past/present/future class.
const indices = Reveal.getIndices();
Reveal.sync();
Reveal.slide(indices.h, indices.v);
}

createReportOffcanvas() {
const offcanvas = document.createElement("div");
offcanvas.className = "offcanvas offcanvas-end quarto-axe-offcanvas";
offcanvas.id = "quarto-axe-offcanvas";
offcanvas.tabIndex = -1;
offcanvas.setAttribute("aria-labelledby", "quarto-axe-offcanvas-label");
offcanvas.setAttribute("data-bs-scroll", "true");
offcanvas.setAttribute("data-bs-backdrop", "false");
const header = document.createElement("div");
header.className = "offcanvas-header";
const title = document.createElement("h5");
title.className = "offcanvas-title";
title.id = "quarto-axe-offcanvas-label";
title.textContent = "Accessibility Report";
header.appendChild(title);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "btn-close";
closeBtn.setAttribute("data-bs-dismiss", "offcanvas");
closeBtn.setAttribute("aria-label", "Close");
header.appendChild(closeBtn);
offcanvas.appendChild(header);
const body = document.createElement("div");
body.className = "offcanvas-body";
body.appendChild(this.createReportElement());
offcanvas.appendChild(body);

Confirmed directly with axe-core on an overflowing report in both formats: reveal's report slide (scrollHeight 901 vs clientHeight 700) and dashboard's offcanvas body (scrollHeight 774 vs clientHeight 658 — this one overflows even with a small, default-sized report) both get flagged.

We could mirror the overlay's fix — tabIndex = 0 / role="region" / aria-label on the scrolling ancestor in each case (the reveal <section> and the offcanvas body <div>), and correct the now-inaccurate comment above.

Related to #14378 (same axe rule, different target — document code blocks rather than the checker's own report UI). Part of #8706.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions