Skip to content
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

[WIP] [ENH] Implement new scrollspy #2119

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
135 changes: 106 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 @@ -1011,6 +983,111 @@ async function fetchRevealBannersTogether() {
}, 320);
}

/**
* Add the machinery needed to highlight elements in the TOC when scrolling.
*
*/
async function addTOCScrollSpy() {
// 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: 1, // Trigger once the heading becomes fully visible within the area described by the root margin
};

// 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;
}, 1500);
});

/**
* 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.
*/
Expand All @@ -1021,10 +1098,10 @@ documentReady(fetchRevealBannersTogether);

documentReady(addModeListener);
documentReady(scrollToActive);
documentReady(addTOCInteractivity);
documentReady(setupSearchButtons);
documentReady(setupSearchAsYouType);
documentReady(setupMobileSidebarKeyboardHandlers);
documentReady(addTOCScrollSpy);

// Determining whether an element has scrollable content depends on stylesheets,
// so we're checking for the "load" event rather than "DOMContentLoaded"
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 %}
10 changes: 4 additions & 6 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 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