main/classes/InBounds.js

import InView from '../../utils/classes/InView';
import customEvent from '../../utils/customEvent';

/**
 * Detects whether the edge of a container has entered or exited the viewport.
 * This is useful because intersection observer has a hard time observing objects
 * that are larger than the viewport itself. This is done by adding small sentinal
 * objects to the top and bottom of the container that can be observed more easily.
 *
 * @class InBounds
 * @prop {string} boundaryLabel - data field for sentinal elements
 * @prop {string} boundaryUpperValue - used to identify the top sentinal element
 * @prop {string} boundaryLowerValue - used to identify the bottom sentinal element
 * @prop {string} boundaryStyle - style applied to sentinal elements
 * @prop {string} adminBarSelector - selector for the wp admin bar (used to calculate topMargin)
 * @prop {string} navBarSelector - selector for the nav bar (used to calculate topMargin)
 * @prop {element} $container - DOM object representing the container being observed
 * @prop {element} $upperbound - DOM object representing the upper sentinal element
 * @prop {element} $lowerbound - DOM object representing the lower sentinal element
 * @prop {object} observer - the InView object instance we are using to keep watch
 * @prop {object} settings - class setting parameters passed to the constructor
 * @prop {string} name - name to uniquely identify this object instance
 * @prop {int} topMargin - we shrink the viewport by this amount to exclude sticky elements
 */
class InBounds {
	boundaryLabel = 'dataBoundary';

	boundaryUpperValue = 'upper';

	boundaryLowerValue = 'lower';

	boundaryStyle = 'width:1px;height:1px;margin-top:-1px;background-color:transparent';

	adminBarSelector = '#wpadminbar';

	navBarSelector = '.l-navbar';

	$container = null;

	$upperbound = null;

	$lowerbound = null;

	observer = null;

	settings = null;

	topMargin = 0;

	/**
	 * Constructor
	 */
	constructor( settings ) {
		this.settings = settings;

		if ( ! this.settings.selector ) {
			return;
		}

		this.$container = document.querySelector( this.settings.selector );
	}

	/**
	 * Sets up listeners to watch for upper and lower bounds of container coming in / out of view
	 *
	 * @method init
	 */
	init() {
		// We want to treat the boundary below sticky elements as the top of the viewport
		this.calculateTopMargin();

		// Initialize in view watcher for stickiness
		this.observer = new InView({
			rootMargin: `-${this.topMargin}px 0px 0px 0px`,
		});
		this.observer.init();

		// Set up boundary sentinals
		this.setupBoundary();
	}

	/**
	 * Sets up sentinal elements at the top and bottom of the container to serve as signals
	 *
	 * @method setupBoundary
	 */
	setupBoundary() {
		this.$upperbound = document.createElement( 'div' );
		this.$upperbound.dataset[this.boundaryLabel] = this.boundaryUpperValue;
		this.$upperbound.setAttribute( 'style', this.boundaryStyle );
		this.$upperbound.dataset.alwaysObserve = 'true';
		this.$upperbound.addEventListener( customEvent.IN_VIEW,
			evt => this.handleUpperboundInView( evt ) );

		this.$lowerbound = document.createElement( 'div' );
		this.$lowerbound.dataset[this.boundaryLabel] = this.boundaryLowerValue;
		this.$lowerbound.setAttribute( 'style', this.boundaryStyle );
		this.$lowerbound.dataset.alwaysObserve = 'true';
		this.$lowerbound.addEventListener( customEvent.IN_VIEW,
			evt => this.handleLowerboundInView( evt ) );

		const $firstChild = this.$container.children[0];
		this.$container.insertBefore( this.$upperbound, $firstChild );
		this.$container.appendChild( this.$lowerbound );

		this.observer.startWatching( this.$upperbound );
	}

	/**
	 * Handle upperbound coming in / out of viewport
	 *
	 * @method handleUpperboundInView
	 * @param {object} evt - InView event
	 */
	handleUpperboundInView( evt ) {
		// check if upper bounds has passed below viewport (we can ignore this)
		const { bottom } = this.$upperbound.getBoundingClientRect();
		if ( bottom >= window.innerHeight ) {
			return;
		}

		let isInBounds = null;

		this.observer.stopWatching( this.$lowerbound );

		if ( evt.detail.ratioInView > 0 ) {
			// The bottom boundary has scrolled into view
			isInBounds = false;
		} else {
			// The bottom boundary has scrolled out of view
			// The container is now fully in view
			isInBounds = true;
			this.observer.startWatching( this.$lowerbound );
		}

		customEvent.fire( this.$container, customEvent.IN_BOUNDS, {
			caller: this,
			target: this.$container,
			side: 'upper',
			isInBounds,
		});
	}

	/**
	 * Handle lower coming in / out of viewport
	 *
	 * @method handleLowerboundInView
	 * @param {object} evt - InView event
	 */
	handleLowerboundInView( evt ) {
		// check if lower bounds has moved above viewport (we can ignore this)
		const { top } = this.$lowerbound.getBoundingClientRect();
		if ( top <= this.topMargin ) {
			return;
		}

		let isInBounds = null;

		if ( evt.detail.ratioInView > 0 ) {
			// The top boundary has scrolled into view
			isInBounds = false;
		} else {
			// The top boundary has scrolled out of view
			// The container is now fully in view
			isInBounds = true;
		}

		customEvent.fire( this.$container, customEvent.IN_BOUNDS, {
			caller: this,
			target: this.$container,
			side: 'lower',
			isInBounds,
		});
	}

	/**
	 * Unbind observer and remove sentinal elements
	 *
	 * @method stopWatching
	 */
	stopWatching() {
		this.observer.stopWatching( this.$upperbound );
		this.observer.stopWatching( this.$lowerbound );
		this.$upperbound.parentNode.removeChild( this.$upperbound );
		this.$lowerbound.parentNode.removeChild( this.$lowerbound );
	}

	/**
	 * This will shorten the viewport based on the sticky nav and WP admin bar height
	 *
	 * @method calculateTopMargin
	 */
	calculateTopMargin() {
		const $adminBar = document.querySelector( this.adminBarSelector );
		const $navBar   = document.querySelector( this.navBarSelector );

		if ( $adminBar ) {
			this.topMargin += $adminBar.getBoundingClientRect().height;
		}

		if ( $navBar ) {
			this.topMargin += $navBar.getBoundingClientRect().height;
		}
	}
}

export default InBounds;