/*! * gumshoe v3.2.0: A simple, framework-agnostic scrollspy script. * (c) 2016 Chris Ferdinandi * MIT License * http://github.com/cferdinandi/gumshoe */ (function (root, factory) { if ( typeof define === 'function' && define.amd ) { define([], factory(root)); } else if ( typeof exports === 'object' ) { module.exports = factory(root); } else { root.gumshoe = factory(root); } })(typeof global !== 'undefined' ? global : this.window || this.global, function (root) { 'use strict'; // // Variables // var gumshoe = {}; // Object for public APIs var supports = 'querySelector' in document && 'addEventListener' in root && 'classList' in document.createElement('_'); // Feature test var navs = []; // Array for nav elements var settings, eventTimeout, docHeight, header, headerHeight, currentNav; // Default settings var defaults = { selector: '[data-gumshoe] a', selectorHeader: '[data-gumshoe-header]', container: root, offset: 0, activeClass: 'active', callback: function () {} }; // // Methods // /** * A simple forEach() implementation for Arrays, Objects and NodeLists. * @private * @author Todd Motto * @link https://github.com/toddmotto/foreach * @param {Array|Object|NodeList} collection Collection of items to iterate * @param {Function} callback Callback function for each iteration * @param {Array|Object|NodeList} scope Object/NodeList/Array that forEach is iterating over (aka `this`) */ var forEach = function ( collection, callback, scope ) { if ( Object.prototype.toString.call( collection ) === '[object Object]' ) { for ( var prop in collection ) { if ( Object.prototype.hasOwnProperty.call( collection, prop ) ) { callback.call( scope, collection[prop], prop, collection ); } } } else { for ( var i = 0, len = collection.length; i < len; i++ ) { callback.call( scope, collection[i], i, collection ); } } }; /** * Merge two or more objects. Returns a new object. * @private * @param {Boolean} deep If true, do a deep (or recursive) merge [optional] * @param {Object} objects The objects to merge together * @returns {Object} Merged values of defaults and options */ var extend = function () { // Variables var extended = {}; var deep = false; var i = 0; var length = arguments.length; // Check if a deep merge if ( Object.prototype.toString.call( arguments[0] ) === '[object Boolean]' ) { deep = arguments[0]; i++; } // Merge the object into the extended object var merge = function (obj) { for ( var prop in obj ) { if ( Object.prototype.hasOwnProperty.call( obj, prop ) ) { // If deep merge and property is an object, merge properties if ( deep && Object.prototype.toString.call(obj[prop]) === '[object Object]' ) { extended[prop] = extend( true, extended[prop], obj[prop] ); } else { extended[prop] = obj[prop]; } } } }; // Loop through each object and conduct a merge for ( ; i < length; i++ ) { var obj = arguments[i]; merge(obj); } return extended; }; /** * Get the height of an element. * @private * @param {Node} elem The element to get the height of * @return {Number} The element's height in pixels */ var getHeight = function ( elem ) { return Math.max( elem.scrollHeight, elem.offsetHeight, elem.clientHeight ); }; /** * Get the document element's height * @private * @returns {Number} */ var getDocumentHeight = function () { return Math.max( document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight ); }; /** * Get an element's distance from the top of the Document. * @private * @param {Node} elem The element * @return {Number} Distance from the top in pixels */ var getOffsetTop = function ( elem ) { var location = 0; if (elem.offsetParent) { do { location += elem.offsetTop; elem = elem.offsetParent; } while (elem); } else { location = elem.offsetTop; } location = location - headerHeight - settings.offset; return location >= 0 ? location : 0; }; /** * Determine if an element is in the viewport * @param {Node} elem The element * @return {Boolean} Returns true if element is in the viewport */ var isInViewport = function ( elem ) { var distance = elem.getBoundingClientRect(); return ( distance.top >= 0 && distance.left >= 0 && distance.bottom <= (window.innerHeight || document.documentElement.clientHeight) && distance.right <= (window.innerWidth || document.documentElement.clientWidth) ); }; /** * Arrange nagivation elements from furthest from the top to closest * @private */ var sortNavs = function () { navs.sort( function (a, b) { if (a.distance > b.distance) { return -1; } if (a.distance < b.distance) { return 1; } return 0; }); }; /** * Calculate the distance of elements from the top of the document * @public */ gumshoe.setDistances = function () { // Calculate distances docHeight = getDocumentHeight(); // The document headerHeight = header ? ( getHeight(header) + getOffsetTop(header) ) : 0; // The fixed header forEach(navs, function (nav) { nav.distance = getOffsetTop(nav.target); // Each navigation target }); // When done, organization navigation elements sortNavs(); }; /** * Get all navigation elements and store them in an array * @private */ var getNavs = function () { // Get all navigation links var navLinks = document.querySelectorAll( settings.selector ); // For each link, create an object of attributes and push to an array forEach( navLinks, function (nav) { if ( !nav.hash ) return; var target = document.querySelector( nav.hash ); if ( !target ) return; navs.push({ nav: nav, target: target, parent: nav.parentNode.tagName.toLowerCase() === 'li' ? nav.parentNode : null, distance: 0 }); }); }; /** * Remove the activation class from the currently active navigation element * @private */ var deactivateCurrentNav = function () { if ( currentNav ) { currentNav.nav.classList.remove( settings.activeClass ); if ( currentNav.parent ) { currentNav.parent.classList.remove( settings.activeClass ); } } }; /** * Add the activation class to the currently active navigation element * @private * @param {Node} nav The currently active nav */ var activateNav = function ( nav ) { // If a current Nav is set, deactivate it deactivateCurrentNav(); // Activate the current target's navigation element nav.nav.classList.add( settings.activeClass ); if ( nav.parent ) { nav.parent.classList.add( settings.activeClass ); } settings.callback( nav ); // Callback after methods are run // Set new currentNav currentNav = { nav: nav.nav, parent: nav.parent }; }; /** * Determine which navigation element is currently active and run activation method * @public * @returns {Object} The current nav data. */ gumshoe.getCurrentNav = function () { // Get current position from top of the document var position = root.pageYOffset; // If at the bottom of the page and last section is in the viewport, activate the last nav if ( (root.innerHeight + position) >= docHeight && isInViewport( navs[0].target ) ) { activateNav( navs[0] ); return navs[0]; } // Otherwise, loop through each nav until you find the active one for (var i = 0, len = navs.length; i < len; i++) { var nav = navs[i]; if ( nav.distance <= position ) { activateNav( nav ); return nav; } } // If no active nav is found, deactivate the current nav deactivateCurrentNav(); settings.callback(); }; /** * If nav element has active class on load, set it as currently active navigation * @private */ var setInitCurrentNav = function () { forEach(navs, function (nav) { if ( nav.nav.classList.contains( settings.activeClass ) ) { currentNav = { nav: nav.nav, parent: nav.parent }; } }); }; /** * Destroy the current initialization. * @public */ gumshoe.destroy = function () { // If plugin isn't already initialized, stop if ( !settings ) return; // Remove event listeners settings.container.removeEventListener('resize', eventThrottler, false); settings.container.removeEventListener('scroll', eventThrottler, false); // Reset variables navs = []; settings = null; eventTimeout = null; docHeight = null; header = null; headerHeight = null; currentNav = null; }; /** * On window scroll and resize, only run events at a rate of 15fps for better performance * @private * @param {Function} eventTimeout Timeout function * @param {Object} settings */ var eventThrottler = function (event) { if ( !eventTimeout ) { eventTimeout = setTimeout(function() { eventTimeout = null; // Reset timeout // If scroll event, get currently active nav if ( event.type === 'scroll' ) { gumshoe.getCurrentNav(); } // If resize event, recalculate distances and then get currently active nav if ( event.type === 'resize' ) { gumshoe.setDistances(); gumshoe.getCurrentNav(); } }, 66); } }; /** * Initialize Plugin * @public * @param {Object} options User settings */ gumshoe.init = function ( options ) { // feature test if ( !supports ) return; // Destroy any existing initializations gumshoe.destroy(); // Set variables settings = extend( defaults, options || {} ); // Merge user options with defaults header = document.querySelector( settings.selectorHeader ); // Get fixed header getNavs(); // Get navigation elements // If no navigation elements exist, stop running gumshoe if ( navs.length === 0 ) return; // Run init methods setInitCurrentNav(); gumshoe.setDistances(); gumshoe.getCurrentNav(); // Listen for events settings.container.addEventListener('resize', eventThrottler, false); settings.container.addEventListener('scroll', eventThrottler, false); }; // // Public APIs // return gumshoe; });