-
Notifications
You must be signed in to change notification settings - Fork 456
Make scrollable code blocks and cell output keyboard-focusable in HTML output #14816
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f666630
38d87b5
f1eb6f4
93eab23
053cd2b
ea74fd5
939150b
2b4ccb1
2e3a12a
550c66f
d0799b8
c9f3855
f614c31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| /* | ||
| * scrollable-regions.js | ||
| * | ||
| * Makes scrollable code blocks and cell output keyboard-focusable while they | ||
| * actually overflow, so keyboard users can Tab to them and scroll with the | ||
| * arrow keys (axe scrollable-region-focusable, WCAG 2.1.1). Chrome 132+ and | ||
| * Firefox already focus such scrollers natively; this adds Safari coverage | ||
| * and an accessible name, and keeps the attributes in sync so a region that | ||
| * stops overflowing stops being a tab stop. | ||
| * | ||
| * Copyright (C) 2026 Posit Software, PBC | ||
| */ | ||
|
|
||
| // Marker for attributes we added, so removal never touches author markup. | ||
| const kMarker = "data-quarto-scrollable"; | ||
|
|
||
| // Scroll containers in rendered HTML: Pandoc emits | ||
| // `div.sourceCode { overflow: auto }` for highlighted code, the Bootstrap | ||
| // reboot gives every `pre` `overflow: auto`, and _quarto-rules.scss gives | ||
| // `.cell-output-display:not(.no-overflow-x)` `overflow-x: auto`. | ||
| const kCandidateSelector = | ||
| "div.sourceCode, pre, .cell-output-display:not(.no-overflow-x)"; | ||
|
|
||
| // Mirrors axe's pass condition (and Chrome's native heuristic): a region with | ||
| // keyboard-focusable content is already reachable, and adding tabindex would | ||
| // create a double tab stop. tabindex="-1" removes an element from the tab | ||
| // order, so Pandoc's per-line anchors (`a[href][tabindex="-1"]`) don't count. | ||
| const kFocusableSelector = | ||
| 'a[href]:not([tabindex="-1"]), button:not([tabindex="-1"]), ' + | ||
| 'input:not([tabindex="-1"]), select:not([tabindex="-1"]), ' + | ||
| 'textarea:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"])'; | ||
|
|
||
| // Pandoc emits one fragment link per numbered line, as the first child of the | ||
| // line's span. They are real, focusable links and stay that way on purpose | ||
| // (#14655), but every one sits at the start of its line, so they can never | ||
| // scroll the region horizontally. A numbered block therefore satisfies axe | ||
| // while its clipped content stays unreachable — so they don't count as | ||
| // focusable content here, and a numbered block gets a region tab stop of its | ||
| // own in addition to its per-line links. | ||
| const kLineNumberAnchorSelector = "code > span > a:first-child"; | ||
|
|
||
| // True when the region holds focusable content that already makes it | ||
| // reachable and scrollable — in which case adding tabindex would only create | ||
| // a redundant tab stop. | ||
| export function hasFocusableContent(el) { | ||
| for (const candidate of el.querySelectorAll(kFocusableSelector)) { | ||
| if (!candidate.matches(kLineNumberAnchorSelector)) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| const kDefaultLabels = { | ||
| code: "Scrollable code", | ||
| output: "Scrollable output", | ||
| }; | ||
|
|
||
| // Merge configured labels over the English defaults, ignoring missing or | ||
| // non-string values (e.g. a language file without the new keys). | ||
| export function resolveLabels(labels) { | ||
| const resolved = { ...kDefaultLabels }; | ||
| for (const key of Object.keys(kDefaultLabels)) { | ||
| if (labels && typeof labels[key] === "string" && labels[key] !== "") { | ||
| resolved[key] = labels[key]; | ||
| } | ||
| } | ||
| return resolved; | ||
| } | ||
|
|
||
| // A region scrolls when it is a scroll container (computed overflow auto or | ||
| // scroll — axe's own condition) whose content overflows its box on that axis; | ||
| // the 1px tolerance absorbs subpixel rounding. Without the overflow check, a | ||
| // child that merely bleeds outside a scrolling parent (pre.sourceCode inside | ||
| // div.sourceCode has overflow visible but the same scrollWidth) would become | ||
| // a second, useless tab stop. Pure over hand-built geometry and style. | ||
| export function isScrollable(el, style) { | ||
| const scrollsX = style.overflowX === "auto" || style.overflowX === "scroll"; | ||
| const scrollsY = style.overflowY === "auto" || style.overflowY === "scroll"; | ||
| return ( | ||
| (scrollsX && el.scrollWidth - el.clientWidth > 1) || | ||
| (scrollsY && el.scrollHeight - el.clientHeight > 1) | ||
| ); | ||
| } | ||
|
|
||
| // A region needs a usable size before a user can scroll it. Closed <details> | ||
| // and inactive tab panes report 0. Visually-hidden alternatives are clipped to | ||
| // 1px (`.visually-hidden` sets width 1px and overflow hidden; CSS then computes | ||
| // the other axis to auto), so their full content height reads as overflow — | ||
| // marking one would add an invisible tab stop. | ||
| export function hasUsableSize(el) { | ||
| return el.clientWidth > 1 && el.clientHeight > 1; | ||
| } | ||
|
|
||
| function labelFor(el, labels) { | ||
| if (el.matches(".cell-output-display") || el.closest(".cell-output")) { | ||
| return labels.output; | ||
| } | ||
| return labels.code; | ||
| } | ||
|
|
||
| export function syncScrollableRegions(labels) { | ||
| labels = resolveLabels(labels || window.quartoScrollableRegionsLabels); | ||
| for (const el of document.querySelectorAll(kCandidateSelector)) { | ||
| if (!hasUsableSize(el)) { | ||
| continue; | ||
| } | ||
| if (!el.hasAttribute(kMarker)) { | ||
| // respect author markup and regions with their own tab stops | ||
| if ( | ||
| el.hasAttribute("tabindex") || | ||
| el.hasAttribute("aria-label") || | ||
| hasFocusableContent(el) | ||
| ) { | ||
| continue; | ||
| } | ||
| if (isScrollable(el, getComputedStyle(el))) { | ||
| el.setAttribute("tabindex", "0"); | ||
| el.setAttribute("role", "group"); | ||
| el.setAttribute("aria-label", labelFor(el, labels)); | ||
| el.setAttribute(kMarker, ""); | ||
| } | ||
| } else if ( | ||
| !isScrollable(el, getComputedStyle(el)) && | ||
| el !== document.activeElement | ||
| ) { | ||
| el.removeAttribute("tabindex"); | ||
| el.removeAttribute("role"); | ||
| el.removeAttribute("aria-label"); | ||
| el.removeAttribute(kMarker); | ||
| } | ||
|
Comment on lines
+123
to
+131
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cleanup is skipped while |
||
| } | ||
| } | ||
|
Comment on lines
+102
to
+133
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The skip guard (lines 110–116) checks |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,6 +82,10 @@ toggle-dark-mode: "Toggle dark mode" | |
| toggle-reader-mode: "Toggle reader mode" | ||
| toggle-navigation: "Toggle navigation" | ||
|
|
||
| # Accessible names for scrollable regions (never shown as visible text) | ||
| scrollable-code-label: "Scrollable code" | ||
| scrollable-output-label: "Scrollable output" | ||
|
Comment on lines
+85
to
+87
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These two keys only exist in the English |
||
|
|
||
| crossref-fig-title: "Figure" | ||
| crossref-tbl-title: "Table" | ||
| crossref-lst-title: "Listing" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
kFocusableSelectordoesn't exclude disabled controls.button:disabled,input:disabled, etc. aren't in the tab order, sohasFocusableContentreturnstruefor a region whose only "focusable" descendant is a disabled control, andsyncScrollableRegionsskips adding a tab stop there. The region ends up scrollable but unreachable by keyboard. Add:not(:disabled)to the button/input/select/textarea clauses.