@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
text/typescript
/**
* 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 };