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;