vevet
Version:
Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.
376 lines (299 loc) • 9.52 kB
text/typescript
import { Module, TModuleOnCallbacksProps } from '@/base/Module';
import { initVevet } from '@/global/initVevet';
import { cnToggle } from '@/internal/cn';
import { body } from '@/internal/env';
import { noopIfDestroyed } from '@/internal/noopIfDestroyed';
import { TRequiredProps } from '@/internal/requiredProps';
import { getTextDirection } from '@/internal/textDirection';
import { clamp } from '@/utils/math';
import { MUTABLE_PROPS, STATIC_PROPS } from './props';
import {
IInViewCallbacksMap,
IInViewElement,
IInViewMutableProps,
IInViewStaticProps,
TInViewElementDirection,
} from './types';
export * from './types';
type TC = IInViewCallbacksMap;
type TS = IInViewStaticProps;
type TM = IInViewMutableProps;
/**
* InView is a visibility detection utility that leverages the `IntersectionObserver` API to monitor when elements enter or leave the viewport.
* It provides customizable options for triggering events, delaying visibility changes, and dynamically adding CSS classes to elements based on their visibility state.
*
* [Documentation](https://vevetjs.com/docs/InView)
*
* @group Components
*/
export class InView extends Module<TC, TS, TM> {
/**
* Returns default static properties.
*/
public _getStatic(): TRequiredProps<TS> {
return { ...super._getStatic(), ...STATIC_PROPS };
}
/**
* Returns default mutable properties.
*/
public _getMutable(): TRequiredProps<TM> {
return { ...super._getMutable(), ...MUTABLE_PROPS };
}
/** Intersection observer for detecting elements entering the viewport. */
private _in?: IntersectionObserver;
/** Intersection observer for detecting elements leaving the viewport. */
private _out?: IntersectionObserver;
/** Tracks whether this is the first time the elements are being observed. */
private _isInitialStart = true;
/** Stores the elements being observed. */
private _elements: IInViewElement[] = [];
/** Detects if the container is RTL */
private _isRtl = false;
/**
* Initializes the `InView` module.
*/
constructor(
props?: TS & TM & TModuleOnCallbacksProps<TC, InView>,
onCallbacks?: TModuleOnCallbacksProps<TC, InView>,
) {
super(props, onCallbacks as any);
// get direction
this._isRtl = getTextDirection(body) === 'rtl';
this._setup();
}
/**
* Indicates whether the observation has started for the first time.
*/
get isInitialStart() {
return this._isInitialStart;
}
/**
* Returns all elements currently being observed.
*/
get elements() {
return this._elements;
}
/**
* Handles property mutations and updates observation events accordingly.
*/
protected _handleProps(props: TM) {
super._handleProps(props);
this._setup();
}
/**
* Configures or reconfigures the view observation events.
*/
private _setup() {
this._removeViewEvents();
if (this.props.enabled) {
this._setViewEvents();
}
}
/**
* Removes all observation events and disconnects observers.
*/
private _removeViewEvents() {
this._in?.disconnect();
this._in = undefined;
this._out?.disconnect();
this._out = undefined;
}
/**
* Sets up `IntersectionObserver` instances to detect visibility changes.
*/
private _setViewEvents() {
const { isInitialStart, props } = this;
const rootMargin = isInitialStart ? '0% 0% 0% 0%' : props.rootMargin;
this._in = new IntersectionObserver(
(data) => this._handleIn(data, isInitialStart),
{ root: null, threshold: 0, rootMargin },
);
this.elements.forEach((element) => this._in?.observe(element));
if (!props.hasOut) {
return;
}
this._out = new IntersectionObserver((data) => this._handleOut(data), {
root: null,
threshold: 0,
rootMargin: '0px 0px 0px 0px',
});
this.elements.forEach((element) => this._out?.observe(element));
}
/**
* Handles elements entering the viewport.
*/
private _handleIn(
data: IntersectionObserverEntry[],
isInitialStart: boolean,
) {
data.forEach((entry) => {
const element = entry.target as IInViewElement;
if (!entry.isIntersecting || element.$vevetInViewBool) {
return;
}
element.$vevetInViewBool = true;
if (element.$vevetInViewTimeout) {
clearTimeout(element.$vevetInViewTimeout);
element.$vevetInViewTimeout = undefined;
}
element.$vevetInViewTimeout = setTimeout(
() => this._handleInOut(entry, true, isInitialStart),
this._getDelay(element),
);
if (!this.props.hasOut) {
this.removeElement(element);
}
});
if (this._isInitialStart) {
this._isInitialStart = false;
this._setup();
}
}
/**
* Handles elements leaving the viewport.
*/
private _handleOut(data: IntersectionObserverEntry[]) {
data.forEach((entry) => {
const element = entry.target as IInViewElement;
if (entry.isIntersecting || !element.$vevetInViewBool) {
return;
}
element.$vevetInViewBool = false;
if (element.$vevetInViewTimeout) {
clearTimeout(element.$vevetInViewTimeout);
element.$vevetInViewTimeout = undefined;
}
element.$vevetInViewTimeout = setTimeout(
() => this._handleInOut(entry, false),
0,
);
});
}
/**
* Toggles visibility classes and emits events for visibility changes.
*/
private _handleInOut(
entry: IntersectionObserverEntry,
isInView: boolean,
isInitialStart = false,
) {
const element = entry.target as IInViewElement;
const direction = this._getDirection(entry, isInView, isInitialStart);
this._toggleClassname(element, isInView, direction);
this.callbacks.emit(isInView ? 'in' : 'out', { element, direction });
}
/** Toggles visibility classes */
private _toggleClassname(
element: Element,
isInView: boolean,
direction: TInViewElementDirection,
) {
const classNames = element.getAttribute('data-in-view-class');
if (!classNames) {
return;
}
const split = classNames.split('|');
const direct = split[0].trim();
const reverse = split[1]?.trim() || direct;
if (!direct) {
return;
}
if (isInView) {
const isReverse = direction === 'fromRight' || direction === 'fromTop';
const className = isReverse ? reverse.trim() : direct.trim();
cnToggle(element, className, isInView);
return;
}
cnToggle(element, direct, isInView);
cnToggle(element, reverse, isInView);
}
/** Gets element direction */
private _getDirection(
entry: IntersectionObserverEntry,
isInView: boolean,
isInitialStart: boolean,
) {
const app = initVevet();
const bounding = entry.boundingClientRect;
if (this.props.scrollDirection === 'horizontal') {
let direction: TInViewElementDirection = 'fromRight';
if ((isInView && !isInitialStart) || !isInView) {
if (bounding.left > app.width / 2) {
direction = 'fromRight';
} else if (bounding.right < app.width / 2) {
direction = 'fromLeft';
}
}
return direction;
}
let direction: TInViewElementDirection = 'fromBottom';
if ((isInView && !isInitialStart) || !isInView) {
if (bounding.top > app.height / 2) {
direction = 'fromBottom';
} else if (bounding.bottom < app.height / 2) {
direction = 'fromTop';
}
}
return direction;
}
/**
* Calculates the delay before triggering an element's visibility event.
*/
private _getDelay(element: IInViewElement) {
const { scrollDirection, maxInitialDelay } = this.props;
const app = initVevet();
if (!this.isInitialStart || maxInitialDelay <= 0) {
return 0;
}
const bounding = element.getBoundingClientRect();
const rootBounding = {
top: 0,
left: 0,
width: app.width,
height: app.height,
};
let progress = clamp(
scrollDirection === 'horizontal'
? (bounding.left - rootBounding.left) / rootBounding.width
: (bounding.top - rootBounding.top) / rootBounding.height,
);
if (this._isRtl && scrollDirection === 'horizontal') {
progress = 1 - progress;
}
return progress * maxInitialDelay;
}
/**
* Registers an element for visibility observation.
*
* If the element has a `data-in-view-class` attribute, the specified class will be applied upon entering the viewport.
*
* @returns A function to stop observing the element.
*/
public addElement(element: Element) {
const finalElement = element as IInViewElement;
finalElement.$vevetInViewBool = undefined;
this._elements.push(finalElement);
this._in?.observe(finalElement);
this._out?.observe(finalElement);
return () => this.removeElement(finalElement);
}
/**
* Removes an element from observation, preventing further visibility tracking.
*/
public removeElement(element: Element) {
const finalElement = element as IInViewElement;
this._in?.unobserve(finalElement);
this._out?.unobserve(finalElement);
this._elements = this._elements.filter((el) => el !== element);
finalElement.$vevetInViewBool = undefined;
}
/**
* Cleans up the module and disconnects all observers and listeners.
*/
protected _destroy() {
super._destroy();
this._removeViewEvents();
}
}