article/QuickLinks.js

import InView from '../utils/classes/InView';
import customEvent from '../utils/customEvent';

/**
 * Quick links for anchoring on the page
 *
 * @module QuickLinks
 * @prop {Array} headings - Section headings found in article
 * @prop {Object} selectors - DOM element selectors
 * @prop {Object} css - quick link elements class names
 */
const QuickLinks = {
	headings: [],
	selectors: {
		list: '.c-quickLinks__list',
		select: '.c-quickLinks__select',
		heading: '.l-article__sectionHeading',
		anchor: '.l-article__sectionAnchor',
		scroll: '.c-quickLinks--scroll',
		section: '.l-article__section',
		stickyDropdown: 'c-quickLinks--sticky',
		articleDropdown: '.c-quickLinks--article',
	},

	css: {
		link: 'c-quickLinks__link',
		item: 'c-quickLinks__item',
		highlight: 'c-quickLinks__item--highlight',
		hidden: 'is-hidden',
	},

	/**
	 * Look for section headings in the article body
	 * and generate quick links accordingly.
	 *
	 * @method init
	 */
	init() {
		const $lists = document.querySelectorAll( this.selectors.list );
		const $selects = document.querySelectorAll( this.selectors.select );
		this.headings = document.querySelectorAll( this.selectors.heading );

		if ( this.headings && $lists ) {
			[].forEach.call( $lists, ( $list ) => {
				if ( this.headings.length > 0 ) {
					[].forEach.call( this.headings, ( $heading ) => {
						const $anchor = $heading.querySelector( this.selectors.anchor );
						if ( $anchor ) {
							const $item = document.createElement( 'li' );
							$item.classList.add( this.css.item );

							const $link = document.createElement( 'a' );
							$link.textContent = $heading.textContent;
							$link.classList.add( this.css.link );
							$link.setAttribute( 'href', `#${$anchor.getAttribute( 'id' )}` );

							$item.appendChild( $link );
							$list.appendChild( $item );

							const $addedLink = $item.querySelector( `.${this.css.link}` );
							$addedLink.addEventListener( 'click', evt => this.track( evt ) );
						}
					});
				} else {
					$list.parentNode.classList.add( this.css.hidden );
				}
			});
		}

		if ( this.headings && $selects ) {
			[].forEach.call( $selects, ( $select ) => {
				if ( this.headings.length > 0 ) {
					// Create 1st generic option
					const $firstOption = document.createElement( 'option' );
					$firstOption.textContent = 'Go to section...';
					$firstOption.setAttribute( 'disabled', true );
					$firstOption.setAttribute( 'selected', true );
					$firstOption.setAttribute( 'value', '' );
					$select.appendChild( $firstOption );
					[].forEach.call( this.headings, ( $heading ) => {
						const $anchor = $heading.querySelector( this.selectors.anchor );
						if ( $anchor ) {
							const $option = document.createElement( 'option' );
							$option.textContent = $heading.textContent;
							$option.classList.add( this.css.link );
							$option.setAttribute( 'value', `#${$anchor.getAttribute( 'id' )}` );
							$select.appendChild( $option );
						}
					});
					$select.addEventListener( 'change', ( evt ) => {
						if ( '' !== $select.value ) {
							window.location.hash = $select.value;
							this.track( evt );

							// Update the value on the other select for consistency
							[].forEach.call( $selects, ( $select2 ) => {
								const $selectDropdown = $select2;
								$selectDropdown.value = $select.value;
							});
						}
					});
				} else {
					$select.parentNode.classList.add( this.css.hidden );
				}
			});
		}

		// If at least one quickLinks menu has a scroll class
		// attach scrolling trigger to the sections
		// currently the scrolling menu is only on 'Issues' article template
		if ( document.querySelector( this.selectors.scroll ) ) {
			// Trigger to add contrst class to sticky nav on custom template
			this.attachTriggers(
				this.selectors.section,
				[{
					handler: this.menuHighlight,
					key: 'menuHighlight',
				}],
			);
		}
		this.$sectionsDropdown = document.querySelector( this.selectors.articleDropdown );
		this.$stickyQuickLinks = document.querySelector( `.${this.selectors.stickyDropdown}` );

		// Section dropdown menu transitions on scroll on 'Featured' and 'Issues' templates
		if ( this.$sectionsDropdown && this.$stickyQuickLinks ) {
			// Runs only on tablet-portrait and below
			if ( window.innerWidth <= 1024 ) {
				this.attachTriggers(
					this.selectors.articleDropdown,
					[{
						handler: this.transitionSectionsDropdown,
						key: 'transitionSectionsDropdown',
					}],
				);
			}
		}
	},

	/**
	 * Attaches Intersection Observer trigger to all sections within the article
	 *
	 * @method attachTriggers
	 * @param {string} selector - CSS selector for the transition trigger element.
	 * @param {array} callbacks - Array of callback handlers and keys.
	 */
	attachTriggers( selector, callback ) {
		const $targets = document.querySelectorAll( selector );

		// Don't run if there are no targers in the article
		if ( ! $targets ) {
			return;
		}

		[].forEach.call( $targets, ( target ) => {
			if ( target ) {
				const $target = target;

				// We need to continue observing the breakpoint no matter how often the user scrolls past
				$target.dataset.alwaysObserve = 'true';

				// Threshold height
				const boundary = $target.getBoundingClientRect();

				// Bind observer handler
				$target.addEventListener(
					customEvent.IN_VIEW,
					callback[0].handler.bind( this, boundary, callback[0].key ),
				);
			}
		});

		// Initialize Intersection Observer
		const observer = new InView({
			selector,
			threshold: 0.1,
		});
		observer.init();
	},

	/**
	 * Hightlights section menu item when corresponding section is visible
	 *
	 * @method menuHighlight
	 * @param {object} boundary - The bounding box values of the trigger element.
	 * @param {string} key - String to select the appropriate status setting.
	 * @param {object} event - Intersection Observer callback object with InView.
	 */
	menuHighlight( boundary, key, event ) {
		if ( event && event.detail ) {
			const $section = event.target;

			// Check if this section has a heading anchor
			const $sectionAnchor = $section.querySelector( this.selectors.anchor );
			let anchorId = '';
			if ( $sectionAnchor ) {
				anchorId = $sectionAnchor.getAttribute( 'id' );

				// Find corresponding menu item
				const $menuLink = document.querySelector( `a[href="#${anchorId}"]` );
				const $menuItem = $menuLink.parentNode;

				// If the section with a heading is now in view
				if ( event.detail.isInView ) {
					$menuItem.classList.add( this.css.highlight );
				} else {
					$menuItem.classList.remove( this.css.highlight );
				}
			}
		}
	},

	/**
	 * Handles sticky behavior of the quickLinks article menu select
	 * Used on the 'Issues' and 'Featured' template
	 *
	 * @method transitionSectionsDropdown
	 * @param {object} boundary - The bounding box values of the trigger element.
	 * @param {string} key - String to select the appropriate status setting.
	 * @param {object} event - Intersection Observer callback object with InView.
	 */
	transitionSectionsDropdown( boundary, key, event ) {
		const dropdownTop = this.$sectionsDropdown.getBoundingClientRect().top;
		const aboveMenu = dropdownTop > 0;

		if ( event
			&& event.detail
			&& event.detail.ratioInView < 0.1
			&& aboveMenu ) { // In-article dropdown is below viewport
			setTimeout( () => {
				this.$stickyQuickLinks.classList.add( this.css.hidden );
				customEvent.fire( window, customEvent.STICKY_OFF, {
					target: this.$stickyQuickLinks,
					selector: this.selectors.stickyDropdown,
				});
			}, 500 );
		} else if ( event
			&& event.detail
			&& event.detail.ratioInView < 0.1
			&& ! aboveMenu ) { // In-article dropdown is above viewport
			setTimeout( () => {
				if ( this.headings.length > 0 ) {
					this.$stickyQuickLinks.classList.remove( this.css.hidden );
					customEvent.fire( window, customEvent.STICKY_ON, {
						target: this.$stickyQuickLinks,
						selector: this.selectors.stickyDropdown,
					});
				}
			}, 500 );
		} else if ( event
			&& event.detail
			&& event.detail.ratioInView >= 0.1 ) { // In-article dropdown is currently visible
			setTimeout( () => {
				this.$stickyQuickLinks.classList.add( this.css.hidden );
				customEvent.fire( window, customEvent.STICKY_OFF, {
					target: this.$stickyQuickLinks,
					selector: this.selectors.stickyDropdown,
				});
			}, 500 );
		}
	},

	/**
	 * Track link click
	 */
	track( event ) {
		/* global gn_analytics */
		/* eslint-disable camelcase */
		if ( 'undefined' !== typeof ( gn_analytics ) ) {
			const $target = event.currentTarget;
			gn_analytics.Analytics.track(['adobe'], {
				eventType: 'article quick link',
				action: $target.textContent.trim(),
				target: $target,
			});
		}
		/* eslint-enable camelcase */
	},
};

export default QuickLinks;