longform/Chapters.js

import throttle from 'lodash/throttle';
import InView from '../utils/classes/InView';
import customEvent from '../utils/customEvent';
import supportsPassiveEvent from '../utils/supportsPassiveEvent';

/**
 * Show chapters selection when article marker is in view
 *
 * @module Chapters
 * @prop {object} selectors - Chapters CSS selectors
 * @prop {object} states - css and animation states for chapters module
 * @prop {NodeList} $elem - chapters element
 * @prop {number} currentScrolPos - store current scroll position to determine scroll direction
 * @prop {string} currentScrollDirection - 'up' or 'down'
 * @prop {function} scrollListener - event handler for handling scroll event
 */
const Chapters = {
	selectors: {
		elem: '.c-longform-chapters',
		marker: '.l-longform-article__marker',
		hero: '.c-longform-hero',
		panel: '.c-longform-chapters__panel',
		desktopPanel: '.c-longform-chapters__panel--desktop',
		pagination: '.c-longform-chapters__pagination',
	},
	states: {
		opened: 'c-longform-chapters--opened',
		active: 'c-longform-chapters--active',
		shifted: 'c-longform-chapters--shifted',
		fading: {
			in: 'is-slid-and-faded-in',
		},
	},
	$elem: false,
	currentScrollPos: 0,
	currentScrollDirection: '',
	scrollListener: false,

	/**
	 * Set up inview listener to handle show / hide of chapters
	 *
	 * @method init
	 */
	init() {
		const $marker = document.querySelector( this.selectors.marker );
		this.$elem = document.querySelector( this.selectors.elem );

		if ( this.$elem && $marker ) {
			// bind event to capture scroll listener
			this.scrollListener = throttle( () => {
				this.monitorScroll();
			}, 200 );

			// Initialize InView watcher
			const watcher = new InView({});
			watcher.init();

			// Watch article midway marker
			$marker.dataset.alwaysObserve = 'true';
			$marker.setAttribute( 'style', 'width:100%;position:absolute;top:auto;pointer-events:none;height:300px;' );
			$marker.addEventListener( customEvent.IN_VIEW, evt => this.showChapters( evt ) );
			watcher.startWatching( $marker );

			const $hero = document.querySelector( this.selectors.hero );
			if ( $hero ) {
				// Watch article hero
				$hero.dataset.alwaysObserve = 'true';
				$hero.addEventListener( customEvent.IN_VIEW, evt => this.hideChapters( evt ) );
				watcher.startWatching( $hero );
			}

			// Show chapters immediately when scrollY is below article marker
			if ( window.scrollY >= $marker.offsetTop ) {
				this.showChapters( false, true );
			}

			// Watch panel attribute change to adjust next / prev item style
			const observer = new MutationObserver( () => this.updateState() );
			const $panels = document.querySelectorAll( this.selectors.panel );
			[].forEach.call( $panels, ( $panel ) => {
				observer.observe( $panel, {
					attributes: true,
					childList: false,
					subtree: false,
				});
			});

			// Set top position of the chapters panel
			const $desktopPanel = document.querySelector( this.selectors.desktopPanel );
			const $pagination = document.querySelector( this.selectors.pagination );
			if ( $desktopPanel && $pagination ) {
				const style = $desktopPanel.getAttribute( 'style' );
				$desktopPanel.setAttribute( 'style', `${style || ''}top:${$pagination.offsetHeight}px;` );
			}
		}
	},

	/**
	 * Monitor scroll position to determine if the chapters widget
	 * should be anchored to the top of stacked below sticky header
	 *
	 * @method monitorScroll
	 */
	monitorScroll() {
		const direction = window.pageYOffset > this.currentScrollPos ? 'down' : 'up';
		// adjust css class when scrolling in opposite direction
		if ( this.currentScrollDirection !== direction ) {
			if ( 'down' === direction ) {
				this.$elem.classList.remove( this.states.shifted );
			} else {
				this.$elem.classList.add( this.states.shifted );
			}
		}

		this.currentScrollDirection = direction;
		this.currentScrollPos = window.pageYOffset;
	},

	/**
	 * Show chapters widget when marker element is in view
	 * or when forced via forcedShow flag (triggered on page load)
	 *
	 * @method showChatpers
	 * @param {object} evt - in view event
	 * @param {boolean} forcedShow
	 */
	showChapters( evt, forcedShow ) {
		if ( ( ( evt && evt.detail.isInView ) || forcedShow )
			&& ! this.$elem.classList.contains( this.states.active ) ) {
			this.$elem.classList.add( this.states.active );
			this.$elem.classList.add( this.states.shifted );
			this.$elem.classList.add( this.states.fading.in );

			// If browser supports passive event, add passive option
			window.addEventListener( 'scroll', this.scrollListener, supportsPassiveEvent ? { passive: true } : false );
		}
	},

	/**
	 * Hide chapters widget when hero area is in view
	 *
	 * @method hideChatpers
	 * @param {object} evt - in view event
	 */
	hideChapters( evt ) {
		if ( evt.detail.isInView ) {
			this.$elem.classList.remove( this.states.active );
			this.$elem.classList.remove( this.states.fading.in );

			this.currentScrollPos = 0;
			this.currentScrollDirection = '';
			window.removeEventListener( 'scroll', this.scrollListener );
		}
	},

	/**
	 * Update next / previous item states when panel is opened / closed
	 *
	 * @method updateState
	 */
	updateState() {
		const $openedPanel = document.querySelector( `${this.selectors.panel}.${this.states.fading.in}` );
		if ( $openedPanel ) {
			this.$elem.classList.add( this.states.opened );
		} else {
			this.$elem.classList.remove( this.states.opened );
		}
	},
};

export default Chapters;