Make scrollable code blocks and cell output keyboard-focusable in HTML output - #14816
Draft
cwickham wants to merge 12 commits into
Draft
Make scrollable code blocks and cell output keyboard-focusable in HTML output#14816cwickham wants to merge 12 commits into
cwickham wants to merge 12 commits into
Conversation
…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
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
Member
Author
|
Filed #14817 for the revealjs side, which this PR does not cover. Same axe rule, different target: there the scroll container is |
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
Collaborator
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 getstabindex="0",role="group", a localizedaria-label, and adata-quarto-scrollablemarker so removal only touches attributes we added. The attributes come off when the region fits again. The sync runs onDOMContentLoadedand from the existing throttled bodyResizeObserver, so it adds no new observers.The candidates are the three scroll containers in rendered HTML:
div.sourceCodediv.sourceCode { overflow: auto }for highlighted codeprepre { overflow: auto }.cell-output-display:not(.no-overflow-x)_quarto-rules.scssWhich regions get marked
A region qualifies when its computed overflow is
autoorscroll, which is axe's own condition, and it overflows by more than 1px. Three kinds of region are skipped:tabindexoraria-label.<details>, an inactive tab pane, or avisually-hiddencode 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.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 leavesscrollLeftat 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 singleResizeObservercovers 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.tabhook 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-labelandscrollable-output-label.quarto.jsis static, so the after-body script passes the resolved strings through a window global. The module falls back to the English defaults.role="group", notregion. Pages carry many code blocks, andregionlandmarks would flood the screen-reader rotor.Tests
tests/unit/scrollable-regions.test.ts): overflow geometry, usable size, the focusable-content rule, label merging.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-accessibilitystill passes (116 tests), with its code-line-number fixtures re-rendered.about.html(#cb1) anddocs/troubleshooting/index.html(barepre) are gone, and axe reports none for the rule at 390x844.Checklist
I have (if applicable):
AI-assisted PR