Skip to content

Commit 6370851

Browse files
peytondmurraygabalafou
authored andcommitted
[ENH] Implement new scrollspy for secondary sidebar TOC
1 parent 0fe9d02 commit 6370851

File tree

1 file changed

+123
-38
lines changed

1 file changed

+123
-38
lines changed

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

Lines changed: 123 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -95,43 +95,6 @@ function addModeListener() {
9595
});
9696
}
9797

98-
/*******************************************************************************
99-
* Right sidebar table of contents (TOC) interactivity
100-
*/
101-
function setupPageTableOfContents() {
102-
const pageToc = document.querySelector("#pst-page-toc-nav");
103-
pageToc.addEventListener("click", (event) => {
104-
const clickedLink = event.target.closest(".nav-link");
105-
if (!clickedLink) {
106-
return;
107-
}
108-
109-
// First, clear all the added classes and attributes
110-
// -----
111-
pageToc.querySelectorAll("a[aria-current]").forEach((el) => {
112-
el.removeAttribute("aria-current");
113-
});
114-
pageToc.querySelectorAll(".active").forEach((el) => {
115-
el.classList.remove("active");
116-
});
117-
118-
// Then add the classes and attributes to where they should go now
119-
// -----
120-
clickedLink.setAttribute("aria-current", "true");
121-
clickedLink.classList.add("active");
122-
// Find all parents (up to the TOC root) matching .toc-entry and add the
123-
// active class. This activates style rules that expand the TOC when the
124-
// user clicks a TOC entry that has nested entries.
125-
let parentElement = clickedLink.parentElement;
126-
while (parentElement && parentElement !== pageToc) {
127-
if (parentElement.matches(".toc-entry")) {
128-
parentElement.classList.add("active");
129-
}
130-
parentElement = parentElement.parentElement;
131-
}
132-
});
133-
}
134-
13598
/*******************************************************************************
13699
* Scroll
137100
*/
@@ -1020,6 +983,128 @@ async function fetchRevealBannersTogether() {
1020983
}, 320);
1021984
}
1022985

986+
/**
987+
* Add the machinery needed to highlight elements in the TOC when scrolling.
988+
*
989+
*/
990+
async function addTOCScrollSpy() {
991+
const options = {
992+
root: null, // Target the viewport
993+
// Offset the rootMargin slightly so that intersections trigger _before_ headings begin to
994+
// go offscreen
995+
rootMargin: `${-2 * document.querySelector("header.bd-header").getBoundingClientRect().bottom}px 0px 0px 0px`,
996+
threshold: 0, // Trigger as soon as 1 pixel is visible
997+
};
998+
999+
const pageToc = document.querySelector("#pst-page-toc-nav");
1000+
const tocLinks = Array.from(document.querySelectorAll("#pst-page-toc-nav a"));
1001+
1002+
/**
1003+
* Activate an element and its parent TOC dropdowns; deactivate
1004+
* everything else in the TOC. Together with the theme CSS, this
1005+
* highlights the given TOC entry.
1006+
*
1007+
* @param {HTMLElement} tocElement The TOC entry to be highlighted
1008+
*/
1009+
function activate(tocElement) {
1010+
// Deactivate all TOC links except the requested element
1011+
tocLinks
1012+
.filter((el) => el !== tocElement)
1013+
.forEach((el) => {
1014+
el.classList.remove("active");
1015+
el.removeAttribute("aria-current");
1016+
});
1017+
1018+
// Activate the requested element
1019+
tocElement.classList.add("active");
1020+
tocElement.setAttribute("aria-current", "true");
1021+
1022+
// Travel up the DOM from the requested element, collecting the set of
1023+
// all parent elements that need to be activated
1024+
const parents = new Set();
1025+
let el = tocElement.parentElement;
1026+
while (el && el !== pageToc) {
1027+
if (el.matches(".toc-entry")) {
1028+
parents.add(el);
1029+
}
1030+
el = el.parentElement;
1031+
}
1032+
1033+
// Iterate over all child elements of the TOC, deactivating everything
1034+
// that isn't a parent of the active node and activating the parents
1035+
// of the active TOC entry
1036+
pageToc.querySelectorAll(".toc-entry").forEach((el) => {
1037+
if (parents.has(el)) {
1038+
el.classList.add("active");
1039+
} else {
1040+
el.classList.remove("active");
1041+
}
1042+
});
1043+
}
1044+
1045+
/**
1046+
* Get the heading in the article associated with a TOC entry.
1047+
*
1048+
* @param {HTMLElement} tocElement TOC DOM element to use to grab an article heading
1049+
*
1050+
* @returns The article heading that the TOC element links to
1051+
*/
1052+
function getHeading(tocElement) {
1053+
return document.querySelector(
1054+
`${tocElement.getAttribute("href")} > :is(h1,h2,h3,h4,h5,h6)`,
1055+
);
1056+
}
1057+
1058+
// Create a hashmap which stores the state of the headings. This object maps headings
1059+
// in the article to TOC elements, along with information about whether they are
1060+
// visible and the order in which they appear in the article.
1061+
const headingState = new Map(
1062+
Array.from(tocLinks).map((el, index) => {
1063+
return [
1064+
getHeading(el),
1065+
{
1066+
tocElement: el,
1067+
visible: false,
1068+
index: index,
1069+
},
1070+
];
1071+
}),
1072+
);
1073+
1074+
/**
1075+
*
1076+
* @param {IntersectionObserverEntry} entries Objects containing threshold-crossing
1077+
* event information
1078+
*
1079+
*/
1080+
function callback(entries) {
1081+
// Update the state of the TOC headings
1082+
entries.forEach((entry) => {
1083+
headingState.get(entry.target).visible = entry.isIntersecting;
1084+
});
1085+
1086+
// Sort the active headings by the order in which they appear in the TOC.
1087+
const sorted = Array.from(headingState.values())
1088+
.filter(({ visible }) => visible)
1089+
.sort((a, b) => a.index > b.index);
1090+
1091+
// If there are any visible results, activate the one _above_ the first visible
1092+
// heading. This ensures that when a heading scrolls offscreen, the TOC entry
1093+
// for that entry remains highlighted.
1094+
//
1095+
// If the first element is visible, just highlight the first entry in the TOC.
1096+
if (sorted.length > 0) {
1097+
const idx = sorted[0].index;
1098+
activate(tocLinks[idx > 0 ? idx - 1 : 0]);
1099+
}
1100+
}
1101+
1102+
const observer = new IntersectionObserver(callback, options);
1103+
tocLinks.forEach((tocElement) => {
1104+
observer.observe(getHeading(tocElement));
1105+
});
1106+
}
1107+
10231108
/*******************************************************************************
10241109
* Call functions after document loading.
10251110
*/
@@ -1030,10 +1115,10 @@ documentReady(fetchRevealBannersTogether);
10301115

10311116
documentReady(addModeListener);
10321117
documentReady(scrollToActive);
1033-
documentReady(setupPageTableOfContents);
10341118
documentReady(setupSearchButtons);
10351119
documentReady(setupSearchAsYouType);
10361120
documentReady(setupMobileSidebarKeyboardHandlers);
1121+
documentReady(addTOCScrollSpy);
10371122

10381123
// Determining whether an element has scrollable content depends on stylesheets,
10391124
// so we're checking for the "load" event rather than "DOMContentLoaded"

0 commit comments

Comments
 (0)