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;