// ============================================================================ // Stylescape | Scroll Utilities // ============================================================================ // Utility functions and classes for scroll-based interactions. // Supports data-ss-scroll attributes for declarative configuration. // ============================================================================ /** * Configuration options for smooth scrolling */ export interface SmoothScrollOptions { /** Target element or selector */ target?: string | HTMLElement; /** Offset from top in pixels */ offset?: number; /** Duration in milliseconds */ duration?: number; /** Easing function name */ easing?: "linear" | "easeInOut" | "easeIn" | "easeOut"; /** Callback on scroll complete */ onComplete?: () => void; } /** * Configuration options for scroll-to-top button */ export interface ScrollToTopOptions { /** Selector or element for the button */ button?: string | HTMLElement; /** Show button after scrolling this many pixels */ threshold?: number; /** Smooth scroll behavior */ smooth?: boolean; /** Duration for scroll animation */ duration?: number; /** CSS class when button is visible */ visibleClass?: string; } /** * Easing functions for scroll animations */ const easingFunctions = { linear: (t: number): number => t, easeIn: (t: number): number => t * t, easeOut: (t: number): number => t * (2 - t), easeInOut: (t: number): number => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, }; /** * Smooth scroll to a target element or position. * * @example JavaScript * ```typescript * // Scroll to element * scrollTo("#section-2", { offset: -100, duration: 800 }) * * // Scroll to position * scrollToPosition(500, { smooth: true }) * ``` * * @example HTML with data-ss * ```html * * Contact Us * * ``` */ export function scrollTo( target: string | HTMLElement | number, options: SmoothScrollOptions = {}, ): void { const { offset = 0, duration = 500, easing = "easeInOut", onComplete, } = options; let targetPosition: number; if (typeof target === "number") { targetPosition = target; } else { const element = typeof target === "string" ? document.querySelector(target) : target; if (!element) { console.warn("[Stylescape] scrollTo target not found:", target); return; } targetPosition = element.getBoundingClientRect().top + window.pageYOffset; } const startPosition = window.pageYOffset; const distance = targetPosition + offset - startPosition; const easingFn = easingFunctions[easing]; let startTime: number | null = null; function animation(currentTime: number): void { if (startTime === null) startTime = currentTime; const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); window.scrollTo(0, startPosition + distance * easingFn(progress)); if (elapsed < duration) { requestAnimationFrame(animation); } else { onComplete?.(); } } requestAnimationFrame(animation); } /** * Scroll to a specific Y position */ export function scrollToPosition( y: number, options: { smooth?: boolean; duration?: number } = {}, ): void { if (options.smooth && options.duration) { scrollTo(y, { duration: options.duration }); } else { window.scrollTo({ top: y, behavior: options.smooth ? "smooth" : "auto", }); } } /** * Scroll to top of page */ export function scrollToTop( options: { smooth?: boolean; duration?: number } = {}, ): void { scrollToPosition(0, options); } /** * Scroll to bottom of page */ export function scrollToBottom( options: { smooth?: boolean; duration?: number } = {}, ): void { const documentHeight = Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, ); scrollToPosition(documentHeight, options); } /** * Scroll-to-top button manager. * * @example JavaScript * ```typescript * const scrollTop = new ScrollToTopButton({ * button: "#back-to-top", * threshold: 300, * smooth: true * }) * ``` * * @example HTML with data-ss * ```html * * ``` */ export class ScrollToTopButton { private button: HTMLElement | null; private options: Required; private ticking: boolean = false; constructor(options: ScrollToTopOptions = {}) { this.button = typeof options.button === "string" ? document.querySelector(options.button) : (options.button ?? null); this.options = { button: this.button ?? document.createElement("button"), threshold: options.threshold ?? 300, smooth: options.smooth ?? true, duration: options.duration ?? 500, visibleClass: options.visibleClass ?? "scroll-to-top--visible", }; if (!this.button) { console.warn("[Stylescape] ScrollToTopButton: button not found"); return; } this.init(); } /** * Manually show the button */ public show(): void { this.button?.classList.add(this.options.visibleClass); this.button?.setAttribute("aria-hidden", "false"); } /** * Manually hide the button */ public hide(): void { this.button?.classList.remove(this.options.visibleClass); this.button?.setAttribute("aria-hidden", "true"); } /** * Destroy the button manager */ public destroy(): void { window.removeEventListener("scroll", this.handleScroll); this.button?.removeEventListener("click", this.handleClick); this.button = null; } /** * Initialize scroll-to-top buttons with data-ss */ public static init(): ScrollToTopButton[] { const buttons: ScrollToTopButton[] = []; document .querySelectorAll('[data-ss="scroll-to-top"]') .forEach((el) => { const threshold = el.dataset.ssScrollThreshold; buttons.push( new ScrollToTopButton({ button: el, threshold: threshold ? parseInt(threshold, 10) : undefined, }), ); }); return buttons; } private init(): void { if (!this.button) return; // Setup ARIA this.button.setAttribute( "aria-label", this.button.getAttribute("aria-label") || "Scroll to top", ); this.button.setAttribute("aria-hidden", "true"); // Initial state this.checkScroll(); // Event listeners window.addEventListener("scroll", this.handleScroll, { passive: true, }); this.button.addEventListener("click", this.handleClick); } private handleScroll = (): void => { if (!this.ticking) { requestAnimationFrame(() => { this.checkScroll(); this.ticking = false; }); this.ticking = true; } }; private checkScroll(): void { const scrollY = window.pageYOffset || document.documentElement.scrollTop; if (scrollY > this.options.threshold) { this.show(); } else { this.hide(); } } private handleClick = (event: Event): void => { event.preventDefault(); if (this.options.smooth) { scrollTo(0, { duration: this.options.duration }); } else { window.scrollTo(0, 0); } }; } /** * Initialize scroll-to links with data-ss="scroll-to" */ export function initScrollLinks(): void { document .querySelectorAll('[data-ss="scroll-to"]') .forEach((el) => { const target = el.dataset.ssScrollTarget || el.getAttribute("href"); const offset = el.dataset.ssScrollOffset; const duration = el.dataset.ssScrollDuration; el.addEventListener("click", (event) => { event.preventDefault(); if (target) { scrollTo(target, { offset: offset ? parseInt(offset, 10) : 0, duration: duration ? parseInt(duration, 10) : 500, }); } }); }); } /** * Auto-initialize all scroll utilities */ export function initScrollUtilities(): void { initScrollLinks(); ScrollToTopButton.init(); } export default { scrollTo, scrollToPosition, scrollToTop, scrollToBottom, ScrollToTopButton, initScrollLinks, initScrollUtilities, };