Skip to content

Make scrollable code blocks and cell output keyboard-focusable in HTML output - #14816

Draft
cwickham wants to merge 12 commits into
mainfrom
feat/14378-scrollable-regions
Draft

Make scrollable code blocks and cell output keyboard-focusable in HTML output#14816
cwickham wants to merge 12 commits into
mainfrom
feat/14378-scrollable-regions

Conversation

@cwickham

Copy link
Copy Markdown
Member

Closes #14378. Part of #8706.

What this does

A code block or cell output that overflows is scrollable, but keyboard users cannot reach or scroll it in Safari. Chrome and Firefox already make such regions focusable natively, so this is a Safari gap for users and a missing accessible name everywhere. axe reports scrollable-region-focusable (serious, WCAG 2.1.1/2.1.3) in all three, because it reads markup rather than browser behavior. Compatibility under Design notes has the detail.

This adds a runtime module to quarto.js, for bootstrap HTML formats only. While a region overflows it gets tabindex="0", role="group", a localized aria-label, and a data-quarto-scrollable marker so removal only touches attributes we added. The attributes come off when the region fits again. The sync runs on DOMContentLoaded and from the existing throttled body ResizeObserver, so it adds no new observers.

The candidates are the three scroll containers in rendered HTML:

Candidate Overflow source
div.sourceCode Pandoc's div.sourceCode { overflow: auto } for highlighted code
pre Bootstrap reboot's pre { overflow: auto }
.cell-output-display:not(.no-overflow-x) _quarto-rules.scss

Which regions get marked

A region qualifies when its computed overflow is auto or scroll, which is axe's own condition, and it overflows by more than 1px. Three kinds of region are skipped:

  • Author markup, meaning an existing tabindex or aria-label.
  • No usable size: a closed <details>, an inactive tab pane, or a visually-hidden code alternative. The last is clipped to 1px, which makes its content height read as overflow. Marking it would add an invisible tab stop, and quarto-web's get-started pages carry about nine each.
  • Real focusable content, such as a link in a wide table, which the browser already scrolls into view.

Pandoc's line anchors are the exception to that third rule. Without line numbers they carry tabindex="-1", so they are out of the tab order anyway. With line numbers they are real focusable links, kept that way on purpose by #14655. But each one sits at the start of its line, so focusing it leaves scrollLeft at 0 and the clipped content unreachable. A numbered block therefore satisfies axe while staying unscrollable, so it gets its own region tab stop alongside its per-line links.

When it re-syncs

One rule, not one per widget: the module re-syncs when the page's layout changes size. Collapsed callouts, folded <details>, and tab panes of differing heights all make the page taller, so a single ResizeObserver covers them with no mechanism-specific code.

The boundary is a reveal that changes no size, and two tab panes of equal height are the clean example. That case keeps today's behavior rather than regressing anything. A shown.bs.tab hook would fix tabsets while still missing equal-height accordions and overlay panels, trading one documented boundary for a special case plus a boundary.

Design notes

Runtime JS, not a static tabindex. A Lua filter would put a tab stop on every code block, whether or not it scrolls. It would also change every rendered HTML file, breaking downstream snapshot tests and scrapers. Rendered HTML stays byte-identical here.

Compatibility. Chrome (since 132, January 2025) and Firefox already make these regions focusable natively (Chrome blog). These tab stops have therefore been live on every Quarto site for over a year, with no reports. That bounds the risk: this polyfills shipped browser behavior rather than inventing a new interaction. What it adds on top is Safari coverage, an accessible name, and axe conformance. Every failure mode leaves an element exactly as it is today.

Labels. Two new language keys, scrollable-code-label and scrollable-output-label. quarto.js is static, so the after-body script passes the resolved strings through a window global. The module falls back to the English defaults.

role="group", not region. Pages carry many code blocks, and region landmarks would flood the screen-reader rotor.

Tests

  • 15 unit tests (tests/unit/scrollable-regions.test.ts): overflow geometry, usable size, the focusable-content rule, label merging.
  • 30 Playwright tests (tests/integration/playwright/tests/html-scrollable-regions.spec.ts) on chromium, firefox, and webkit: marking and removal across viewports, the three skips, one tab stop per block, real-Tab reach with a visible focus ring, and arrow-key scrolling. Arrow keys are asserted on chromium and firefox only, because Playwright's WebKit routes them to the page even when a scroller has focus.
  • axe-accessibility still passes (116 tests), with its code-line-number fixtures re-rendered.
  • On quarto-web pages rendered with this build, the violations on about.html (#cb1) and docs/troubleshooting/index.html (bare pre) are gone, and axe reports none for the rule at 390x844.
  • A full sync costs 0.6ms median on a 200-block page (400 candidates), against the existing 50ms throttle.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes (the suites listed above; not the full matrix)
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

…board-focusable

When a code block or cell output actually scrolls, keyboard users need to
Tab to it and scroll it with the arrow keys (WCAG 2.1.1, axe
scrollable-region-focusable). Chrome 132+ and Firefox focus such scrollers
natively; this runtime sync adds Safari coverage and an accessible name,
and only marks regions while they overflow.

Part of #14378
all-schema-definitions.json lags one build-artifacts run behind; the
schema change from 8b287b6 (#14736) reached it only on this build.
The other three files carry the resulting _internalId shifts.
Two new language keys, scrollable-code-label and scrollable-output-label,
give keyboard-focusable scrollable regions an accessible name. The
after-body script exposes them to the scrollable-regions module via a
window global (quarto.js is static, so it cannot be templated directly).

Part of #14378
Verified in-browser, two corrections to the sync pass:

- An element only scrolls by keyboard when its computed overflow is auto
  or scroll (axe's own condition). Without this, pre.sourceCode - which
  bleeds outside its scrolling div.sourceCode parent but is not itself a
  scroll container - became a second, useless tab stop on every
  highlighted block.
- tabindex="-1" removes an element from the tab order, so Pandoc's
  per-line anchors (a[href][tabindex="-1"]) must not count as focusable
  content; they were suppressing the tab stop on every highlighted block.

Part of #14378
Unit tests cover the pure helpers (isScrollable geometry + overflow
logic, resolveLabels merging). The Playwright spec covers the runtime:
attributes present at 390px and absent at 1440px, one tab stop per
block, hidden tab panes left alone, removal on resize, Tab reach with
a visible focus ring, arrow-key scrolling (chromium/firefox; WebKit
routes arrow keys to the page), and code inside <details>.

Part of #14378
A `.visually-hidden` block is clipped to 1px and sets `overflow: hidden`
on one axis; CSS then computes the other axis to auto, so its full content
height reads as overflow. The sync marked it, which gave real quarto-web
pages (the get-started screenshots each carry ~9 such blocks) an invisible
1px tab stop labelled "Scrollable code".

Require a usable size before marking. Also corrects the copyright header
year on the two new files, per .claude/rules/copyright.md.

Part of #14378
Pandoc emits a focusable fragment link per numbered line, and #14655 keeps
those links focusable on purpose. They satisfy axe's focusable-content
condition, so a numbered block passed scrollable-region-focusable while
staying unscrollable: every anchor sits at the start of its line, so
focusing one leaves scrollLeft at 0 and the clipped content unreachable.

Line-number anchors no longer count as focusable content, so a numbered
block gets a region tab stop alongside its per-line links. Regions holding
genuinely useful focusable content are still skipped.

Part of #14378
…special case

Opening a <details> re-syncs because it makes the page taller and trips the
body ResizeObserver, not because folded code is handled specially. Without
the note the test reads as evidence of per-widget support, and the obvious
next move is to add a matching hook for tabsets.

Part of #14378
@cwickham

Copy link
Copy Markdown
Member Author

Filed #14817 for the revealjs side, which this PR does not cover. Same axe rule, different target: there the scroll container is .reveal pre code rather than div.sourceCode, and axe stays silent because revealjs enables code line numbers by default.

These two keys are aria-labels, never shown as visible text, so they
belong beside the toggle-* strings rather than next to the code-copy
tooltips. Moved in _language.yml (with a comment marking the group),
and kept in step in the constants and FormatLanguage declarations.

definitions.yml does not list the toggle-* keys, so the schema is
unchanged and no artifacts need regenerating.

Part of #14378
@posit-snyk-bot

posit-snyk-bot commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a11y: scrollable code blocks and output pre lack tabindex=0 (keyboard inaccessible)

2 participants