main/StickyRail.js

import InView from '../utils/classes/InView';
import customEvent from '../utils/customEvent';
import positionSticky from '../utils/positionSticky';
import isVisible from '../utils/dom/isVisible';

/**
 * Sticky Rail - makes its child element sticky within itself
 *
 * @example
 * <div class="c-stickyRail {{modifier_class}}">
 * 	<div class="c-stickyRail__widget">
 * 	</div>
 * </div>
 *
 * @module StickyRail
 * @prop {string} selector - DOM selector for a sticky rail
 * @prop {string} childSelector - DOM selector for widget that is to be sticky in the rail
 * @prop {string} boundryCss - CSS class for boundry element
 * @prop {string} upperboundCss - CSS class for upperbound element
 * @prop {string} lowerboundCss - CSS class for lowerbound element
 * @prop {object} $lowerboundMarkers - Object keeping a list of lowerbound elements
 * @prop {object} $rails - Object keeping a list of sticky rails on the page
 * @prop {number} minRailHeight - minimal rail height for stickiness to take place
 * @prop {number} topMargin - top position of element when sticky
 * @prop {number} adTopPadding - ad top padding beneath ad to account for lowerbound placement
 * @prop {number} adBottomPadding - ad bottom padding beneath ad to account for lowerbound placement
 * @prop {number} visibleRatio - ratio to consider an element in view
 * @prop {object} watcher - In view watcher for determining sticky state
 */
const stickyRail = {
	selector: '.c-stickyRail',
	growableRailSelector: '.c-stickyRail[data-sticky-rail-grow="true"]',
	rightrailAdSelector: '.c-ad--bigboxCombo .c-ad__unit',
	adUnitSelector: '.c-ad__unit',
	childSelector: '.c-stickyRail__widget',
	mainCss: 'c-stickyRail',
	autoHeightCss: 'c-stickyRail--auto',
	boundryCss: 'c-stickyRail__boundry',
	upperboundCss: 'c-stickyRail__boundry--upper',
	lowerboundCss: 'c-stickyRail__boundry--lower',
	nativeStickyCss: 'c-stickyRail--native',
	$lowerboundMarkers: {},
	$rails: {},
	minRailHeight: 1000, // Ensure in view watching only applies to desktop
	topMargin: 54, // Height of sticky nav (54px)
	adTopPadding: 12, // margin-top of .c-stickyRail[data-display='sticky'] .c-stickyRail__widget
	adBottomPadding: 10,
	visibleRatio: 0.9,
	watcher: false,

	template: `
		<div class="c-ad c-ad--bigboxCombo c-stickyRail__widget l-sidebar__ad c-ad--left">
			<div class="c-ad__label">Advertisement</div>
			<div id="{{adId}}" class="c-ad__unit" data-ad-pos="{{adPos}}" loading="lazy"></div>
		</div>
	`,

	/**
	 * Search for any sticky rail elements and set up boundries for in view watching
	 * @method init
	 */
	init() {
		const $elems = document.querySelectorAll( this.selector );
		if ( 0 === $elems.length ) {
			return;
		}

		/* global gnca_settings */

		// Adjust top margin when admin bar is present
		if ( '1' === gnca_settings.is_admin ) {
			this.topMargin = 86; // Height of sticky nav (54px) + admin bar (32px)
		}

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

		// Listen to load more event to generate more sticky rails
		window.addEventListener( customEvent.MORE_LOADED, () => {
			const $stickyRails = document.querySelectorAll( `${this.growableRailSelector}` );
			if ( $stickyRails.length > 0 ) {
				this.grow( $stickyRails[$stickyRails.length - 1]);
			}
		});

		// Allow page elements to settle before initiating stickyRail elements
		setTimeout( () => {
			this.setup( $elems );
		}, 1000 );
	},

	/**
	 * Set up boundries for in view watching if native sticky css isn't supported
	 * When sticky rail ad is loaded, generate a new sticky rail element
	 * when data-sticky-rail-grow is set
	 *
	 * @method setup
	 * @param Array{$elems} - an array of sticky rail elements
	 * @param startIndex - when $elems contains dynamically created sticky rail,
	 * index accounts for existing sticky rail
	 */
	setup( $elems, startIndex = 0 ) {
		[].forEach.call( $elems, ( $elem, index ) => {
			const $watchTarget = document.querySelector( `#${$elem.dataset.watchOnload}` );

			// Listen to ad load event and append new sticky rail when data-sticky-rail-grow is set.
			if ( $watchTarget && 'true' === $elem.dataset.stickyRailGrow ) {
				// Bail if not visible or if rail height is less than the minimum.
				const railHeight = $elem.getBoundingClientRect().height;
				if ( ! isVisible( $elem ) || railHeight < this.minRailHeight ) {
					$elem.classList.add( this.autoHeightCss );
					$elem.dataset.stickyRailGrow = false; // eslint-disable-line no-param-reassign
					return;
				}

				const boundry = $elem.dataset.stickyRailBound;
				const $boundry = document.querySelector( `${boundry}` );
				const boundryRect = boundry ? $boundry.getBoundingClientRect() : false;
				const $parent = $elem.parentNode;
				const $rails = $parent.querySelectorAll( `${this.growableRailSelector}` );
				const numRails = $rails.length;
				const totalRailHeight = numRails * railHeight;
				const offset = $rails[0].offsetTop;
				if ( ! boundryRect || boundryRect.height - totalRailHeight - offset >= railHeight ) {
					$watchTarget.addEventListener( customEvent.LOADED, () => {
						this.grow( $elem );
					});
				} else {
					// Set the height of the last stickyrail element
					// to line up with the respective boundary element

					// If there are lazy loaded ads in the boundry element that the sticky rail should
					// match its height with, then the height should only be set once the ads are loaded.

					/* eslint-disable no-param-reassign */

					const $lazyloadAds = $boundry.querySelectorAll( '.c-ad__unit:not(.c-ad__unit--loaded)[loading="lazy"][data-ad-bp="desktop"]' );
					if ( $lazyloadAds.length > 0 ) {
						[].forEach.call( $lazyloadAds, ( $ad ) => {
							$ad.addEventListener( customEvent.LOADED, () => {
								const boundryHeight = $boundry.getBoundingClientRect().height;
								const elemHeight = boundryHeight - totalRailHeight - offset + railHeight;
								$elem.style.height = `${numRails > 0 ? elemHeight : Math.max( boundryHeight, elemHeight )}px`;
							});
						});
					} else {
						const elemHeight = boundryRect.height - totalRailHeight - offset + railHeight;
						$elem.style.height = `${numRails > 0 ? elemHeight : Math.max( boundryRect.height, elemHeight )}px`;
					}

					/* eslint-enable no-param-reassign */
				}
			}

			// If native position:sticky supported, bail.
			if ( positionSticky.supported() ) {
				$elem.classList.add( this.nativeStickyCss );
				return;
			}

			// Set up element boundry for in view watching with
			// current child height (which might change if child updates on load)
			const $childElement = $elem.querySelector( this.childSelector );
			const { height: minHeight } = $childElement.getBoundingClientRect();
			const itemIndex = startIndex + index;
			this.setupBoundry( $elem, itemIndex, minHeight );

			// If a child element is expected to load and update DOM according
			// listen to the load event and update boundry placement accordingly
			if ( $watchTarget ) {
				$watchTarget.addEventListener( customEvent.LOADED, () => {
					const { height } = $childElement.getBoundingClientRect();
					const adPadding = this.adBottomPadding + this.adTopPadding;
					this.updateLowerbound( itemIndex, height + adPadding );

					// there's a chance that ad content within wrapper hasn't been loaded yet,
					// attempt to read element height again after 1 second
					setTimeout( () => {
						const { height: widgetHeight } = $childElement.getBoundingClientRect();
						this.updateLowerbound( itemIndex, widgetHeight + adPadding );
					}, 1000 );
				});
			}
		});
	},

	/**
	 * Create a new sticky rail element, append after the last sticky rail in the sidebar
	 *
	 * @method grow
	 * @param HTMLElement{$elem} - a sticky rail element
	 */
	grow( $elem ) {
		// define ad position and id based on previous ad's position value
		const adPrefix = 'gpt-ad-300250300600';
		let adPos = parseInt( $elem.querySelector( this.adUnitSelector ).dataset.adPos, 10 ) + 1;
		let adId = `${adPrefix}-${adPos}`;

		// Ad id exists, generate a unique ID
		if ( document.querySelector( `#${adId}` ) ) {
			const $comboBox = document.querySelectorAll( this.rightrailAdSelector );
			if ( $comboBox ) {
				const $lastAd = $comboBox[$comboBox.length - 1];
				adPos = $lastAd.dataset.adPos + 1;
				adId = `${adPrefix}-${adPos}`;
			}
		}

		const $stickyRail = document.createElement( 'div' );
		$stickyRail.dataset.stickyRailGrow = true;
		$stickyRail.dataset.stickyRailBound = $elem.dataset.stickyRailBound;
		$stickyRail.dataset.watchOnload = adId;
		$stickyRail.setAttribute( 'class', $elem.getAttribute( 'class' ) );
		$stickyRail.innerHTML = this.template.replace( '{{adId}}', adId ).replace( '{{adPos}}', adPos );

		// Reset any inline height
		$elem.style.height = ''; /* eslint-disable-line no-param-reassign */
		$elem.parentNode.appendChild( $stickyRail );

		this.setup([$stickyRail], adPos );

		/* global gn_monetize */
		/* eslint-disable camelcase */
		if ( 'undefined' !== typeof gn_monetize && 'undefined' !== typeof gn_monetize.Ads ) {
			// Initiate dynamic ad
			gn_monetize.Ads.create({
				sizes: '[[300,250],[300,600]]',
				biddable: true,
				id: adId,
				lazy: true,
				targeting: {
					pos: adPos,
				},
			});
		}
		/* eslint-enable camelcase */
	},

	/**
	 * Set up upper and lower bound for sticky rail
	 * in / out of view detection
	 *
	 * @method setupBoundry
	 * @param {HTMLElement} $elem - DOM element to set boundries for
	 * @param {number} index - unique index for identifying the element
	 * @param {number} height - height of sticky content, padding included
	 * 							for calculating lower bound
	 */
	setupBoundry( $elem, index, height ) {
		const $upperbound = document.createElement( 'div' );
		$upperbound.classList.add( this.upperboundCss );
		$upperbound.classList.add( this.boundryCss );
		$upperbound.setAttribute( 'style', `top: -${this.adTopPadding}px;` );
		$upperbound.dataset.alwaysObserve = 'true';
		$upperbound.dataset.watchIndex = index;
		$upperbound.addEventListener( customEvent.IN_VIEW, evt => this.handleUpperboundInView( evt ) );

		const $lowerbound = document.createElement( 'div' );
		$lowerbound.classList.add( this.lowerboundCss );
		$lowerbound.classList.add( this.boundryCss );
		$lowerbound.dataset.alwaysObserve = 'true';
		$lowerbound.dataset.watchIndex = index;
		$lowerbound.addEventListener( customEvent.IN_VIEW, evt => this.handleLowerboundInView( evt ) );

		const $childElement = $elem.querySelector( this.childSelector );
		$elem.dataset.watchIndex = index; /* eslint-disable-line no-param-reassign */
		$elem.insertBefore( $upperbound, $childElement );
		$elem.appendChild( $lowerbound );

		this.$rails[index] = $elem;
		this.$lowerboundMarkers[index] = $lowerbound;
		this.updateLowerbound( index, height );

		this.watcher.startWatching( $upperbound );
	},

	/**
	 * Set lower bound position for specific element
	 *
	 * @method updateLowerbound
	 * @param {number} index - unique index for identifying the element
	 * @param {number} position - position of lower bound element
	 */
	updateLowerbound( index, position ) {
		const $lowerbound = this.$lowerboundMarkers[index];
		if ( $lowerbound ) {
			$lowerbound.setAttribute( 'style', `bottom: ${position}px;` );
		}
	},

	/**
	 * Handle upperbound coming in / out of viewport
	 *
	 * @method handleUpperboundInView
	 * @param {object} evt - InView event
	 */
	handleUpperboundInView( evt ) {
		const $rail = this.$rails[evt.detail.target.dataset.watchIndex];
		const $lowerbound = this.$lowerboundMarkers[evt.detail.target.dataset.watchIndex];

		// stop watching for lowerbound in view events
		this.watcher.stopWatching( $lowerbound );

		if ( ! evt.detail.isInView && evt.detail.clientBounds.top <= evt.detail.rootBounds.top ) {
			// upperbound went out of view as user scrolls downward
			// make element sticky within rail
			const { width } = $rail.getBoundingClientRect();
			$rail.dataset.display = 'sticky';
			$rail.querySelector( this.childSelector ).setAttribute( 'style', `width: ${width}px;` );

			// start watching for lower bound going out of view
			// for anchoring the element
			this.watcher.startWatching( $lowerbound );
		} else {
			// upperbound is in view, restore normal view
			delete $rail.dataset.display;
			$rail.querySelector( this.childSelector ).setAttribute( 'style', '' );
		}
	},

	/**
	 * Handle lower coming in / out of viewport
	 *
	 * @method handleUpperboundInView
	 * @param {object} evt - InView event
	 */
	handleLowerboundInView( evt ) {
		const $rail = this.$rails[evt.detail.target.dataset.watchIndex];

		if ( ! evt.detail.isInView && evt.detail.clientBounds.top <= evt.detail.rootBounds.top ) {
			// lower bound went out of view as user scrolls downward
			// anchor sticky element to the bottom of the rail
			$rail.dataset.display = 'anchored';
			$rail.querySelector( this.childSelector ).setAttribute( 'style', '' );
		} else if ( evt.detail.isInView && 'anchored' === $rail.dataset.display ) {
			const { width } = $rail.getBoundingClientRect();
			$rail.dataset.display = 'sticky';
			$rail.querySelector( this.childSelector ).setAttribute( 'style', `width: ${width}px;` );
		}
	},
};

export default stickyRail;