web/wp-content/themes/mozilla-builders/static/js/plugins/heading-nav.js (70 lines of code) (raw):

function slugify(text) { return text.toLowerCase().replace(/\s+/g, '-'); } /** @type {import('alpinejs').PluginCallback} */ export function headingNav(Alpine) { Alpine.directive('headings', (el, { value }, { cleanup }) => { if (!value) { const unregister = registerRoot(el, Alpine); cleanup(() => unregister()); } }); } /** * Registers the root element. * * @param {HTMLElement} el * @param {import('alpinejs').Alpine} Alpine */ function registerRoot(el, Alpine) { const content = el.querySelector('[x-headings\\:content]'); if (!content) { return () => {}; } const headingEls = Array.from(content.querySelectorAll('h1, h2, h3, h4, h5, h6')); const headingIdSet = new Set(); const headings = []; headingEls.forEach(heading => { const text = heading.innerText; let id = heading.id || slugify(text); if (headingIdSet.has(id)) { id = `${id}-${headingIdSet.size}`; } heading.id = id; heading.classList.add('scroll-mt-32'); headingIdSet.add(id); headings.push({ text, id, href: `#${id}`, }); }); Alpine.data('headings', () => ({ headings, activeIndex: 0, lastActiveIndex: 0, observer: null, init() { this.observer = new IntersectionObserver(this.onIntersection.bind(this), { rootMargin: '0px 0px -80% 0px', threshold: 1, }); headingEls.forEach(heading => { this.observer.observe(heading); }); }, /** * @param {IntersectionObserverEntry[]} entries */ onIntersection(entries) { const intersectingEntry = entries.find(entry => entry.isIntersecting); let nextIndex = this.activeIndex; if (intersectingEntry) { nextIndex = this.headings.findIndex(heading => heading.id === intersectingEntry.target.id); } this.lastActiveIndex = this.activeIndex; if (nextIndex === -1) { this.activeIndex = 0; } else { this.activeIndex = nextIndex; } }, destroy() { this.observer?.disconnect(); this.observer = null; }, })); return Alpine.bind(el, () => ({ 'x-data': 'headings', })); }