Skip to content

Commit e344b27

Browse files
gabalafouivanov
authored andcommitted
Fix keyboard access for scrollable regions created by notebook outputs (pydata#1787)
One of many fixes for the failing accessibility tests (see pydata#1428). The accessibility tests were still reporting some violations of: - Scrollable region must have keyboard access (https://dequeuniversity.com/rules/axe/4.8/scrollable-region-focusable) even after merging pydata#1636 and pydata#1777. These were due to Jupyter notebook outputs that have scrollable content. This PR extends the functionality of PRs pydata#1636 and pydata#1777 to such outputs. - Adds a test for tabindex = 0 on notebook outputs after page load This also addresses one of the issues in pydata#1740: missing horizontal scrollbar by: - Adding CSS rule to allow scrolling - Add ipywidgets example to the examples/pydata page
1 parent bcca769 commit e344b27

File tree

4 files changed

+93
-34
lines changed

4 files changed

+93
-34
lines changed

src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js

+46-12
Original file line numberDiff line numberDiff line change
@@ -719,19 +719,45 @@ function setupMobileSidebarKeyboardHandlers() {
719719
}
720720

721721
/**
722-
* When the page loads or the window resizes check all elements with
723-
* [data-tabindex="0"], and if they have scrollable overflow, set tabIndex = 0.
722+
* When the page loads, or the window resizes, or descendant nodes are added or
723+
* removed from the main element, check all code blocks and Jupyter notebook
724+
* outputs, and for each one that has scrollable overflow, set tabIndex = 0.
724725
*/
725-
function setupLiteralBlockTabStops() {
726+
function addTabStopsToScrollableElements() {
726727
const updateTabStops = () => {
727-
document.querySelectorAll('[data-tabindex="0"]').forEach((el) => {
728-
el.tabIndex =
729-
el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight
730-
? 0
731-
: -1;
732-
});
728+
document
729+
.querySelectorAll(
730+
"pre, " + // code blocks
731+
".nboutput > .output_area, " + // NBSphinx notebook output
732+
".cell_output > .output, " + // Myst-NB
733+
".jp-RenderedHTMLCommon", // ipywidgets
734+
)
735+
.forEach((el) => {
736+
el.tabIndex =
737+
el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight
738+
? 0
739+
: -1;
740+
});
733741
};
734-
window.addEventListener("resize", debounce(updateTabStops, 300));
742+
const debouncedUpdateTabStops = debounce(updateTabStops, 300);
743+
744+
// On window resize
745+
window.addEventListener("resize", debouncedUpdateTabStops);
746+
747+
// The following MutationObserver is for ipywidgets, which take some time to
748+
// finish loading and rendering on the page (so even after the "load" event is
749+
// fired, they still have not finished rendering). Would be nice to replace
750+
// the MutationObserver if there is a way to hook into the ipywidgets code to
751+
// know when it is done.
752+
const mainObserver = new MutationObserver(debouncedUpdateTabStops);
753+
754+
// On descendant nodes added/removed from main element
755+
mainObserver.observe(document.getElementById("main-content"), {
756+
subtree: true,
757+
childList: true,
758+
});
759+
760+
// On page load (when this function gets called)
735761
updateTabStops();
736762
}
737763
function debounce(callback, wait) {
@@ -805,13 +831,21 @@ async function fetchRevealBannersTogether() {
805831
* Call functions after document loading.
806832
*/
807833

808-
// Call this one first to kick off the network request for the version warning
834+
// This one first to kick off the network request for the version warning
809835
// and announcement banner data as early as possible.
810836
documentReady(fetchRevealBannersTogether);
837+
811838
documentReady(addModeListener);
812839
documentReady(scrollToActive);
813840
documentReady(addTOCInteractivity);
814841
documentReady(setupSearchButtons);
815842
documentReady(initRTDObserver);
816843
documentReady(setupMobileSidebarKeyboardHandlers);
817-
documentReady(setupLiteralBlockTabStops);
844+
845+
// Determining whether an element has scrollable content depends on stylesheets,
846+
// so we're checking for the "load" event rather than "DOMContentLoaded"
847+
if (document.readyState === "complete") {
848+
addTabStopsToScrollableElements();
849+
} else {
850+
window.addEventListener("load", addTabStopsToScrollableElements);
851+
}

src/pydata_sphinx_theme/assets/styles/extensions/_notebooks.scss

+5
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
html div.rendered_html,
1313
// NBsphinx ipywidgets output selector
1414
html .jp-RenderedHTMLCommon {
15+
// Add some margin around the element box for the focus ring. Otherwise the
16+
// focus ring gets clipped because the containing elements have `overflow:
17+
// hidden` applied to them (via the `.lm-Widget` selector)
18+
margin: $focus-ring-width;
19+
1520
table {
1621
table-layout: auto;
1722
}

src/pydata_sphinx_theme/translator.py

-21
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import types
44

55
import sphinx
6-
from docutils import nodes
76
from packaging.version import Version
87
from sphinx.application import Sphinx
98
from sphinx.ext.autosummary import autosummary_table
@@ -27,32 +26,12 @@ def starttag(self, *args, **kwargs):
2726
"""Perform small modifications to tags.
2827
2928
- ensure aria-level is set for any tag with heading role
30-
- ensure <pre> tags have tabindex="0".
3129
"""
3230
if kwargs.get("ROLE") == "heading" and "ARIA-LEVEL" not in kwargs:
3331
kwargs["ARIA-LEVEL"] = "2"
3432

35-
if "pre" in args:
36-
kwargs["data-tabindex"] = "0"
37-
3833
return super().starttag(*args, **kwargs)
3934

40-
def visit_literal_block(self, node):
41-
"""Modify literal blocks.
42-
43-
- add tabindex="0" to <pre> tags within the HTML tree of the literal
44-
block
45-
"""
46-
try:
47-
super().visit_literal_block(node)
48-
except nodes.SkipNode:
49-
# If the super method raises nodes.SkipNode, then we know it
50-
# executed successfully and appended to self.body a string of HTML
51-
# representing the code block, which we then modify.
52-
html_string = self.body[-1]
53-
self.body[-1] = html_string.replace("<pre", '<pre data-tabindex="0"')
54-
raise nodes.SkipNode
55-
5635
def visit_table(self, node):
5736
"""Custom visit table method.
5837

tests/test_a11y.py

+42-1
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,14 @@ def test_version_switcher_highlighting(page: Page, url_base: str) -> None:
245245
expect(entry).to_have_css("color", light_mode)
246246

247247

248+
@pytest.mark.a11y
248249
def test_code_block_tab_stop(page: Page, url_base: str) -> None:
249250
"""Code blocks that have scrollable content should be tab stops."""
250251
page.set_viewport_size({"width": 1440, "height": 720})
251252
page.goto(urljoin(url_base, "/examples/kitchen-sink/blocks.html"))
253+
252254
code_block = page.locator(
253-
'css=#code-block pre[data-tabindex="0"]', has_text="from typing import Iterator"
255+
"css=#code-block pre", has_text="from typing import Iterator"
254256
)
255257

256258
# Viewport is wide, so code block content fits, no overflow, no tab stop
@@ -265,3 +267,42 @@ def test_code_block_tab_stop(page: Page, url_base: str) -> None:
265267
# Narrow viewport, content overflows and code block should be a tab stop
266268
assert code_block.evaluate("el => el.scrollWidth > el.clientWidth") is True
267269
assert code_block.evaluate("el => el.tabIndex") == 0
270+
271+
272+
@pytest.mark.a11y
273+
def test_notebook_output_tab_stop(page: Page, url_base: str) -> None:
274+
"""Notebook outputs that have scrollable content should be tab stops."""
275+
page.goto(urljoin(url_base, "/examples/pydata.html"))
276+
277+
# A "plain" notebook output
278+
nb_output = page.locator("css=#Pandas > .nboutput > .output_area")
279+
280+
# At the default viewport size (1280 x 720) the Pandas data table has
281+
# overflow...
282+
assert nb_output.evaluate("el => el.scrollWidth > el.clientWidth") is True
283+
284+
# ...and so our js code on the page should make it keyboard-focusable
285+
# (tabIndex = 0)
286+
assert nb_output.evaluate("el => el.tabIndex") == 0
287+
288+
289+
@pytest.mark.a11y
290+
def test_notebook_ipywidget_output_tab_stop(page: Page, url_base: str) -> None:
291+
"""Notebook ipywidget outputs that have scrollable content should be tab stops."""
292+
page.goto(urljoin(url_base, "/examples/pydata.html"))
293+
294+
# An ipywidget notebook output
295+
ipywidget = page.locator("css=.jp-RenderedHTMLCommon").first
296+
297+
# As soon as the ipywidget is attached to the page it should trigger the
298+
# mutation observer, which has a 300 ms debounce
299+
ipywidget.wait_for(state="attached")
300+
page.wait_for_timeout(301)
301+
302+
# At the default viewport size (1280 x 720) the data table inside the
303+
# ipywidget has overflow...
304+
assert ipywidget.evaluate("el => el.scrollWidth > el.clientWidth") is True
305+
306+
# ...and so our js code on the page should make it keyboard-focusable
307+
# (tabIndex = 0)
308+
assert ipywidget.evaluate("el => el.tabIndex") == 0

0 commit comments

Comments
 (0)