utils/classes/InView.js

import '../../polyfills/IntersectionObserver';
import customEvent from '../customEvent';

/**
 * InView Class for observing elements's viewability
 *
 * @class InView
 */
class InView {
	selector = '';

	observer = null;

	settings = null;

	name = '';

	$targets = [];

	static watchers = [];

	/**
	 * Constructor
	 * Register itself in a global static variable of In View Watchers
	 */
	constructor( settings, name ) {
		this.name = name || `inView${( new Date() ).getTime()}`;
		this.settings = settings;

		InView.watchers[name] = this;
	}

	/**
	 * Setup intersection observer and watch for elements getting in view.
	 *
	 * @method init
	 */
	init() {
		const options = {
			root: this.settings.root ? this.settings.root : null,
			rootMargin: this.settings.rootMargin ? this.settings.rootMargin : '0px',
			threshold: this.settings.threshold ? this.settings.threshold : 1,
		};

		this.selector = this.settings.selector ? this.settings.selector : this.selector;
		this.$targets = this.selector ? document.querySelectorAll( this.selector ) : false;

		// Set up Intersection Observer
		this.observer = new IntersectionObserver( ( entries ) => {
			this.intersectionCallback( entries );
		}, options );

		// Start obbserving elements when a selector has been provided
		if ( this.$targets ) {
			[].forEach.call( this.$targets, ( $el ) => {
				this.startWatching( $el );
			});
		}
	}

	/**
	 * Stop watching viewability of an element
	 *
	 * @method stopWatching
	 * @param {HTMLElement} $elem - unobserve element
	 */
	stopWatching( $elem ) {
		this.observer.unobserve( $elem );
	}

	/**
	 * Start watching viewability of an element
	 *
	 * @method startWatching
	 * @param {HTMLElement} $elem - observe element
	 */
	startWatching( $elem ) {
		// If element is already in view, trigger inView event right away,
		// unless the alwaysObserve data attribute is set to true,
		// also set the data attribute inView to true in case the event fires
		// too early and cannot be caught in time by other code, dataset.inView
		// can always be used as a flag to check
		const rect = $elem.getBoundingClientRect();
		let shouldObserve = true;

		let margin = 0;
		if ( this.settings.rootMargin ) {
			margin = parseInt( this.settings.rootMargin.replace( 'px', '' ), 10 );
		}

		if ( rect.top > window.scrollY
			&& rect.top < window.scrollY + window.innerHeight + 2 * margin ) {
			$elem.dataset.inView = true; // eslint-disable-line no-param-reassign
			customEvent.fire( $elem, customEvent.IN_VIEW, {
				caller: this,
				target: $elem,
				isInView: true,
			});
			shouldObserve = 'true' === $elem.dataset.alwaysObserve;
		}

		if ( shouldObserve ) {
			this.observer.observe( $elem );
		}
	}

	/**
	 * Stop observing all
	 *
	 * @method stop
	 */
	stop() {
		this.observer.disconnect();
	}

	/**
	 * Callback for Intersection Observer
	 * Fire inView event when element becomes in view
	 *
	 */
	intersectionCallback( entries ) {
		[].forEach.call( entries, ( entry ) => {
			const { target: $target } = entry;
			const { isIntersecting } = entry;
			$target.dataset.inView = isIntersecting.toString(); // eslint-disable-line no-param-reassign
			customEvent.fire( $target, customEvent.IN_VIEW, {
				caller: this,
				target: $target,
				isInView: isIntersecting,
				ratioInView: entry.intersectionRatio,
				intersectionRect: entry.intersectionRect,
				rootBounds: entry.rootBounds,
				clientBounds: entry.boundingClientRect,
			});

			// stop observing element once it's in view unless "alwaysObserve" attribute is "true"
			if ( isIntersecting && 'true' !== $target.dataset.alwaysObserve ) {
				this.stopWatching( $target );
			}
		});
	}

	/**
	 * Static method for getting a specific in view watcher by name
	 *
	 * @method getWatcher
	 * @param {string} name
	 */
	static getWatcher( name ) {
		return InView.watchers[name];
	}
}

export default InView;