UNPKG

@opuu/inview

Version:

Lightweight JavaScript library for viewport detection with debounced callbacks - intersection observer, lazy loading, scroll animations, infinite scroll, element visibility tracking with TypeScript support. Zero dependencies, ~1KB gzipped.

384 lines (353 loc) 9.12 kB
/** * The configuration object for the InView class * * @typedef {Object} InViewConfig * * @property {string} selector - CSS selector * @property {number} delay - Debounce delay in ms (default: 0) * @property {"low" | "medium" | "high"} precision - Precision of the observer (default: medium) * @property {boolean} single - Only observe the first element (default: false) * * @example * const config: InViewConfig = { * selector: ".class", * delay: 0, * precision: "medium", * single: true * } */ interface InViewConfig { selector: string; delay?: number; precision?: "low" | "medium" | "high"; single?: boolean; } /** * The event object for the InView class * * @typedef {Object} InViewEvent * * @property {number} percentage - Percentage of the element in the viewport * @property {DOMRectReadOnly} rootBounds - The bounds of the viewport * @property {DOMRectReadOnly} boundingClientRect - The bounds of the element * @property {DOMRectReadOnly} intersectionRect - The bounds of the intersection * @property {Element} target - The observed element * @property {number} time - The time of the event * @property {"enter" | "exit"} event - The event type * * @example * new InView(".selector").on("enter", (e: InViewEvent) => { * console.log(e); * }); */ interface InViewEvent { percentage: number; rootBounds: DOMRectReadOnly | null; boundingClientRect: DOMRectReadOnly; intersectionRect: DOMRectReadOnly; target: Element; time: number; event: "enter" | "exit"; } /** * InView * * Check if element is visible in viewport * * @example * new InView(".selector").on("enter", (e) => { * console.log(e.percentage); * }); * * @example * new InView({ * selector: ".selector", * delay: 1000, * precision: "low", * single: true * }).on("enter", (e) => { * console.log(e.percentage); * }).on("exit", (e) => { * console.log("exit"); * }); */ class InView { /** * List of elements to observe or single element */ private items: NodeListOf<Element> | Element | null = null; /** * Is the observer is paused */ private paused: boolean = false; /** * Debounce delay for the callback */ private delay: number = 0; /** * Threshold */ private threshold: Array<number> = []; /** * Single element observer */ private single: boolean = false; /** * Array to store all observers for cleanup */ private observers: IntersectionObserver[] = []; /** * WeakMap to store debounce timers for each element */ private debounceTimers: WeakMap<Element, number> = new WeakMap(); /** * Constructor * * Create a new InView instance * * @param {InViewConfig | string} config - Configuration object or CSS selector * * @example * new InView(".selector"); * * @example * new InView({ * selector: ".selector", * delay: 1000, * precision: "low", * single: true * }); */ constructor(config: InViewConfig | string) { // default threshold increment let increment: number = 0.01; // check if config is a string or an object if (typeof config === "string") { this.items = document.querySelectorAll(config); this.delay = 0; } else if (typeof config === "object") { if (config.delay) { this.delay = config.delay; } if (config.single) { this.single = config.single; } if (config.precision === "low") { increment = 0.1; } else if (config.precision === "medium") { increment = 0.01; } else if (config.precision === "high") { increment = 0.001; } if (this.single) { this.items = document.querySelector(config.selector); } else { this.items = document.querySelectorAll(config.selector); } } // create threshold array (Doing this way to save download size at cost of little bit of performance) for (let i = 0; i <= 1; i += increment) { this.threshold.push(i); } } /** * Debounce function to delay callback execution * * @param {Element} element - The element triggering the event * @param {CallableFunction} callback - The callback to execute * @param {InViewEvent} event - The event object to pass to callback */ private debounceCallback(element: Element, callback: CallableFunction, event: InViewEvent): void { // Clear existing timer for this element const existingTimer = this.debounceTimers.get(element); if (existingTimer) { clearTimeout(existingTimer); } // Set new timer if (this.delay > 0) { const timerId = window.setTimeout(() => { this.debounceTimers.delete(element); callback(event); }, this.delay); this.debounceTimers.set(element, timerId); } else { callback(event); } } /** * Pause the observer * * @returns {InView} - Returns the InView instance * * @example * const inview = new InView(".selector"); * inview.on("enter", (e) => {}); * // pause on specific needs * inview.pause(); */ public pause(): InView { this.paused = true; return this; } /** * Resume the observer * * @returns {InView} - Returns the InView instance * * @example * const inview = new InView(".selector"); * inview.on("enter", (e) => {}); * // pause the observer * inview.pause(); * // resume the observer again * inview.resume(); */ public resume(): InView { this.paused = false; return this; } /** * Set the debounce delay * * @param {number} delay - Debounce delay in ms * * @returns {InView} - Returns the InView instance * * @example * const inview = new InView(".selector"); * inview.on("enter", (e) => {}); * // set debounce delay to 1000ms * inview.setDelay(1000); */ public setDelay(delay: number): InView { this.delay = delay; return this; } /** * Destroy the observer and clean up all resources * * @returns {InView} - Returns the InView instance * * @example * const inview = new InView(".selector"); * inview.on("enter", (e) => {}); * // Clean up when done * inview.destroy(); */ public destroy(): InView { // Clear all debounce timers if (this.items instanceof Element) { const existingTimer = this.debounceTimers.get(this.items); if (existingTimer) { clearTimeout(existingTimer); } } else if (this.items instanceof NodeList) { this.items.forEach((item) => { const existingTimer = this.debounceTimers.get(item); if (existingTimer) { clearTimeout(existingTimer); } }); } this.debounceTimers = new WeakMap(); // Disconnect all observers this.observers.forEach((observer) => { observer.disconnect(); }); // Clear the observers array this.observers = []; // Reset other properties this.paused = false; this.items = null; return this; } /** * Listen for enter or exit events * * @param {"enter" | "exit"} event - Event type * @param {CallableFunction} callback - Callback function * * @returns {InView} - Returns the InView instance * * @example * const inview = new InView({...}); * inview.on("enter", (e: InViewEvent) => { * console.log(e.percentage); * }); * * inview.on("exit", (e: InViewEvent) => { * console.log("exit"); * }); * * @example * new InView(".selector").on("enter", (e: InViewEvent) => { * console.log(e.percentage); * }).on("exit", (e: InViewEvent) => { * console.log("exit"); * }); */ public on(event: "enter" | "exit", callback: CallableFunction): InView { /** * Check if IntersectionOberver is available */ if ("IntersectionObserver" in window) { /** * New observer to check for each items position */ const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { /** * Determine if element exited or entered the viewport */ if ( (event === "enter" && entry.intersectionRatio > 0) || (event === "exit" && entry.intersectionRatio === 0) ) { /** * Create output object */ const e: InViewEvent = { percentage: entry.intersectionRatio * 100, rootBounds: entry.rootBounds, boundingClientRect: entry.boundingClientRect, intersectionRect: entry.intersectionRect, target: entry.target, time: entry.time, event: event, }; /** * Call the callback function if not paused, using debounce if delay is set */ if (!this.paused) { this.debounceCallback(entry.target, callback, e); } } }); }, { threshold: this.threshold, } ); // if single element observer if (this.items instanceof Element) { // observe single item observer.observe(this.items as Element); } else if (this.items instanceof NodeList) { // observe each item this.items.forEach((item) => { observer.observe(item); }); } else { console.error("InView: No items found."); } // Store the observer for cleanup this.observers.push(observer); } else { console.error("InView: IntersectionObserver not supported."); } return this; } } export default InView; export type { InViewConfig, InViewEvent };