Jake Vanderwerf
9 days ago ed57c386db34d8693ca75311972d0929ebe5f488
src/glossary/view.js
New file
@@ -0,0 +1,184 @@
/**
 * 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();
}