diff --git a/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js b/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js index f3b54908d..430ef08db 100644 --- a/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js +++ b/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js @@ -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) { @@ -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); +} diff --git a/src/pydata_sphinx_theme/assets/styles/extensions/_notebooks.scss b/src/pydata_sphinx_theme/assets/styles/extensions/_notebooks.scss index b4c77a5f8..96257f076 100644 --- a/src/pydata_sphinx_theme/assets/styles/extensions/_notebooks.scss +++ b/src/pydata_sphinx_theme/assets/styles/extensions/_notebooks.scss @@ -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; } diff --git a/src/pydata_sphinx_theme/translator.py b/src/pydata_sphinx_theme/translator.py index b42e141e0..ba715ab43 100644 --- a/src/pydata_sphinx_theme/translator.py +++ b/src/pydata_sphinx_theme/translator.py @@ -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 @@ -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
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" totags 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("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 @@ -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