| New file |
| | |
| | | /** |
| | | * Glossary Navigation Active State Manager |
| | | * Handles highlighting active terms as they scroll into view |
| | | * and syncing navigation with scroll position |
| | | */ |
| | | class GlossaryNavigator { |
| | | constructor(glossarySelector = 'dl.glossary', navSelector = 'nav.glossary-index') { |
| | | this.glossary = document.querySelector(glossarySelector); |
| | | this.nav = document.querySelector(navSelector); |
| | | |
| | | if (!this.glossary || !this.nav) return; |
| | | |
| | | this.terms = this.glossary.querySelectorAll('dt[id]'); |
| | | this.navList = this.nav.querySelector('ul'); |
| | | this.activeClass = 'active'; |
| | | this.currentActive = null; |
| | | |
| | | this.init(); |
| | | this.setupResizeHandler(); |
| | | } |
| | | |
| | | init() { |
| | | // Set up Intersection Observer with screen-size appropriate margins |
| | | const observerOptions = { |
| | | root: null, // viewport |
| | | rootMargin: this.getRootMargin(), |
| | | threshold: 0 |
| | | }; |
| | | |
| | | this.observer = new IntersectionObserver( |
| | | (entries) => this.handleIntersection(entries), |
| | | observerOptions |
| | | ); |
| | | |
| | | // Observe all terms |
| | | this.terms.forEach(term => this.observer.observe(term)); |
| | | |
| | | // Also handle manual scroll for edge cases |
| | | this.handleScroll = this.debounce(() => this.checkActiveTerm(), 100); |
| | | window.addEventListener('scroll', this.handleScroll, { passive: true }); |
| | | } |
| | | |
| | | getRootMargin() { |
| | | // On larger screens: centered (50% from top and bottom) |
| | | return '-50% 0px -50% 0px'; |
| | | } |
| | | |
| | | setupResizeHandler() { |
| | | let resizeTimer; |
| | | window.addEventListener('resize', () => { |
| | | clearTimeout(resizeTimer); |
| | | resizeTimer = setTimeout(() => { |
| | | // Reinitialize observer with new margins on resize |
| | | this.reinitialize(); |
| | | }, 250); |
| | | }); |
| | | } |
| | | |
| | | reinitialize() { |
| | | // Disconnect old observer |
| | | if (this.observer) { |
| | | this.observer.disconnect(); |
| | | } |
| | | |
| | | // Create new observer with updated margins |
| | | this.init(); |
| | | } |
| | | |
| | | handleIntersection(entries) { |
| | | // Find the entry that's intersecting |
| | | const intersecting = entries.find(entry => entry.isIntersecting); |
| | | |
| | | if (intersecting) { |
| | | this.setActive(intersecting.target); |
| | | } |
| | | } |
| | | |
| | | checkActiveTerm() { |
| | | // Fallback method to find which term is in the trigger zone |
| | | const remInPixels = parseFloat(getComputedStyle(document.documentElement).fontSize); |
| | | const margin = remInPixels * 4; |
| | | |
| | | let closestTerm = null; |
| | | let closestDistance = Infinity; |
| | | |
| | | this.terms.forEach(term => { |
| | | const rect = term.getBoundingClientRect(); |
| | | |
| | | const isInZone = rect.top + rect.height / 2 >= 0 && rect.top + rect.height / 2 <= window.innerHeight; |
| | | |
| | | if (isInZone) { |
| | | // Find closest to the trigger point |
| | | const triggerPoint = window.innerHeight / 2; |
| | | |
| | | const distance = Math.abs(rect.top - triggerPoint); |
| | | |
| | | if (distance < closestDistance) { |
| | | closestDistance = distance; |
| | | closestTerm = term; |
| | | } |
| | | } |
| | | }); |
| | | |
| | | if (closestTerm) { |
| | | this.setActive(closestTerm); |
| | | } |
| | | } |
| | | |
| | | setActive(term) { |
| | | if (this.currentActive === term) return; |
| | | |
| | | // Remove active class from previous term |
| | | if (this.currentActive) { |
| | | this.currentActive.classList.remove(this.activeClass); |
| | | } |
| | | |
| | | // Add active class to current term |
| | | term.classList.add(this.activeClass); |
| | | this.currentActive = term; |
| | | |
| | | // Update navigation |
| | | this.updateNavigation(term.id); |
| | | } |
| | | |
| | | updateNavigation(termId) { |
| | | // Remove active from all nav links |
| | | const navLinks = this.nav.querySelectorAll('a'); |
| | | navLinks.forEach(link => link.classList.remove(this.activeClass)); |
| | | |
| | | // Find and activate corresponding nav link |
| | | const activeLink = this.nav.querySelector(`a[href="#${termId}"]`); |
| | | |
| | | if (activeLink) { |
| | | activeLink.classList.add(this.activeClass); |
| | | |
| | | // Scroll the nav list to center the active link |
| | | this.centerNavItem(activeLink); |
| | | } |
| | | } |
| | | |
| | | centerNavItem(link) { |
| | | const listRect = this.navList.getBoundingClientRect(); |
| | | const linkRect = link.getBoundingClientRect(); |
| | | |
| | | // Calculate position to center the link in the nav container |
| | | const scrollTop = this.navList.scrollTop; |
| | | const linkOffset = linkRect.top - listRect.top; |
| | | const centerOffset = (listRect.height / 2) - (linkRect.height / 2); |
| | | |
| | | this.navList.scrollTo({ |
| | | top: scrollTop + linkOffset - centerOffset, |
| | | behavior: 'smooth' |
| | | }); |
| | | } |
| | | |
| | | debounce(func, wait) { |
| | | let timeout; |
| | | return function executedFunction(...args) { |
| | | const later = () => { |
| | | clearTimeout(timeout); |
| | | func(...args); |
| | | }; |
| | | clearTimeout(timeout); |
| | | timeout = setTimeout(later, wait); |
| | | }; |
| | | } |
| | | |
| | | destroy() { |
| | | if (this.observer) { |
| | | this.observer.disconnect(); |
| | | } |
| | | window.removeEventListener('scroll', this.handleScroll); |
| | | } |
| | | } |
| | | |
| | | |
| | | // Initialize when DOM is ready |
| | | if (document.readyState === 'loading') { |
| | | document.addEventListener('DOMContentLoaded', () => { |
| | | new GlossaryNavigator(); |
| | | }); |
| | | } else { |
| | | new GlossaryNavigator(); |
| | | } |