section-nav.js

 1/**
 2 * Sticky Section Nav
 3 * Shows/hides based on scroll position and highlights current section.
 4 */
 5
 6export function initSectionNav() {
 7	const nav = document.getElementById('section-nav');
 8	if (!nav) return;
 9
10	const items = nav.querySelectorAll('.section-nav-item');
11	const sectionIds = Array.from(items).map(item => item.dataset.section);
12
13	// Show/hide nav based on scroll position
14	const hero = document.getElementById('hero');
15	const footer = document.querySelector('.site-footer');
16	if (!hero) return;
17
18	let ticking = false;
19
20	// Returns the element's top position relative to the document,
21	// which works even when the element is inside a positioned parent.
22	function docTop(el) {
23		return el.getBoundingClientRect().top + window.scrollY;
24	}
25
26	function updateNav() {
27		const scrollY = window.scrollY;
28		const heroBottom = hero.offsetTop + hero.offsetHeight - 100;
29		const footerTop = footer ? docTop(footer) : Infinity;
30		const viewportBottom = scrollY + window.innerHeight;
31
32		// Show nav after hero, hide when footer is visible
33		if (scrollY > heroBottom && viewportBottom < footerTop + 60) {
34			nav.classList.add('is-visible');
35		} else {
36			nav.classList.remove('is-visible');
37		}
38
39		// Find current section
40		let currentSection = null;
41		const viewportMiddle = scrollY + window.innerHeight * 0.4;
42
43		for (let i = sectionIds.length - 1; i >= 0; i--) {
44			const section = document.getElementById(sectionIds[i]);
45			if (section && docTop(section) <= viewportMiddle) {
46				currentSection = sectionIds[i];
47				break;
48			}
49		}
50
51		// If the current section shares its top row with siblings (e.g. side-by-side
52		// changelog + FAQ on desktop), treat all of them as active.
53		const activeSections = new Set();
54		if (currentSection) {
55			const currentEl = document.getElementById(currentSection);
56			const currentTop = currentEl ? docTop(currentEl) : 0;
57			sectionIds.forEach(id => {
58				const el = document.getElementById(id);
59				if (el && Math.abs(docTop(el) - currentTop) < 4) {
60					activeSections.add(id);
61				}
62			});
63		}
64
65		// Update active state
66		items.forEach(item => {
67			if (activeSections.has(item.dataset.section)) {
68				item.classList.add('is-active');
69			} else {
70				item.classList.remove('is-active');
71			}
72		});
73
74		ticking = false;
75	}
76
77	window.addEventListener('scroll', () => {
78		if (!ticking) {
79			requestAnimationFrame(updateNav);
80			ticking = true;
81		}
82	}, { passive: true });
83
84	// Initial check
85	updateNav();
86}