// ============================================================================
// 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;