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" to 
 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(" 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