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 33b9fde0c..72255397a 100644 --- a/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js +++ b/src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js @@ -95,34 +95,6 @@ function addModeListener() { }); } -/******************************************************************************* - * TOC interactivity - */ - -/** - * TOC sidebar - add "active" class to parent list - * - * Bootstrap's scrollspy adds the active class to the link, - * but for the automatic collapsing we need this on the parent list item. - * - * The event is triggered on "window" (and not the nav item as documented), - * see https://github.com/twbs/bootstrap/issues/20086 - */ -function addTOCInteractivity() { - window.addEventListener("activate.bs.scrollspy", function () { - const navLinks = document.querySelectorAll(".bd-toc-nav a"); - - navLinks.forEach((navLink) => { - navLink.parentElement.classList.remove("active"); - }); - - const activeNavLinks = document.querySelectorAll(".bd-toc-nav a.active"); - activeNavLinks.forEach((navLink) => { - navLink.parentElement.classList.add("active"); - }); - }); -} - /******************************************************************************* * Scroll */ @@ -1011,6 +983,111 @@ async function fetchRevealBannersTogether() { }, 320); } +/** + * Add the machinery needed to highlight elements in the TOC when scrolling. + * + */ +async function addArticleTOCSyncing() { + // Intersection observer options + const options = { + root: null, + rootMargin: `0px 0px -70% 0px`, // Use -70% for the bottom margin so that intersection events happen in only the top third of the viewport + threshold: 0, // Trigger as soon as the heading goes into (or out of) the top 30% of the viewport + }; + + // Right sidebar table of contents container + const pageToc = document.querySelector("#pst-page-toc-nav"); + + // The table of contents is a list of .toc-entry items each of which contains + // a link and possibly a nested list representing one level deeper in the + // table of contents. + const tocEntries = Array.from(pageToc.querySelectorAll(".toc-entry")); + const tocLinks = Array.from(pageToc.querySelectorAll("a")); + + // When the website visitor clicks a link in the TOC, we want that link to be + // highlighted/activated, NOT whichever TOC link the intersection observer + // callback would otherwise highlight, so we turn off the observer and turn it + // back on later. + let disableObserver = false; + pageToc.addEventListener("click", (event) => { + disableObserver = true; + const clickedTocLink = tocLinks.find((el) => el.contains(event.target)); + activate(clickedTocLink); + setTimeout(() => { + // Give the page ample time to finish scrolling, then re-enable the + // intersection observer. + disableObserver = false; + }, 1000); + }); + + /** + * Activate an element and its chain of ancestor TOC entries; deactivate + * everything else in the TOC. Together with the theme CSS, this unfolds + * the TOC out to the given entry and highlights that entry. + * + * @param {HTMLElement} tocLink The TOC entry to be highlighted + */ + function activate(tocLink) { + tocLinks.forEach((el) => { + if (el === tocLink) { + el.classList.add("active"); + el.setAttribute("aria-current", "true"); + } else { + el.classList.remove("active"); + el.removeAttribute("aria-current"); + } + }); + tocEntries.forEach((el) => { + if (el.contains(tocLink)) { + el.classList.add("active"); + } else { + el.classList.remove("active"); + } + }); + } + + /** + * Get the heading in the article associated with the link in the table of contents + * + * @param {HTMLElement} tocLink TOC DOM element to use to grab an article heading + * + * @returns The article heading that the TOC element links to + */ + function getHeading(tocLink) { + return document.querySelector( + `${tocLink.getAttribute("href")} > :is(h1,h2,h3,h4,h5,h6)`, + ); + } + + // Map article headings to their associated TOC links + const tocLinksByHeading = new Map(); + tocLinks.forEach((link) => tocLinksByHeading.set(getHeading(link), link)); + + /** + * + * @param {IntersectionObserverEntry[]} entries Objects containing threshold-crossing + * event information + * + */ + function callback(entries) { + if (disableObserver) { + return; + } + const entry = entries.filter((entry) => entry.isIntersecting).pop(); + if (!entry) { + return; + } + const heading = entry.target; + const tocLink = tocLinksByHeading.get(heading); + activate(tocLink); + } + + const observer = new IntersectionObserver(callback, options); + tocLinksByHeading.keys().forEach((heading) => { + observer.observe(heading); + }); +} + /******************************************************************************* * Call functions after document loading. */ @@ -1021,10 +1098,10 @@ documentReady(fetchRevealBannersTogether); documentReady(addModeListener); documentReady(scrollToActive); -documentReady(addTOCInteractivity); documentReady(setupSearchButtons); documentReady(setupSearchAsYouType); documentReady(setupMobileSidebarKeyboardHandlers); +documentReady(addArticleTOCSyncing); // Determining whether an element has scrollable content depends on stylesheets, // so we're checking for the "load" event rather than "DOMContentLoaded" diff --git a/src/pydata_sphinx_theme/assets/styles/components/_toc-inpage.scss b/src/pydata_sphinx_theme/assets/styles/components/_toc-inpage.scss index b4d416494..fd3629dbb 100644 --- a/src/pydata_sphinx_theme/assets/styles/components/_toc-inpage.scss +++ b/src/pydata_sphinx_theme/assets/styles/components/_toc-inpage.scss @@ -40,7 +40,8 @@ nav.page-toc { @include link-sidebar; - &.active { + &.active, + &[aria-current="true"] { @include link-sidebar-current; background-color: transparent; diff --git a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/components/page-toc.html b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/components/page-toc.html index 1a5b02582..c3feb6a97 100644 --- a/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/components/page-toc.html +++ b/src/pydata_sphinx_theme/theme/pydata_sphinx_theme/components/page-toc.html @@ -7,7 +7,7 @@ class="page-toc tocsection onthispage"> {{ _('On this page') }} -