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;