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"])';

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

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