Skip to content

Commit 5d9d8a5

Browse files
peytondmurraygabalafoupre-commit-ci[bot]
authored
[ENH] Implement new scrollspy (#2119)
This PR implements a new scrollspy mechanism to highlight entries in the secondary sidebar TOC; fixes #1965, and builds on top of #2107. ## Approach Every link in the secondary sidebar TOC is a reference to a section in the main article. This PR works by adding an `IntersectionObserver` that observes the _heading_ at the top of every one of these sections. As the user scrolls down, the `IntersectionObserver` triggers a callback when new section headings come into view; an object containing the visibility of the various sections is kept up to date by these events. The first visible heading is the one that needs to have its respective TOC entry highlighted. ## Other Notes - Unrelated, but I kept getting errors coming from a place in `setupAnnouncementBanner` where a `const` was being assigned to. I fixed this just to make it stop complaining. If you'd rather have this in a separate PR, I'm happy to do so. - The `IntersectionObserver` is set to observe element crossings with the viewport, but because there's a sticky menu bar at the top of the page I added a `rootMargin` to pad down the top edge so that element crossings happen below that menu bar. Testing by hand this seems to work pretty well. - I also tested clicking on entries in the TOC, and as long as you click something high enough up on the page (not close to the bottom), the correct element gets highlighted as expected. Of course, if you have a bunch of sections right at the bottom, the one you click on won't be highlighted: ``` ------------- | Section A | | | --|-----------|--- ⁞ | | ⁞ ⁞ | | ⁞ ⁞ | | ⁞ ⁞ | Section B | ⁞ <-- Viewport is still considered to be focused on Section A, ⁞ | Section C | ⁞ which extends all the way to the start of Section B ⁞ | end | ⁞ ------------------ ``` --------- Co-authored-by: gabalafou <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 71ecc96 commit 5d9d8a5

File tree

7 files changed

+502
-38
lines changed

7 files changed

+502
-38
lines changed

src/pydata_sphinx_theme/assets/scripts/pydata-sphinx-theme.js

+155-29
Original file line numberDiff line numberDiff line change
@@ -95,34 +95,6 @@ function addModeListener() {
9595
});
9696
}
9797

98-
/*******************************************************************************
99-
* TOC interactivity
100-
*/
101-
102-
/**
103-
* TOC sidebar - add "active" class to parent list
104-
*
105-
* Bootstrap's scrollspy adds the active class to the <a> link,
106-
* but for the automatic collapsing we need this on the parent list item.
107-
*
108-
* The event is triggered on "window" (and not the nav item as documented),
109-
* see https://github.com/twbs/bootstrap/issues/20086
110-
*/
111-
function addTOCInteractivity() {
112-
window.addEventListener("activate.bs.scrollspy", function () {
113-
const navLinks = document.querySelectorAll(".bd-toc-nav a");
114-
115-
navLinks.forEach((navLink) => {
116-
navLink.parentElement.classList.remove("active");
117-
});
118-
119-
const activeNavLinks = document.querySelectorAll(".bd-toc-nav a.active");
120-
activeNavLinks.forEach((navLink) => {
121-
navLink.parentElement.classList.add("active");
122-
});
123-
});
124-
}
125-
12698
/*******************************************************************************
12799
* Scroll
128100
*/
@@ -1012,6 +984,160 @@ async function fetchRevealBannersTogether() {
1012984
}, 320);
1013985
}
1014986

987+
/**
988+
* Add the machinery needed to highlight elements in the TOC when scrolling.
989+
*
990+
*/
991+
function setupArticleTocSyncing() {
992+
// Right sidebar table of contents container
993+
const pageToc = document.querySelector("#pst-page-toc-nav");
994+
995+
// Not all pages have or include a table of contents. (For example, in the PST
996+
// docs, at the time of this writing: /user_guide/index.html.)
997+
if (!pageToc) {
998+
return;
999+
}
1000+
1001+
// The table of contents is a list of .toc-entry items each of which contains
1002+
// a link and possibly a nested list representing one level deeper in the
1003+
// table of contents.
1004+
const tocEntries = Array.from(pageToc.querySelectorAll(".toc-entry"));
1005+
const tocLinks = Array.from(pageToc.querySelectorAll("a"));
1006+
1007+
// If there are no links in the TOC, there's no syncing to be done.
1008+
// (Currently, the template does not render the TOC container if there are no
1009+
// TOC links, so this condition should never evaluate to true if the TOC
1010+
// container is found on the page, but should the template change in the
1011+
// future, this check will prevent a runtime error.)
1012+
if (tocLinks.length === 0) {
1013+
return;
1014+
}
1015+
1016+
// When the website visitor clicks a link in the TOC, we want that link to be
1017+
// highlighted/activated, NOT whichever TOC link the intersection observer
1018+
// callback would otherwise highlight, so we turn off the observer and turn it
1019+
// back on later.
1020+
let disableObserver = false;
1021+
pageToc.addEventListener("click", (event) => {
1022+
disableObserver = true;
1023+
const clickedTocLink = tocLinks.find((el) => el.contains(event.target));
1024+
activate(clickedTocLink);
1025+
setTimeout(() => {
1026+
// Give the page ample time to finish scrolling, then re-enable the
1027+
// intersection observer.
1028+
disableObserver = false;
1029+
}, 1000);
1030+
});
1031+
1032+
/**
1033+
* Activate an element and its chain of ancestor TOC entries; deactivate
1034+
* everything else in the TOC. Together with the theme CSS, this unfolds
1035+
* the TOC out to the given entry and highlights that entry.
1036+
*
1037+
* @param {HTMLElement} tocLink The TOC entry to be highlighted
1038+
*/
1039+
function activate(tocLink) {
1040+
tocLinks.forEach((el) => {
1041+
if (el === tocLink) {
1042+
el.classList.add("active");
1043+
el.setAttribute("aria-current", "true");
1044+
} else {
1045+
el.classList.remove("active");
1046+
el.removeAttribute("aria-current");
1047+
}
1048+
});
1049+
tocEntries.forEach((el) => {
1050+
if (el.contains(tocLink)) {
1051+
el.classList.add("active");
1052+
} else {
1053+
el.classList.remove("active");
1054+
}
1055+
});
1056+
}
1057+
1058+
/**
1059+
* Get the heading in the article associated with the link in the table of contents
1060+
*
1061+
* @param {HTMLElement} tocLink TOC DOM element to use to grab an article heading
1062+
*
1063+
* @returns The article heading that the TOC element links to
1064+
*/
1065+
function getHeading(tocLink) {
1066+
const href = tocLink.getAttribute("href");
1067+
if (!href.startsWith("#")) {
1068+
return;
1069+
}
1070+
const id = href.substring(1);
1071+
// There are cases where href="#" (for example, the first one at /examples/kitchen-sink/structure.html)
1072+
if (!id) {
1073+
return;
1074+
}
1075+
// Use getElementById() because querySelector() requires escaping the id string
1076+
const target = document.getElementById(id);
1077+
// Often the target is a section but we want to track section's heading
1078+
const heading = target.querySelector(":is(h1,h2,h3,h4,h5,h6)");
1079+
// Fallback to the target if there is no heading (for example, links on the
1080+
// PST docs page /examples/kitchen-sink/api.html target <dt> elements)
1081+
return heading || target;
1082+
}
1083+
1084+
// Map heading elements to their associated TOC links
1085+
const headingsToTocLinks = new Map();
1086+
tocLinks.forEach((tocLink) => {
1087+
const heading = getHeading(tocLink);
1088+
if (heading) {
1089+
headingsToTocLinks.set(heading, tocLink);
1090+
}
1091+
});
1092+
1093+
let observer;
1094+
1095+
function connectIntersectionObserver() {
1096+
if (observer) {
1097+
observer.disconnect();
1098+
}
1099+
1100+
const header = document.querySelector("#pst-header");
1101+
const headerHeight = header.getBoundingClientRect().height;
1102+
1103+
// Intersection observer options
1104+
const options = {
1105+
root: null,
1106+
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
1107+
threshold: 0, // Trigger as soon as the heading goes into (or out of) the top 30% of the viewport
1108+
};
1109+
1110+
/**
1111+
*
1112+
* @param {IntersectionObserverEntry[]} entries Objects containing threshold-crossing
1113+
* event information
1114+
*
1115+
*/
1116+
function callback(entries) {
1117+
if (disableObserver) {
1118+
return;
1119+
}
1120+
const entry = entries.filter((entry) => entry.isIntersecting).pop();
1121+
if (!entry) {
1122+
return;
1123+
}
1124+
const heading = entry.target;
1125+
const tocLink = headingsToTocLinks.get(heading);
1126+
activate(tocLink);
1127+
}
1128+
1129+
observer = new IntersectionObserver(callback, options);
1130+
headingsToTocLinks.keys().forEach((heading) => {
1131+
observer.observe(heading);
1132+
});
1133+
}
1134+
1135+
// If the user resizes the window, the header height may change and the
1136+
// intersection observer's root margin will need to be recalculated
1137+
window.addEventListener("resize", debounce(connectIntersectionObserver, 300));
1138+
connectIntersectionObserver();
1139+
}
1140+
10151141
/*******************************************************************************
10161142
* Set up expand/collapse button for primary sidebar
10171143
*/
@@ -1145,10 +1271,10 @@ documentReady(fetchRevealBannersTogether);
11451271

11461272
documentReady(addModeListener);
11471273
documentReady(scrollToActive);
1148-
documentReady(addTOCInteractivity);
11491274
documentReady(setupSearchButtons);
11501275
documentReady(setupSearchAsYouType);
11511276
documentReady(setupMobileSidebarKeyboardHandlers);
1277+
documentReady(setupArticleTocSyncing);
11521278
documentReady(() => {
11531279
try {
11541280
setupCollapseSidebarButton();

src/pydata_sphinx_theme/assets/styles/components/_toc-inpage.scss

+2-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ nav.page-toc {
4040

4141
@include link-sidebar;
4242

43-
&.active {
43+
&.active,
44+
&[aria-current="true"] {
4445
@include link-sidebar-current;
4546

4647
background-color: transparent;

src/pydata_sphinx_theme/theme/pydata_sphinx_theme/components/page-toc.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
class="page-toc tocsection onthispage">
88
<i class="fa-solid fa-list"></i> {{ _('On this page') }}
99
</div>
10-
<nav class="bd-toc-nav page-toc" aria-labelledby="{{ page_navigation_heading_id }}">
10+
<nav id="pst-page-toc-nav" class="page-toc" aria-labelledby="{{ page_navigation_heading_id }}">
1111
{{ page_toc }}
1212
</nav>
1313
{%- endif %}

src/pydata_sphinx_theme/theme/pydata_sphinx_theme/layout.html

+5-7
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,14 @@
4848
{% endif %}
4949
{%- endblock extrahead %}
5050
{% block body_tag %}
51-
{# set up with scrollspy to update the toc as we scroll #}
52-
{# ref: https://getbootstrap.com/docs/4.0/components/scrollspy/ #}
53-
<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 }}">
51+
<body data-default-mode="{{ default_mode }}">
52+
{%- endblock %}
5453

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

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

8079
{% block docs_navbar %}
81-
<header class="bd-header navbar navbar-expand-lg bd-navbar d-print-none">
80+
<header id="pst-header" class="bd-header navbar navbar-expand-lg bd-navbar d-print-none">
8281
{%- include "sections/header.html" %}
8382
</header>
8483
{% endblock docs_navbar %}
@@ -148,7 +147,6 @@
148147
</footer>
149148
{%- endblock footer %}
150149
{# Silence the sidebars and relbars since we define our own #}
151-
{% block header %}{% endblock %}
152150
{% block relbar1 %}{% endblock %}
153151
{% block relbar2 %}{% endblock %}
154152
{% block sidebarsourcelink %}{% endblock %}

0 commit comments

Comments
 (0)