ads/StickyAd.js

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

/**
 * Sticky header ad.
 *
 * @example
 * <section class="l-headerAd">
 *		<div class="l-headerAd__container">
 * 			<div class="c-ad c-ad--728x90">
 * 				<div class="c-ad__unit"></div>
 *	 		</div>
 *			<div class="l-headerAd__countdown">
 * 				<span class="l-headerAd__message"></span>
 *				<span class="l-headerAd__close">Close X</span>
 *	 		</div>
 *		</div>
 * </section>
 *
 * @module StickyAd
 */
const StickyAd = {
	selector: '.l-headerAd__container',
	wrapperSelector: '.l-headerAd',
	adUnitSelector: '.c-ad__unit',
	countdownCloseSelector: '.l-headerAd__close',
	countdownMessageSelector: '.l-headerAd__message',
	countdownSelector: '.l-headerAd__countdown',
	stickyCssClass: 'l-headerAd__container--stuck',
	detachCssClass: 'l-headerAd__container--detaching',
	cloneCssClass: 'is-clone',
	anchoredCss: 'l-headerAd--anchored',
	$elem: null,
	$clone: null,
	$countdown: null,
	$countdownMessage: null,
	$countdownClose: null,
	$stickyAdUnit: null,
	observer: null,
	stickyStarted: false,
	interval: 0,
	timeLeft: 5, // in seconds
	visibleFor: 5000, // in milliseconds
	/**
	 * Search for any sticky ad elements and bind click and ad complete events
	 * @method init
	 */
	init() {
		this.$elem = document.querySelector( this.selector );
		this.$countdown = document.querySelector( this.countdownSelector );
		this.$countdownMessage = document.querySelector( this.countdownMessageSelector );
		this.$countdownClose = document.querySelector( this.countdownCloseSelector );

		if ( this.$elem && this.$countdown && this.$countdownMessage && this.$countdownClose ) {
			const options = {
				root: null,
				rootMargin: '0px',
				threshold: 1,
			};

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

			// On ad complete, remember the ad load time and start watching for ad visibilitiy
			this.$stickyAdUnit = this.$elem.querySelector( this.adUnitSelector );
			this.$stickyAdUnit.addEventListener( customEvent.LOADED, () => {
				this.$elem.dataset.loadedAt = ( new Date() ).getTime();
				this.observer.observe( this.$elem );
			});

			// Close button
			this.$countdownClose.addEventListener( 'click', () => {
				this.unstick( true );
				this.stopCountdown();
			});

			if ( ! positionSticky.supported() ) {
				document.querySelector( this.wrapperSelector ).classList.add( this.anchoredCss );
			}
		}
	},

	/**
	 * Start updating sticky ad countdown per 1 second.
	 * @method startCountdown
	 */
	startCountdown() {
		this.updateCountdown();
		this.interval = setInterval( () => {
			this.updateCountdown();
		}, 1000 );
		this.stickyStarted = true;
	},

	/**
	 * Update countdown and display message indicating number second(s) left.
	 * @method updateCountdown
	 */
	updateCountdown() {
		if ( this.timeLeft > 0 ) {
			const unit = this.timeLeft > 1 ? 'seconds' : 'second';
			this.$countdownMessage.textContent = `This ad will close in ${this.timeLeft} ${unit}`;
		} else {
			this.unstick( true );
			this.stopCountdown();
		}
		this.timeLeft = this.timeLeft - 1;
	},

	/**
	 * Clear interval for countdown and hide the countdown message.
	 * @method stopCountdown
	 */
	stopCountdown() {
		clearInterval( this.interval );
	},

	/**
	 * Make element sticky and create a clone for observing its viewability
	 * @method stick
	 */
	stick( entry ) {
		this.$clone = document.createElement( this.$elem.nodeName );
		this.$clone.classList.add( this.cloneCssClass );
		this.$clone.style.width = `${entry.boundingClientRect.width}px`;
		this.$clone.style.height = `${entry.boundingClientRect.height}px`;

		this.$elem.parentNode.appendChild( this.$clone );
		this.$elem.classList.add( this.stickyCssClass );

		this.observer.observe( this.$clone );
		this.observer.unobserve( this.$elem );

		this.broadcast( customEvent.STICKY_ON );
	},

	/**
	 * Unstick element and stop observing viewability of unsticking for good
	 * @method unstick
	 * @param {Boolean} $forGood - when set to true, slide out sticky element and stop observing
	 */
	unstick( animated ) {
		this.observer.unobserve( this.$clone );

		if ( ! animated ) {
			// Unstick without animation
			this.$elem.parentNode.removeChild( this.$clone );
			this.$elem.classList.remove( this.stickyCssClass );
		} else {
			// Unstick witih slide out animation
			this.observer.unobserve( this.$elem );
			if ( this.$elem.classList.contains( this.stickyCssClass ) ) {
				this.slideOut();
			}
		}

		this.broadcast( customEvent.STICKY_OFF );
	},

	/**
	 * Slide out sticky element smoothly and return it to original position
	 * @method slideOut
	 */
	slideOut() {
		this.$elem.classList.remove( this.stickyCssClass );
		this.$elem.classList.add( this.detachCssClass );
		setTimeout( () => {
			this.$elem.parentNode.removeChild( this.$clone );
			this.$elem.classList.remove( this.detachCssClass );
		}, 350 );
	},

	/**
	 * Fire custom event to window object
	 * @method broadcast
	 * @param event - name of custom event
	 */
	broadcast( event ) {
		customEvent.fire( window, event, {
			target: this.$elem,
			selector: this.selector,
		});
	},

	/**
	 * When ad is not visible, make it sticky unless it has already been visible for more than
	 * ${visibleFor} number of seconds
	 * @method intersectionCallback
	 */
	intersectionCallback( entries ) {
		[].forEach.call( entries, ( entry ) => {
			const { isIntersecting } = entry;
			const { target: $target } = entry;

			// Apply stickness based on observation of viewability of $elem and its $clone
			if ( ! isIntersecting && ! $target.classList.contains( this.cloneCssClass ) ) {
				const current = ( new Date() ).getTime();
				let { loadedAt } = this.$elem.dataset;
				loadedAt = loadedAt ? parseFloat( loadedAt ) : current;

				// Make ad sticky if it hasn't been visible for 5 seconds
				if ( ! this.stickyStarted && current - loadedAt < this.visibleFor ) {
					this.startCountdown();
				}

				if ( this.stickyStarted ) {
					this.stick( entry );
				}
			} else if ( isIntersecting && $target.classList.contains( this.cloneCssClass ) ) {
				this.unstick();
				this.stopCountdown();
			}
		});
	},
};

export default StickyAd;