Skip to content

Commit

Permalink
Fix keyboard access for scrollable regions created by notebook outputs (
Browse files Browse the repository at this point in the history
#1787)

One of many fixes for the failing accessibility tests (see #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 #1636 and #1777. 

These were due to Jupyter notebook outputs that have scrollable content. This PR extends the functionality of PRs #1636 and #1777 to such outputs.

- Adds a test for tabindex = 0 on notebook outputs after page load

This also addresses one of the issues in #1740: missing horizontal scrollbar by:

- Adding CSS rule to allow scrolling
- Add ipywidgets example to the examples/pydata page
  • Loading branch information
gabalafou authored May 27, 2024
1 parent a5fbc1a commit c839490
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 34 deletions.
58 changes: 46 additions & 12 deletions src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -719,19 +719,45 @@ function setupMobileSidebarKeyboardHandlers() {
}

/**
* When the page loads or the window resizes check all elements with
* [data-tabindex="0"], and if they have scrollable overflow, set tabIndex = 0.
* When the page loads, or the window resizes, or descendant nodes are added or
* removed from the main element, check all code blocks and Jupyter notebook
* outputs, and for each one that has scrollable overflow, set tabIndex = 0.
*/
function setupLiteralBlockTabStops() {
function addTabStopsToScrollableElements() {
const updateTabStops = () => {
document.querySelectorAll('[data-tabindex="0"]').forEach((el) => {
el.tabIndex =
el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight
? 0
: -1;
});
document
.querySelectorAll(
"pre, " + // code blocks
".nboutput > .output_area, " + // NBSphinx notebook output
".cell_output > .output, " + // Myst-NB
".jp-RenderedHTMLCommon", // ipywidgets
)
.forEach((el) => {
el.tabIndex =
el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight
? 0
: -1;
});
};
window.addEventListener("resize", debounce(updateTabStops, 300));
const debouncedUpdateTabStops = debounce(updateTabStops, 300);

// On window resize
window.addEventListener("resize", debouncedUpdateTabStops);

// The following MutationObserver is for ipywidgets, which take some time to
// finish loading and rendering on the page (so even after the "load" event is
// fired, they still have not finished rendering). Would be nice to replace
// the MutationObserver if there is a way to hook into the ipywidgets code to
// know when it is done.
const mainObserver = new MutationObserver(debouncedUpdateTabStops);

// On descendant nodes added/removed from main element
mainObserver.observe(document.getElementById("main-content"), {
subtree: true,
childList: true,
});

// On page load (when this function gets called)
updateTabStops();
}
function debounce(callback, wait) {
Expand Down Expand Up @@ -805,13 +831,21 @@ async function fetchRevealBannersTogether() {
* Call functions after document loading.
*/

// Call this one first to kick off the network request for the version warning
// This one first to kick off the network request for the version warning
// and announcement banner data as early as possible.
documentReady(fetchRevealBannersTogether);

documentReady(addModeListener);
documentReady(scrollToActive);
documentReady(addTOCInteractivity);
documentReady(setupSearchButtons);
documentReady(initRTDObserver);
documentReady(setupMobileSidebarKeyboardHandlers);
documentReady(setupLiteralBlockTabStops);

// Determining whether an element has scrollable content depends on stylesheets,
// so we're checking for the "load" event rather than "DOMContentLoaded"
if (document.readyState === "complete") {
addTabStopsToScrollableElements();
} else {
window.addEventListener("load", addTabStopsToScrollableElements);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
html div.rendered_html,
// NBsphinx ipywidgets output selector
html .jp-RenderedHTMLCommon {
// Add some margin around the element box for the focus ring. Otherwise the
// focus ring gets clipped because the containing elements have `overflow:
// hidden` applied to them (via the `.lm-Widget` selector)
margin: $focus-ring-width;

table {
table-layout: auto;
}
Expand Down
21 changes: 0 additions & 21 deletions src/pydata_sphinx_theme/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import types

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

if "pre" in args:
kwargs["data-tabindex"] = "0"

return super().starttag(*args, **kwargs)

def visit_literal_block(self, node):
"""Modify literal blocks.
- add tabindex="0" to <pre> tags within the HTML tree of the literal
block
"""
try:
super().visit_literal_block(node)
except nodes.SkipNode:
# If the super method raises nodes.SkipNode, then we know it
# executed successfully and appended to self.body a string of HTML
# representing the code block, which we then modify.
html_string = self.body[-1]
self.body[-1] = html_string.replace("<pre", '<pre data-tabindex="0"')
raise nodes.SkipNode

def visit_table(self, node):
"""Custom visit table method.
Expand Down
43 changes: 42 additions & 1 deletion tests/test_a11y.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,12 +245,14 @@ def test_version_switcher_highlighting(page: Page, url_base: str) -> None:
expect(entry).to_have_css("color", light_mode)


@pytest.mark.a11y
def test_code_block_tab_stop(page: Page, url_base: str) -> None:
"""Code blocks that have scrollable content should be tab stops."""
page.set_viewport_size({"width": 1440, "height": 720})
page.goto(urljoin(url_base, "/examples/kitchen-sink/blocks.html"))

code_block = page.locator(
'css=#code-block pre[data-tabindex="0"]', has_text="from typing import Iterator"
"css=#code-block pre", has_text="from typing import Iterator"
)

# Viewport is wide, so code block content fits, no overflow, no tab stop
Expand All @@ -265,3 +267,42 @@ def test_code_block_tab_stop(page: Page, url_base: str) -> None:
# Narrow viewport, content overflows and code block should be a tab stop
assert code_block.evaluate("el => el.scrollWidth > el.clientWidth") is True
assert code_block.evaluate("el => el.tabIndex") == 0


@pytest.mark.a11y
def test_notebook_output_tab_stop(page: Page, url_base: str) -> None:
"""Notebook outputs that have scrollable content should be tab stops."""
page.goto(urljoin(url_base, "/examples/pydata.html"))

# A "plain" notebook output
nb_output = page.locator("css=#Pandas > .nboutput > .output_area")

# At the default viewport size (1280 x 720) the Pandas data table has
# overflow...
assert nb_output.evaluate("el => el.scrollWidth > el.clientWidth") is True

# ...and so our js code on the page should make it keyboard-focusable
# (tabIndex = 0)
assert nb_output.evaluate("el => el.tabIndex") == 0


@pytest.mark.a11y
def test_notebook_ipywidget_output_tab_stop(page: Page, url_base: str) -> None:
"""Notebook ipywidget outputs that have scrollable content should be tab stops."""
page.goto(urljoin(url_base, "/examples/pydata.html"))

# An ipywidget notebook output
ipywidget = page.locator("css=.jp-RenderedHTMLCommon").first

# As soon as the ipywidget is attached to the page it should trigger the
# mutation observer, which has a 300 ms debounce
ipywidget.wait_for(state="attached")
page.wait_for_timeout(301)

# At the default viewport size (1280 x 720) the data table inside the
# ipywidget has overflow...
assert ipywidget.evaluate("el => el.scrollWidth > el.clientWidth") is True

# ...and so our js code on the page should make it keyboard-focusable
# (tabIndex = 0)
assert ipywidget.evaluate("el => el.tabIndex") == 0

0 comments on commit c839490

Please sign in to comment.