Skip to content

[ENH] Implement new scrollspy #2119

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Mar 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
17a3b57
BUG - stop using Scrollspy
gabalafou Jan 24, 2025
c4548b9
Update src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
gabalafou Jan 27, 2025
0fe9d02
[pre-commit.ci] Automatic linting and formatting fixes
pre-commit-ci[bot] Jan 27, 2025
6370851
[ENH] Implement new scrollspy for secondary sidebar TOC
peytondmurray Jan 30, 2025
38d46a0
Address review comments
peytondmurray Feb 6, 2025
09b58f8
Ensure that the TOC link that the user clicked is activated
gabalafou Feb 7, 2025
2046203
Update src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
peytondmurray Feb 7, 2025
9eb31e1
Add a test of the scrollspy
peytondmurray Feb 14, 2025
e8f93b9
Split out scroll test site on its own
peytondmurray Feb 14, 2025
a7ef19a
Move test from test_playwright.py to test_a11y.py
peytondmurray Feb 14, 2025
23e2b29
threshold 0, timeout 1000, fix test
gabalafou Feb 14, 2025
21a28b3
fix bad merge
gabalafou Feb 14, 2025
7078c47
Remove test fixture
gabalafou Feb 14, 2025
db0fb4f
add a few more assertions
gabalafou Feb 15, 2025
215335a
fix some edge cases
gabalafou Feb 15, 2025
ed3231e
Merge branch 'main' into implement-new-scrollspy
peytondmurray Mar 20, 2025
0e1367e
Move test from a11y to playwright
peytondmurray Mar 20, 2025
9c10dd1
Make CI build the docs before running tests
peytondmurray Mar 20, 2025
b9f0fa1
rewrite playwright test to use test site
gabalafou Mar 24, 2025
cc49912
Revert "Make CI build the docs before running tests"
gabalafou Mar 24, 2025
04a8e25
increase wait time (fix windows?)
gabalafou Mar 24, 2025
b7b8fa4
Merge branch 'main' into implement-new-scrollspy
gabalafou Mar 24, 2025
38c6bb2
scroll in test with mouse wheel
gabalafou Mar 24, 2025
0d5ad67
Merge branch 'main' into implement-new-scrollspy
gabalafou Mar 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 155 additions & 29 deletions src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a> 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
*/
Expand Down Expand Up @@ -1012,6 +984,160 @@ async function fetchRevealBannersTogether() {
}, 320);
}

/**
* Add the machinery needed to highlight elements in the TOC when scrolling.
*
*/
function setupArticleTocSyncing() {
// Right sidebar table of contents container
const pageToc = document.querySelector("#pst-page-toc-nav");

// Not all pages have or include a table of contents. (For example, in the PST
// docs, at the time of this writing: /user_guide/index.html.)
if (!pageToc) {
return;
}

// 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"));

// If there are no links in the TOC, there's no syncing to be done.
// (Currently, the template does not render the TOC container if there are no
// TOC links, so this condition should never evaluate to true if the TOC
// container is found on the page, but should the template change in the
// future, this check will prevent a runtime error.)
if (tocLinks.length === 0) {
return;
}

// 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) {
const href = tocLink.getAttribute("href");
if (!href.startsWith("#")) {
return;
}
const id = href.substring(1);
// There are cases where href="#" (for example, the first one at /examples/kitchen-sink/structure.html)
if (!id) {
return;
}
// Use getElementById() because querySelector() requires escaping the id string
const target = document.getElementById(id);
// Often the target is a section but we want to track section's heading
const heading = target.querySelector(":is(h1,h2,h3,h4,h5,h6)");
// Fallback to the target if there is no heading (for example, links on the
// PST docs page /examples/kitchen-sink/api.html target <dt> elements)
return heading || target;
}

// Map heading elements to their associated TOC links
const headingsToTocLinks = new Map();
tocLinks.forEach((tocLink) => {
const heading = getHeading(tocLink);
if (heading) {
headingsToTocLinks.set(heading, tocLink);
}
});

let observer;

function connectIntersectionObserver() {
if (observer) {
observer.disconnect();
}

const header = document.querySelector("#pst-header");
const headerHeight = header.getBoundingClientRect().height;

// Intersection observer options
const options = {
root: null,
rootMargin: `-${headerHeight}px 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
};

/**
*
* @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 = headingsToTocLinks.get(heading);
activate(tocLink);
}

observer = new IntersectionObserver(callback, options);
headingsToTocLinks.keys().forEach((heading) => {
observer.observe(heading);
});
}

// If the user resizes the window, the header height may change and the
// intersection observer's root margin will need to be recalculated
window.addEventListener("resize", debounce(connectIntersectionObserver, 300));
connectIntersectionObserver();
}

/*******************************************************************************
* Set up expand/collapse button for primary sidebar
*/
Expand Down Expand Up @@ -1145,10 +1271,10 @@ documentReady(fetchRevealBannersTogether);

documentReady(addModeListener);
documentReady(scrollToActive);
documentReady(addTOCInteractivity);
documentReady(setupSearchButtons);
documentReady(setupSearchAsYouType);
documentReady(setupMobileSidebarKeyboardHandlers);
documentReady(setupArticleTocSyncing);
documentReady(() => {
try {
setupCollapseSidebarButton();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ nav.page-toc {

@include link-sidebar;

&.active {
&.active,
&[aria-current="true"] {
@include link-sidebar-current;

background-color: transparent;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
class="page-toc tocsection onthispage">
<i class="fa-solid fa-list"></i> {{ _('On this page') }}
</div>
<nav class="bd-toc-nav page-toc" aria-labelledby="{{ page_navigation_heading_id }}">
<nav id="pst-page-toc-nav" class="page-toc" aria-labelledby="{{ page_navigation_heading_id }}">
{{ page_toc }}
</nav>
{%- endif %}
12 changes: 5 additions & 7 deletions src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,14 @@
{% endif %}
{%- endblock extrahead %}
{% block body_tag %}
{# set up with scrollspy to update the toc as we scroll #}
{# ref: https://getbootstrap.com/docs/4.0/components/scrollspy/ #}
<body data-bs-spy="scroll" data-bs-target=".bd-toc-nav" data-offset="180" data-bs-root-margin="0px 0px -60%" data-default-mode="{{ default_mode }}">
<body data-default-mode="{{ default_mode }}">
{%- endblock %}

{% block header %}
{# A button hidden by default to help assistive devices quickly jump to main content #}
{# ref: https://www.youtube.com/watch?v=VUR0I5mqq7I #}
<div id="pst-skip-link" class="skip-link d-print-none"><a href="#main-content">{{ _("Skip to main content") }}</a></div>

{%- endblock %}
{% endblock %}

{%- block content %}
{# A tiny helper pixel to detect if we've scrolled #}
Expand All @@ -78,7 +77,7 @@
{% include "sections/announcement.html" %}

{% block docs_navbar %}
<header class="bd-header navbar navbar-expand-lg bd-navbar d-print-none">
<header id="pst-header" class="bd-header navbar navbar-expand-lg bd-navbar d-print-none">
{%- include "sections/header.html" %}
</header>
{% endblock docs_navbar %}
Expand Down Expand Up @@ -148,7 +147,6 @@
</footer>
{%- endblock footer %}
{# Silence the sidebars and relbars since we define our own #}
{% block header %}{% endblock %}
{% block relbar1 %}{% endblock %}
{% block relbar2 %}{% endblock %}
{% block sidebarsourcelink %}{% endblock %}
Loading
Loading