Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All changes included in 1.11:

## Accessibility

- ([#14378](https://github.com/quarto-dev/quarto-cli/issues/14378)): Make scrollable code blocks and cell output keyboard-focusable in HTML output, so keyboard users can Tab to them and scroll with the arrow keys (axe rule `scrollable-region-focusable`, WCAG 2.1.1). While a region overflows it gets `tabindex="0"`, `role="group"`, and a localized `aria-label` (new `scrollable-code-label` and `scrollable-output-label` language keys); when it fits again the attributes are removed. Chrome and Firefox already focus such regions natively; this adds Safari coverage and an accessible name.
- ([#14615](https://github.com/quarto-dev/quarto-cli/issues/14615)): Fix invalid `role="menu"` on the website navbar's collapse toggle button, flagged by axe-core (`aria-allowed-role`) and WAVE (`aria_menu_broken`) when the navbar collapses to the hamburger at narrow viewports.

## Engines
Expand Down
4 changes: 4 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ export const kToggleSidebar = "toggle-sidebar";
export const kToggleDarkMode = "toggle-dark-mode";
export const kToggleReaderMode = "toggle-reader-mode";
export const kToggleNavigation = "toggle-navigation";
export const kScrollableCodeLabel = "scrollable-code-label";
export const kScrollableOutputLabel = "scrollable-output-label";

export const kCopyButtonTooltip = "copy-button-tooltip";
export const kCopyButtonTooltipSuccess = "copy-button-tooltip-success";
Expand Down Expand Up @@ -433,6 +435,8 @@ export const kLanguageDefaultsKeys = [
kToggleReaderMode,
kToggleSidebar,
kToggleSection,
kScrollableCodeLabel,
kScrollableOutputLabel,
kCopyButtonTooltip,
kCopyButtonTooltipSuccess,
kBackToTop,
Expand Down
4 changes: 4 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ import {
kRepoActionLinksIssue,
kRepoActionLinksSource,
kResourcePath,
kScrollableCodeLabel,
kScrollableOutputLabel,
kSearchClearButtonTitle,
kSearchCopyLinkTitle,
kSearchDetatchedCancelButtonTitle,
Expand Down Expand Up @@ -701,6 +703,8 @@ export interface FormatLanguage {
[kToggleReaderMode]?: string;
[kToggleSection]?: string;
[kToggleSidebar]?: string;
[kScrollableCodeLabel]?: string;
[kScrollableOutputLabel]?: string;
[kSearchMatchingDocumentsText]?: string;
[kSearchCopyLinkTitle]?: string;
[kSearchMoreMatchText]?: string;
Expand Down
5 changes: 5 additions & 0 deletions src/format/html/format-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,11 @@ export async function htmlFormatExtras(
// Add localization strings
options.language = format.language;

// the scrollable-regions module in quarto.js reads its localized labels
// from a global set by the after-body script; quarto.js only ships with
// bootstrap formats
options.scrollableRegions = bootstrap;

// anchors if required
if (options.anchors) {
scripts.push({
Expand Down
8 changes: 5 additions & 3 deletions src/resources/editor/tools/vs-code.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10373,6 +10373,8 @@ var require_yaml_intelligence_resources = __commonJS({
"search-no-results-text": "string",
"copy-button-tooltip": "string",
"copy-button-tooltip-success": "string",
"scrollable-code-label": "string",
"scrollable-output-label": "string",
"repo-action-links-edit": "string",
"repo-action-links-source": "string",
"repo-action-links-issue": "string",
Expand Down Expand Up @@ -25387,12 +25389,12 @@ var require_yaml_intelligence_resources = __commonJS({
mermaid: "%%"
},
"handlers/mermaid/schema.yml": {
_internalId: 223031,
_internalId: 223759,
type: "object",
description: "be an object",
properties: {
"mermaid-format": {
_internalId: 223023,
_internalId: 223751,
type: "enum",
enum: [
"png",
Expand All @@ -25408,7 +25410,7 @@ var require_yaml_intelligence_resources = __commonJS({
exhaustiveCompletions: true
},
theme: {
_internalId: 223030,
_internalId: 223758,
type: "anyOf",
anyOf: [
{
Expand Down

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions src/resources/editor/tools/yaml/web-worker.js

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
Expand Up @@ -3341,6 +3341,8 @@
"search-no-results-text": "string",
"copy-button-tooltip": "string",
"copy-button-tooltip-success": "string",
"scrollable-code-label": "string",
"scrollable-output-label": "string",
"repo-action-links-edit": "string",
"repo-action-links-source": "string",
"repo-action-links-issue": "string",
Expand Down Expand Up @@ -18355,12 +18357,12 @@
"mermaid": "%%"
},
"handlers/mermaid/schema.yml": {
"_internalId": 223031,
"_internalId": 223759,
"type": "object",
"description": "be an object",
"properties": {
"mermaid-format": {
"_internalId": 223023,
"_internalId": 223751,
"type": "enum",
"enum": [
"png",
Expand All @@ -18376,7 +18378,7 @@
"exhaustiveCompletions": true
},
"theme": {
"_internalId": 223030,
"_internalId": 223758,
"type": "anyOf",
"anyOf": [
{
Expand Down
11 changes: 8 additions & 3 deletions src/resources/formats/html/esbuild-analysis-cache.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"quarto.js": {
"inputs": {
"quarto.js": {
"bytes": 26830,
"bytes": 27007,
"imports": [],
"format": "esm"
}
Expand All @@ -14,16 +14,21 @@
"path": "./tabsets/tabsets.js",
"kind": "import-statement",
"external": true
},
{
"path": "./scrollable-regions/scrollable-regions.js",
"kind": "import-statement",
"external": true
}
],
"exports": [],
"entryPoint": "quarto.js",
"inputs": {
"quarto.js": {
"bytesInOutput": 22313
"bytesInOutput": 22490
}
},
"bytes": 22313
"bytes": 22490
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/resources/formats/html/quarto.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as tabsets from "./tabsets/tabsets.js";
import * as scrollableRegions from "./scrollable-regions/scrollable-regions.js";

const sectionChanged = new CustomEvent("quarto-sectionChanged", {
detail: {},
Expand Down Expand Up @@ -39,6 +40,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
const resizeObserver = new window.ResizeObserver(
throttle(() => {
layoutMarginEls();
scrollableRegions.syncScrollableRegions();
if (
window.document.body.getBoundingClientRect().width < 990 &&
isReaderMode()
Expand All @@ -49,6 +51,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
);
resizeObserver.observe(window.document.body);
}
scrollableRegions.syncScrollableRegions();

const tocEl = window.document.querySelector('nav.toc-active[role="doc-toc"]');
const sidebarEl = window.document.getElementById("quarto-sidebar");
Expand Down
133 changes: 133 additions & 0 deletions src/resources/formats/html/scrollable-regions/scrollable-regions.js
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"])';
Comment on lines +28 to +31

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kFocusableSelector doesn't exclude disabled controls. button:disabled, input:disabled, etc. aren't in the tab order, so hasFocusableContent returns true for a region whose only "focusable" descendant is a disabled control, and syncScrollableRegions skips adding a tab stop there. The region ends up scrollable but unreachable by keyboard. Add :not(:disabled) to the button/input/select/textarea clauses.


// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup is skipped while el === document.activeElement (line 125), which avoids pulling attributes out from under focused content mid-interaction — reasonable. But nothing reruns the check once focus leaves that element. Sequence: Tab into a scrollable region, resize so it no longer overflows, Tab away. tabindex, role, aria-label, and the marker all stay stale until the next unrelated resize or reload. Worth a focusout listener on marked regions that reruns syncScrollableRegions (or just the check for that one element) once focus leaves.

}
}
Comment on lines +102 to +133

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skip guard (lines 110–116) checks tabindex and aria-label but not role. An element with an author-set role (e.g. <pre role="log">) isn't skipped here: it gets role="group" overwritten at line 119, then role is stripped entirely at line 128 once the region stops overflowing. That contradicts the comment above kMarker at the top of the file ("removal never touches author markup") for this one attribute. Add role to the skip condition, or save/restore the original value instead of unconditionally removing it.

10 changes: 9 additions & 1 deletion src/resources/formats/html/templates/quarto-html-after-body.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,15 @@
<% } %>

});

</script>
<% } %>
<% if (scrollableRegions) { %>
<script type="application/javascript">
window.quartoScrollableRegionsLabels = <%= JSON.stringify({
code: language['scrollable-code-label'],
output: language['scrollable-output-label']
}) %>;
</script>
<% } %>

4 changes: 4 additions & 0 deletions src/resources/language/_language.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two keys only exist in the English _language.yml — none of the 33 locale files got them. That's ok (precedent: #14376 added nav-landmark labels English-only) but it's the exact drift #14822 was filed to track, and the more recent #14684 (skip-to-content link) already raised the bar by shipping translations for the major locales (de/es/fr/it/ja/nl/pt/pt-BR/zh/zh-TW) in the same PR. Worth adding at least those before merge rather than leaning on #14822 to backfill later — it's aria-label-only text, so translation cost is two short strings per locale, not a big lift.


crossref-fig-title: "Figure"
crossref-tbl-title: "Table"
crossref-lst-title: "Listing"
Expand Down
2 changes: 2 additions & 0 deletions src/resources/schema/definitions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,8 @@
search-no-results-text: string
copy-button-tooltip: string
copy-button-tooltip-success: string
scrollable-code-label: string
scrollable-output-label: string
repo-action-links-edit: string
repo-action-links-source: string
repo-action-links-issue: string
Expand Down
6 changes: 6 additions & 0 deletions src/resources/schema/json-schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,12 @@
"copy-button-tooltip-success": {
"type": "string"
},
"scrollable-code-label": {
"type": "string"
},
"scrollable-output-label": {
"type": "string"
},
"repo-action-links-edit": {
"type": "string"
},
Expand Down
Loading