main/ScrollingNav.js

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

/**
 * Handles the transitions within the sticky header as users scroll down the page.
 *
 * @module ScrollingNav
 * @prop {objects} durations - List of animated durations in milliseconds.
 * @prop {object} actions - Set of flags that can be used to toggle various menu transitions.
 * @prop {object} selectors - Set of flags that can be used to grab navbar components.
 * @prop {object} status - Set of flags identifying the status of each navbar transition.
 */
const ScrollingNav = {
	durations: {
		short: 100,
		med: 200,
		long: 300,
		slow: 400,
	},

	actions: {
		fadeIn: 'is-faded-in',
		fadeOut: 'is-faded-out',
		fadeAndSlideIn: 'is-slid-and-faded-in',
		fadeAndSlideOut: 'is-slid-and-faded-out',
		animate: 'is-animated',
		animateSlow: 'is-animated-slow',
		animateFast: 'is-animated-fast',
		shrinkNav: 'l-navbar--stuck',
		shrinkNavWrapper: 'l-navWrapper--stuck',
		darkNavbar: 'l-navbar--darkMode',
		invertNav: 'c-nav--stuck',
		showTitleAndSocial: 'l-navbar--titleAndSocial',
		showMobileSocial: 'l-navbar--mobileSocial',
		narrowMobile: 'l-navbar--narrowMobile',
		initRadio: 'l-navbar--initRadio',
		showRadio: 'l-navbar--showRadio',
		shiftNotifications: 'l-notification--shift',
		customContrast: 'l-longform-header--active',
		customHeaderPosition: 'l-longform-header--narrow',
		interactiveContrast: 'l-interactive-header--active',
		interactiveHeaderPosition: 'l-interactive-header--narrow',
		positionNavbar: 'l-longform-navbar--belowHero',
		positionInteractiveNavbar: 'l-interactive-navbar--belowContent',
		openNavbar: 'l-navbar--menuOpen',
	},

	selectors: {
		menu: {
			wrapper: '.l-navWrapper',
			navbar: '.l-navbar',
			main: '.l-navbar__standard',
			morty: '.l-navbar__morty',
			topics: '.l-navbar__topics',
			topicItems: '.l-navbar__topics .c-nav',
			topicList: '.l-navbar__list',
			titleAndSocial: '.l-navbar__scrolling',
			track: '.l-navbar__track',
			toggles: '.l-navbar__toggles',
			toggleButtons: '.l-navbar__toggles .c-nav',
			search: '.l-navbar__search',
			items: '.c-nav--main',
			radio: '.l-navbar__radio',
			radioFader: '.l-navbar__radioFader',
			notification: '.l-notification',
			custom: '.l-navbar--custom',
			mobile: {
				container: '.l-navbar__top',
				buttons: '.l-navbar__buttons',
				buttonsItems: '.l-navbar__buttons--mobile.c-nav--buttons',
				labels: '.l-navbar__buttons .c-nav__label',
				topicItems: '.l-navbar__buttons--mobile',
				watchIcon: '.c-nav__icon--watch',
				liveIcon: '.c-nav__icon--live',
			},
		},
		targets: {
			header: '#header:not(.l-longform-header):not(.l-interactive-header)',
			longformHeader: '#header.l-longform-header',
			longformHero: '.c-longform-hero',
			interactiveHeader: '#header.l-interactive-header',
			interactiveBody: '.l-interactive-article__main-content',
			social: '[data-article-socialShare]',
			radio: '[data-nav-type="radio"]',
		},
	},

	status: {
		transitionNav: {
			started: false,
			animationEnabled: false,
			timeout: false,
		},
		transitionMorty: {
			started: false,
			animationEnabled: false,
			timeout: false,
		},
		transitionMobileButtons: {
			started: false,
			animationEnabled: false,
			timeout: false,
		},
		transitionTitleAndSocial: {
			started: false,
			animationEnabled: false,
			timeout: false,
		},
		transitionRadio: {
			started: false,
			animationEnabled: false,
			timeout: false,
		},
	},

	/**
	 * Initializes navbar transition triggers.
	 *
	 * @method init
	 */
	init() {
		// Trigger for morty logo menu transition and mobile nav button transition after header
		this.attachTriggers(
			this.selectors.targets.header,
			[{
				handler: this.transitionNav,
				key: 'transitionNav',
			}, {
				handler: this.transitionMobileButtons,
				key: 'transitionMobileButtons',
			}],
		);

		// Trigger for article title / social share menu transition after social bar
		this.attachTriggers(
			this.selectors.targets.social,
			[{
				handler: this.transitionTitleAndSocial,
				key: 'transitionTitleAndSocial',
			}],
		);

		// Trigger to animate in radio widget in sticky nav after scrolling past radio nav
		this.attachTriggers(
			this.selectors.targets.radio,
			[{
				handler: this.transitionRadio,
				key: 'transitionRadio',
			}],
		);

		// Trigger to add contrst class to sticky nav on custom template
		this.attachTriggers(
			this.selectors.targets.longformHero,
			[{
				handler: this.transitionCustomTemplate,
				key: 'transitionCustomTemplate',
			}],
		);

		// Trigger to add contrast class to sticky nav on interactive template
		this.attachTriggers(
			this.selectors.targets.interactiveBody,
			[{
				handler: this.transitionInteractiveTemplate,
				key: 'transitionInteractiveTemplate',
			}],
			0.001,
		);
	},

	/**
	 * Bind InView listener for transition triggers.
	 *
	 * @method attachTriggers
	 * @param {string} selector - CSS selector for the transition trigger element.
	 * @param {array} callbacks - Array of callback handlers and keys.
	 */
	attachTriggers( selector, callbacks, threshold = 0.1 ) {
		const $target = document.querySelector( selector );

		if ( ! $target ) {
			// Don't run if breakpoint target is not on the page
			return;
		}

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

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

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

		// Bind observer handler
		callbacks.forEach( ( callback ) => {
			$target.addEventListener(
				customEvent.IN_VIEW,
				callback.handler.bind( this, boundary, callback.key ),
			);
		});
	},

	/**
	 * Handles smoothly shrinking the logo and updating navigation style
	 * when the user scrolls past the header.
	 *
	 * @method transitionNav
	 * @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.
	 */
	transitionNav( boundary, key, event ) {
		this.$wrapper = this.$wrapper || document.querySelector( this.selectors.menu.wrapper );
		this.$navbar = this.$navbar || document.querySelector( this.selectors.menu.navbar );
		this.$customNavbar = this.$customNavbar
			|| document.querySelectorAll( this.selectors.menu.custom );
		this.$morty = this.$morty || document.querySelector( this.selectors.menu.morty );
		this.$topics = this.$topics || document.querySelector( this.selectors.menu.topics );
		this.$topicItems = this.$topicItems || document.querySelector( this.selectors.menu.topicItems );
		this.$buttonsItems = this.$buttonsItems
			|| document.querySelector( this.selectors.menu.mobile.buttonsItems );
		this.$toggles = this.$toggles || document.querySelector( this.selectors.menu.toggles );
		this.$toggleButtons = this.$toggleButtons
			|| document.querySelector( this.selectors.menu.toggleButtons );
		this.$mobileTopicItems = this.$mobileTopicItems
			|| document.querySelector( this.selectors.menu.mobile.topicItems );

		if ( event && event.detail ) {
			// We don't need to do the transition if we're starting below the breakpoint
			if ( false !== this.status[key].started || boundary.bottom > 0 ) {
				if ( false === this.status[key].animationEnabled ) {
					this.$wrapper.classList.add( this.actions.animateFast );
					this.$navbar.classList.add( this.actions.animateFast );
					this.$morty.classList.add( this.actions.animateFast );
					this.$topics.classList.add( this.actions.animateFast );
					this.$toggles.classList.add( this.actions.animateFast );
					[].forEach.call( this.$customNavbar, ( $nav ) => {
						$nav.classList.add( this.actions.animateFast );
					});
					this.status[key].animationEnabled = true;
				}
			}

			if ( event.detail.ratioInView < 0.1 ) {
				this.$wrapper.classList.add( this.actions.shrinkNavWrapper );
				this.$navbar.classList.add( this.actions.shrinkNav );
				this.$topicItems.classList.add( this.actions.invertNav );
				this.$buttonsItems.classList.add( this.actions.invertNav );
				this.$toggleButtons.classList.add( this.actions.invertNav );
				this.$mobileTopicItems.classList.add( this.actions.invertNav );

				// Remember dark mode. Remove dark mode for sticky mode.
				if ( this.$navbar.classList.contains( this.actions.darkNavbar ) ) {
					this.$navbar.dataset.darkMode = true;
					this.$navbar.classList.remove( this.actions.darkNavbar );
				}

				customEvent.fire( window, customEvent.NAV_TRANSITIONED, {
					target: this.$navbar,
					sticky: true,
				});
			} else {
				this.$wrapper.classList.remove( this.actions.shrinkNavWrapper );
				this.$navbar.classList.remove( this.actions.shrinkNav );
				this.$topicItems.classList.remove( this.actions.invertNav );
				this.$buttonsItems.classList.remove( this.actions.invertNav );
				this.$toggleButtons.classList.remove( this.actions.invertNav );
				this.$mobileTopicItems.classList.remove( this.actions.invertNav );

				// Restore dark mode for non-sticky nav.
				if ( this.$navbar.dataset.darkMode ) {
					this.$navbar.classList.add( this.actions.darkNavbar );
				}

				customEvent.fire( window, customEvent.NAV_TRANSITIONED, {
					target: this.$navbar,
					sticky: false,
				});
			}

			this.status[key].started = true;
		}
	},

	/**
	 * Handles smoothly removing mobile menu button labels and shrinking nav
	 * when user scrolls past the header
	 *
	 * @method transitionMobileButtons
	 * @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.
	 */
	transitionMobileButtons( boundary, key, event ) {
		this.$navbar = this.$navbar
			|| document.querySelector( this.selectors.menu.navbar );
		this.$container = this.$container
			|| document.querySelector( this.selectors.menu.mobile.container );
		this.$buttons = this.$buttons
			|| document.querySelectorAll( this.selectors.menu.mobile.buttons );
		this.$labels = this.$labels
			|| document.querySelectorAll( this.selectors.menu.mobile.labels );
		this.$notification = this.$notification
			|| document.querySelector( this.selectors.menu.notification );
		this.$watchIcon = this.$watchIcon
			|| document.querySelector( this.selectors.menu.mobile.watchIcon );
		this.$liveIcon = this.$liveIcon
			|| document.querySelector( this.selectors.menu.mobile.liveIcon );

		if ( event && event.detail ) {
			// We don't need to do the transition if we're starting below the breakpoint
			if ( false !== this.status[key].started || boundary.bottom > 0 ) {
				if ( false === this.status[key].animationEnabled ) {
					this.$container.classList.add( this.actions.animateFast );
					this.$watchIcon.classList.add( this.actions.animateFast );
					this.$liveIcon.classList.add( this.actions.animateFast );

					[].forEach.call( this.$buttons, ( $button ) => {
						if ( $button.classList ) {
							$button.classList.add( this.actions.animateFast );
						}
					});

					[].forEach.call( this.$labels, ( $label ) => {
						$label.classList.add( this.actions.animateFast );
					});

					if ( this.$notification ) {
						this.$notification.classList.add( this.actions.animateFast );
					}

					this.status[key].animationEnabled = true;
				}
			}

			if ( event.detail.ratioInView < 0.1 ) {
				const delay = ( false === this.status[key].animationEnabled ) ? 0 : this.durations.short;

				[].forEach.call( this.$labels, ( $label ) => {
					$label.classList.remove( this.actions.fadeIn );
					$label.classList.add( this.actions.fadeOut );
				});

				// Clear timeout
				this.clearTimeout( this.status[key].timeout );
				this.status[key].timeout = setTimeout( () => {
					this.$navbar.classList.add( this.actions.narrowMobile );

					this.$watchIcon.classList.remove( this.actions.fadeIn );
					this.$watchIcon.classList.add( this.actions.fadeOut );
					this.$liveIcon.classList.remove( this.actions.fadeOut );
					this.$liveIcon.classList.add( this.actions.fadeIn );

					if ( this.$notification ) {
						this.$notification.classList.add( this.actions.shiftNotifications );
					}
				}, delay );
			} else {
				[].forEach.call( this.$labels, ( $label ) => {
					this.animateIn( $label );
				});

				this.$navbar.classList.remove( this.actions.narrowMobile );

				this.$watchIcon.classList.remove( this.actions.fadeOut );
				this.$watchIcon.classList.add( this.actions.fadeIn );
				this.$liveIcon.classList.remove( this.actions.fadeIn );
				this.$liveIcon.classList.add( this.actions.fadeOut );

				if ( this.$notification ) {
					this.$notification.classList.remove( this.actions.shiftNotifications );
				}
			}

			this.status[key].started = true;
		}
	},

	/**
	 * Handles smoothly adding toggling between the topics list and title / social menu.
	 *
	 * @method transitionTitleAndSocial
	 * @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.
	 * @param {boolean} reset - Flags if this transition is triggered by Back To Top scrolling.
	 */
	transitionTitleAndSocial( boundary, key, event, reset = false ) {
		this.$navbar = this.$navbar || document.querySelector( this.selectors.menu.navbar );
		this.$main = this.$main || document.querySelector( this.selectors.menu.main );
		this.$track = this.$track || document.querySelector( this.selectors.menu.track );
		this.$toggles = this.$toggles || document.querySelector( this.selectors.menu.toggles );
		this.$search = this.$search || document.querySelector( this.selectors.menu.search );
		this.$social = this.$social || document.querySelector( this.selectors.targets.social );
		this.$topicList = this.$topicList || document.querySelector( this.selectors.menu.topicList );
		this.$titleAndSocial = this.$titleAndSocial
			|| document.querySelector( this.selectors.menu.titleAndSocial );

		if ( ( event && event.detail ) || reset ) {
			// We don't need to do the transition if we're starting below the breakpoint
			if ( false !== this.status[key].started || boundary.top > 0 ) {
				if ( false === this.status[key].animationEnabled ) {
					// mobile transition
					this.$main.classList.add( this.actions.animateFast );

					// desktop transition
					this.$track.classList.add( this.actions.animate );
					this.$toggles.classList.add( this.actions.animate );
					this.$search.classList.add( this.actions.animate );
					this.status[key].animationEnabled = true;
				}
			}

			// Check if user has scrolled beyond the social share
			const scrolledPastSocial = this.$social.getBoundingClientRect().top <= 0;

			if ( reset ) {
				if ( ! scrolledPastSocial ) {
					this.hideTitleAndSocial( key );
				}
			}

			if ( event && event.detail.ratioInView < 0.1 ) {
				if ( scrolledPastSocial ) {
					// Clear timeout
					this.clearTimeout( this.status[key].timeout );

					// desktop transition
					this.$navbar.classList.add( this.actions.showTitleAndSocial );
					this.$navbar.classList.add( this.actions.showMobileSocial );
					this.animateOut( this.$search );

					// mobile transition
					this.animateIn( this.$main, 'fade-and-slide' );

					// hide to prevent tab index issues
					this.animateOut( this.$topicList );
					this.animateIn( this.$titleAndSocial );
					customEvent.fire( window, customEvent.NAV_TITLE_AND_SOCIAL, {
						in_view: true,
					});
				} else {
					this.hideTitleAndSocial( key );
					customEvent.fire( window, customEvent.NAV_TITLE_AND_SOCIAL, {
						in_view: false,
					});
				}
			} else {
				this.hideTitleAndSocial( key );
				customEvent.fire( window, customEvent.NAV_TITLE_AND_SOCIAL, {
					in_view: false,
				});
			}

			this.status[key].started = true;
		}
	},

	/**
	 * Handles actions for animating out and hiding Title and Social nav
	 *
	 * @method hideTitleAndSocial
	 * @param {string} key
	 */
	hideTitleAndSocial( key ) {
		const delay = ( false === this.status[key].animationEnabled ) ? 0 : this.durations.long;

		// desktop transition
		this.$navbar.classList.remove( this.actions.showTitleAndSocial );
		this.$navbar.classList.remove( this.actions.showMobileSocial );
		this.animateIn( this.$search );

		// mobile transition
		this.animateOut( this.$main, 'fade-and-slide' );

		// hide to prevent tab index issues
		this.animateOut( this.$titleAndSocial );
		this.animateIn( this.$topicList );

		// Clear timeout
		this.clearTimeout( this.status[key].timeout );
		this.status[key].timeout = setTimeout( () => {
			this.$navbar.classList.remove( this.actions.showMobileSocial );
		}, delay );
	},

	/**
	 * Handles smoothly adding and removing the radio widget in sticky nav
	 * when user scrolls past radio nav
	 *
	 * @method transitionRadio
	 * @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.
	 */
	transitionRadio( boundary, key, event ) {
		const delay = ( false === this.status[key].animationEnabled ) ? 0 : this.durations.med;

		this.$navbar = this.$navbar || document.querySelector( this.selectors.menu.navbar );
		this.$toggles = this.$toggles || document.querySelector( this.selectors.menu.toggles );
		this.$radioFader = this.$radioFader || document.querySelector( this.selectors.menu.radioFader );

		if ( event && event.detail ) {
			// We don't need to do the transition if we're starting below the breakpoint
			if ( false !== this.status[key].started || boundary.bottom > 0 ) {
				if ( false === this.status[key].animationEnabled ) {
					this.$toggles.classList.add( this.actions.animateFast );
					this.$radioFader.classList.add( this.actions.animateFast );
					this.status[key].animationEnabled = true;
				}
			}

			if ( event.detail.ratioInView < 0.1 ) {
				this.$navbar.classList.add( this.actions.initRadio );
				this.$navbar.classList.add( this.actions.showRadio );
			} else {
				this.$navbar.classList.remove( this.actions.showRadio );

				// Clear timeout
				this.clearTimeout( this.status[key].timeout );
				this.status[key].timeout = setTimeout( () => {
					this.$navbar.classList.remove( this.actions.initRadio );
				}, delay );
			}

			this.status[key].started = true;
		}
	},

	/**
	 * Handles adding opaque background and styling class to menu on custom template
	 * when user scrolls past hero sections
	 *
	 * @method transitionCustomTemplate
	 * @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.
	 */
	transitionCustomTemplate( boundary, key, event ) {
		this.$longformHeader = this.$longformHeader
			|| document.querySelector( this.selectors.targets.longformHeader );
		this.$navbar = this.$navbar || document.querySelector( this.selectors.menu.navbar );
		if ( event.detail.ratioInView < 0.1 ) {
			this.$longformHeader.classList.add( this.actions.customHeaderPosition );
			this.$navbar.classList.add( this.actions.positionNavbar );
			this.$longformHeader.classList.add( this.actions.customContrast );
			customEvent.fire( window, customEvent.NAV_TRANSITIONED, {
				target: this.$longformHeader,
				sticky: true,
			});
		} else {
			this.$longformHeader.classList.remove( this.actions.customHeaderPosition );
			this.$navbar.classList.remove( this.actions.positionNavbar );
			if ( ! this.$navbar.classList.contains( this.actions.openNavbar ) ) {
				this.$longformHeader.classList.remove( this.actions.customContrast );
			}
			customEvent.fire( window, customEvent.NAV_TRANSITIONED, {
				target: this.$longformHeader,
				sticky: false,
			});
		}
	},

	/**
	 * Handles adding opaque background and styling class to menu on interactive template
	 * when user scrolls past the main content section
	 *
	 * @method transitionInteractiveTemplate
	 * @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.
	 */
	transitionInteractiveTemplate( boundary, key, event ) {
		this.$interactiveHeader = this.$interactiveHeader
				|| document.querySelector( this.selectors.targets.interactiveHeader );
		this.$navbar = this.$navbar || document.querySelector( this.selectors.menu.navbar );

		if ( ! event.detail.isInView ) {
			this.$interactiveHeader.classList.add( this.actions.interactiveHeaderPosition );
			this.$navbar.classList.add( this.actions.positionInteractiveNavbar );
			this.$interactiveHeader.classList.add( this.actions.interactiveContrast );
		} else {
			this.$interactiveHeader.classList.remove( this.actions.interactiveHeaderPosition );
			this.$navbar.classList.remove( this.actions.positionInteractiveNavbar );
			if ( ! this.$navbar.classList.contains( this.actions.openNavbar ) ) {
				this.$interactiveHeader.classList.remove( this.actions.interactiveContrast );
			}
		}
	},


	/**
	 * Apply proper css for animating in target $elem
	 *
	 * @method animateIn
	 * @param {number} $elem
	 * @param {string} $mode
	 */
	animateIn( $elem, $mode = 'fade' ) {
		const outAction = 'fade-and-slide' === $mode
			? this.actions.fadeAndSlideOut : this.actions.fadeOut;
		const inAction = 'fade-and-slide' === $mode
			? this.actions.fadeAndSlideIn : this.actions.fadeIn;

		$elem.classList.remove( outAction );
		$elem.classList.add( inAction );
	},

	/**
	 * Apply proper css for animating out target $elem
	 *
	 * @method animateOut
	 * @param {number} $elem
	 * @param {string} $mode
	 */
	animateOut( $elem, $mode = 'fade' ) {
		const outAction = 'fade-and-slide' === $mode
			? this.actions.fadeAndSlideOut : this.actions.fadeOut;
		const inAction = 'fade-and-slide' === $mode
			? this.actions.fadeAndSlideIn : this.actions.fadeIn;

		$elem.classList.add( outAction );
		$elem.classList.remove( inAction );
	},

	/**
	 * Clear timeout of a timeout id when defined.
	 *
	 * @method clearTimeout
	 * @param {number} id - integer
	 */
	clearTimeout( id ) {
		if ( id ) {
			clearTimeout( id );
		}
	},

	/**
	 * Resets Social Nav/Main Nav Transitions
	 * Sometimes on iOS devices 'transitionTitleAndSocial' is not triggered when
	 * 'Back to Top' button is clicked
	 * This method is called in BackToTop.js to trigger this transition
	 *
	 * @method resetTransitions
	 *
	 */
	resetTransitions() {
		const $social = document.querySelector( this.selectors.targets.social );
		if ( $social ) {
			const socialBoundary = $social.getBoundingClientRect();
			this.transitionTitleAndSocial( socialBoundary, 'transitionTitleAndSocial', false, true );
		}
	},
};

export default ScrollingNav;