// ============================================================================ // Stylescape | Scroll Spy Manager // ============================================================================ // Updates navigation based on scroll position for single-page navigation. // Supports data-ss-scrollspy attributes for declarative configuration. // ============================================================================ /** * Configuration options for ScrollSpyManager */ export interface ScrollSpyOptions { /** Selector for navigation links */ navSelector?: string; /** Custom scroll container (defaults to window) */ containerId?: string; /** Threshold offset (0-1) for when section is considered active */ threshold?: number; /** Offset from top in pixels */ offset?: number; /** CSS class for active link */ activeClass?: string; /** CSS class for active parent (e.g., li) */ activeParentClass?: string; /** Smooth scroll to sections on link click */ smoothScroll?: boolean; /** Callback when active section changes */ onChange?: (activeId: string | null, link: HTMLElement | null) => void; /** History API integration */ updateHistory?: boolean; } /** * Scroll spy for single-page navigation with accessibility support. * * @example JavaScript * ```typescript * const scrollSpy = new ScrollSpyManager({ * navSelector: ".toc a", * threshold: 0.3, * smoothScroll: true, * onChange: (id) => console.log("Active section:", id) * }) * ``` * * @example HTML with data-ss * ```html * * *
...
*
...
* ``` */ export class ScrollSpyManager { private sections: HTMLElement[] = []; private navLinks: HTMLElement[] = []; private scrollContainer: HTMLElement | Window; private options: Required; private ticking: boolean = false; private currentActiveId: string | null = null; constructor(options: ScrollSpyOptions = {}) { this.options = { navSelector: options.navSelector ?? "[data-ss-scrollspy-link], .scrollspy-link", containerId: options.containerId ?? "", threshold: options.threshold ?? 0.5, offset: options.offset ?? 0, activeClass: options.activeClass ?? "active", activeParentClass: options.activeParentClass ?? "active", smoothScroll: options.smoothScroll ?? true, onChange: options.onChange ?? (() => {}), updateHistory: options.updateHistory ?? false, }; // Setup scroll container this.scrollContainer = this.options.containerId ? (document.getElementById(this.options.containerId) ?? window) : window; this.init(); } // For backwards compatibility with old constructor static fromElements( sections: HTMLElement[], navLinksSelector: string, containerId?: string, thresholdOffset: number = 0.5, ): ScrollSpyManager { const instance = new ScrollSpyManager({ navSelector: navLinksSelector, containerId, threshold: thresholdOffset, }); instance.sections = sections; instance.updateActiveLink(); return instance; } // ======================================================================== // Public Methods // ======================================================================== /** * Manually refresh sections and links */ public refresh(): void { this.findSections(); this.updateActiveLink(); } /** * Scroll to a specific section */ public scrollTo(sectionId: string): void { const section = document.getElementById(sectionId); if (!section) return; const top = section.offsetTop - this.options.offset; if (this.scrollContainer instanceof Window) { window.scrollTo({ top, behavior: this.options.smoothScroll ? "smooth" : "auto", }); } else { this.scrollContainer.scrollTo({ top, behavior: this.options.smoothScroll ? "smooth" : "auto", }); } } /** * Get current active section ID */ public getActive(): string | null { return this.currentActiveId; } /** * Destroy the scroll spy */ public destroy(): void { const container = this.scrollContainer instanceof Window ? window : this.scrollContainer; container.removeEventListener("scroll", this.handleScroll); this.navLinks.forEach((link) => { link.removeEventListener("click", this.handleLinkClick); }); this.sections = []; this.navLinks = []; } // ======================================================================== // Static Factory // ======================================================================== /** * Initialize scroll spy from data-ss="scrollspy" */ public static init(): ScrollSpyManager[] { const managers: ScrollSpyManager[] = []; document .querySelectorAll('[data-ss="scrollspy"]') .forEach((el) => { const navSelector = el.dataset.ssScrollspyNav || `#${el.id} a`; const threshold = el.dataset.ssScrollspyThreshold; const smooth = el.dataset.ssScrollspySmooth !== "false"; const offset = el.dataset.ssScrollspyOffset; managers.push( new ScrollSpyManager({ navSelector, threshold: threshold ? parseFloat(threshold) : undefined, smoothScroll: smooth, offset: offset ? parseInt(offset, 10) : undefined, }), ); }); return managers; } // ======================================================================== // Private Methods // ======================================================================== private init(): void { this.findSections(); this.bindScrollListener(); this.bindLinkListeners(); this.updateActiveLink(); } private findSections(): void { // Find nav links this.navLinks = Array.from( document.querySelectorAll(this.options.navSelector), ); // Find sections based on nav link hrefs this.sections = []; this.navLinks.forEach((link) => { const href = link.getAttribute("href"); if (href?.startsWith("#")) { const sectionId = href.slice(1); const section = document.getElementById(sectionId); if (section && !this.sections.includes(section)) { this.sections.push(section); } } }); } private bindScrollListener(): void { const container = this.scrollContainer instanceof Window ? window : this.scrollContainer; container.addEventListener("scroll", this.handleScroll, { passive: true, }); } private bindLinkListeners(): void { this.navLinks.forEach((link) => { link.addEventListener("click", this.handleLinkClick); }); } private handleScroll = (): void => { if (!this.ticking) { window.requestAnimationFrame(() => { this.updateActiveLink(); this.ticking = false; }); this.ticking = true; } }; private handleLinkClick = (event: Event): void => { const link = event.currentTarget as HTMLElement; const href = link.getAttribute("href"); if (href?.startsWith("#")) { event.preventDefault(); const sectionId = href.slice(1); this.scrollTo(sectionId); if (this.options.updateHistory) { history.pushState(null, "", href); } } }; private updateActiveLink(): void { if (this.sections.length === 0 || this.navLinks.length === 0) return; const scrollY = this.scrollContainer instanceof Window ? window.scrollY : this.scrollContainer.scrollTop; let activeId: string | null = null; // Find the active section for (const section of this.sections) { const id = section.getAttribute("id"); if (!id) continue; const top = section.offsetTop - this.options.offset; const height = section.offsetHeight; const threshold = top - height * this.options.threshold; if (scrollY >= threshold) { activeId = id; } } // Only update if changed if (activeId === this.currentActiveId) return; this.currentActiveId = activeId; // Update classes let activeLink: HTMLElement | null = null; this.navLinks.forEach((link) => { const targetId = link.getAttribute("href")?.replace("#", ""); const isActive = targetId === activeId; // Update link link.classList.toggle(this.options.activeClass, isActive); link.setAttribute("aria-current", isActive ? "true" : "false"); if (isActive) { activeLink = link; } // Update parent elements (like li in nav lists) this.updateParentClasses(link, isActive); }); this.options.onChange(activeId, activeLink); } private updateParentClasses(link: HTMLElement, isActive: boolean): void { let parent = link.parentElement; while (parent && parent !== document.body) { if (parent.tagName === "LI") { parent.classList.toggle( this.options.activeParentClass, isActive, ); } parent = parent.parentElement; } } } export default ScrollSpyManager;