Files
brk/website_next/learn/scroll-spy.js
T
2026-06-07 16:11:42 +02:00

123 lines
3.2 KiB
JavaScript

/** @param {HTMLElement} main */
export function initScrollSpy(main) {
const nav = /** @type {HTMLElement} */ (main.querySelector("nav"));
const sections = [...main.querySelectorAll("section[id]")];
const sectionStates = sections.map((section) => ({
section,
firstChild: section.querySelector(":scope > section"),
}));
const links = new Map(
[...main.querySelectorAll('nav a[href^="#"]')].map((link) => [
link.getAttribute("href"),
link,
]),
);
/** @type {string | null} */
let current = null;
let scheduled = false;
function getViewportTop() {
return Number.parseFloat(getComputedStyle(main).scrollPaddingTop);
}
/**
* @param {Element} section
* @param {Element | null} firstChild
* @param {number} viewportTop
*/
function getOwnVisibleHeight(section, firstChild, viewportTop) {
const sectionRect = section.getBoundingClientRect();
const childRect = firstChild?.getBoundingClientRect();
const top = Math.max(sectionRect.top, viewportTop);
const bottom = Math.min(
childRect?.top ?? sectionRect.bottom,
window.innerHeight,
);
return Math.max(
0,
bottom - top,
);
}
/** @param {string} hash */
function getLink(hash) {
return /** @type {HTMLAnchorElement} */ (links.get(hash));
}
/** @param {HTMLElement} link */
function scrollLinkIntoNav(link) {
const style = getComputedStyle(nav);
const top = Number.parseFloat(style.paddingTop);
const bottom = Number.parseFloat(style.paddingBottom);
const navRect = nav.getBoundingClientRect();
const linkRect = link.getBoundingClientRect();
if (linkRect.top < navRect.top + top) {
nav.scrollBy({ top: linkRect.top - navRect.top - top });
}
if (linkRect.bottom > navRect.bottom - bottom) {
nav.scrollBy({ top: linkRect.bottom - navRect.bottom + bottom });
}
}
/** @param {string} hash */
function setCurrentHash(hash) {
if (hash === current) return;
if (current) getLink(current).removeAttribute("aria-current");
const link = getLink(hash);
link.setAttribute("aria-current", "location");
scrollLinkIntoNav(link);
history.replaceState(null, "", hash);
current = hash;
}
function getCurrentSection() {
/** @type {{ section: Element, firstChild: Element | null } | undefined} */
let currentState;
let currentHeight = 0;
const viewportTop = getViewportTop();
for (const state of sectionStates) {
const height = getOwnVisibleHeight(
state.section,
state.firstChild,
viewportTop,
);
if (height > currentHeight) {
currentState = state;
currentHeight = height;
}
}
return currentState?.section;
}
function update() {
if (main.hidden) return;
const section = getCurrentSection();
if (section) setCurrentHash(`#${section.id}`);
}
function scheduleUpdate() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
update();
});
}
window.addEventListener("scroll", scheduleUpdate, { passive: true });
main.addEventListener("pageactive", scheduleUpdate);
scheduleUpdate();
}