/*! * gumshoejs v5.1.1 * A simple, framework-agnostic scrollspy script. * (c) 2019 Chris Ferdinandi * MIT License * http://github.com/cferdinandi/gumshoe */ (function (root, factory) { if ( typeof define === 'function' && define.amd ) { define([], (function () { return factory(root); })); } else if ( typeof exports === 'object' ) { module.exports = factory(root); } else { root.Gumshoe = factory(root); } })(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this, (function (window) { 'use strict'; // // Defaults // var defaults = { // Active classes navClass: 'active', contentClass: 'active', // Nested navigation nested: false, nestedClass: 'active', // Offset & reflow offset: 0, reflow: false, // Event support events: true }; // // Methods // /** * Merge two or more objects together. * @param {Object} objects The objects to merge together * @returns {Object} Merged values of defaults and options */ var extend = function () { var merged = {}; Array.prototype.forEach.call(arguments, (function (obj) { for (var key in obj) { if (!obj.hasOwnProperty(key)) return; merged[key] = obj[key]; } })); return merged; }; /** * Emit a custom event * @param {String} type The event type * @param {Node} elem The element to attach the event to * @param {Object} detail Any details to pass along with the event */ var emitEvent = function (type, elem, detail) { // Make sure events are enabled if (!detail.settings.events) return; // Create a new event var event = new CustomEvent(type, { bubbles: true, cancelable: true, detail: detail }); // Dispatch the event elem.dispatchEvent(event); }; /** * Get an element's distance from the top of the Document. * @param {Node} elem The element * @return {Number} Distance from the top in pixels */ var getOffsetTop = function (elem) { var location = 0; if (elem.offsetParent) { while (elem) { location += elem.offsetTop; elem = elem.offsetParent; } } return location >= 0 ? location : 0; }; /** * Sort content from first to last in the DOM * @param {Array} contents The content areas */ var sortContents = function (contents) { if(contents) { contents.sort((function (item1, item2) { var offset1 = getOffsetTop(item1.content); var offset2 = getOffsetTop(item2.content); if (offset1 < offset2) return -1; return 1; })); } }; /** * Get the offset to use for calculating position * @param {Object} settings The settings for this instantiation * @return {Float} The number of pixels to offset the calculations */ var getOffset = function (settings) { // if the offset is a function run it if (typeof settings.offset === 'function') { return parseFloat(settings.offset()); } // Otherwise, return it as-is return parseFloat(settings.offset); }; /** * 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 ); }; /** * Determine if an element is in view * @param {Node} elem The element * @param {Object} settings The settings for this instantiation * @param {Boolean} bottom If true, check if element is above bottom of viewport instead * @return {Boolean} Returns true if element is in the viewport */ var isInView = function (elem, settings, bottom) { var bounds = elem.getBoundingClientRect(); var offset = getOffset(settings); if (bottom) { return parseInt(bounds.bottom, 10) < (window.innerHeight || document.documentElement.clientHeight); } return parseInt(bounds.top, 10) <= offset; }; /** * Check if at the bottom of the viewport * @return {Boolean} If true, page is at the bottom of the viewport */ var isAtBottom = function () { if (window.innerHeight + window.pageYOffset >= getDocumentHeight()) return true; return false; }; /** * Check if the last item should be used (even if not at the top of the page) * @param {Object} item The last item * @param {Object} settings The settings for this instantiation * @return {Boolean} If true, use the last item */ var useLastItem = function (item, settings) { if (isAtBottom() && isInView(item.content, settings, true)) return true; return false; }; /** * Get the active content * @param {Array} contents The content areas * @param {Object} settings The settings for this instantiation * @return {Object} The content area and matching navigation link */ var getActive = function (contents, settings) { var last = contents[contents.length-1]; if (useLastItem(last, settings)) return last; for (var i = contents.length - 1; i >= 0; i--) { if (isInView(contents[i].content, settings)) return contents[i]; } }; /** * Deactivate parent navs in a nested navigation * @param {Node} nav The starting navigation element * @param {Object} settings The settings for this instantiation */ var deactivateNested = function (nav, settings) { // If nesting isn't activated, bail if (!settings.nested) return; // Get the parent navigation var li = nav.parentNode.closest('li'); if (!li) return; // Remove the active class li.classList.remove(settings.nestedClass); // Apply recursively to any parent navigation elements deactivateNested(li, settings); }; /** * Deactivate a nav and content area * @param {Object} items The nav item and content to deactivate * @param {Object} settings The settings for this instantiation */ var deactivate = function (items, settings) { // Make sure their are items to deactivate if (!items) return; // Get the parent list item var li = items.nav.closest('li'); if (!li) return; // Remove the active class from the nav and content li.classList.remove(settings.navClass); items.content.classList.remove(settings.contentClass); // Deactivate any parent navs in a nested navigation deactivateNested(li, settings); // Emit a custom event emitEvent('gumshoeDeactivate', li, { link: items.nav, content: items.content, settings: settings }); }; /** * Activate parent navs in a nested navigation * @param {Node} nav The starting navigation element * @param {Object} settings The settings for this instantiation */ var activateNested = function (nav, settings) { // If nesting isn't activated, bail if (!settings.nested) return; // Get the parent navigation var li = nav.parentNode.closest('li'); if (!li) return; // Add the active class li.classList.add(settings.nestedClass); // Apply recursively to any parent navigation elements activateNested(li, settings); }; /** * Activate a nav and content area * @param {Object} items The nav item and content to activate * @param {Object} settings The settings for this instantiation */ var activate = function (items, settings) { // Make sure their are items to activate if (!items) return; // Get the parent list item var li = items.nav.closest('li'); if (!li) return; // Add the active class to the nav and content li.classList.add(settings.navClass); items.content.classList.add(settings.contentClass); // Activate any parent navs in a nested navigation activateNested(li, settings); // Emit a custom event emitEvent('gumshoeActivate', li, { link: items.nav, content: items.content, settings: settings }); }; /** * Create the Constructor object * @param {String} selector The selector to use for navigation items * @param {Object} options User options and settings */ var Constructor = function (selector, options) { // // Variables // var publicAPIs = {}; var navItems, contents, current, timeout, settings; // // Methods // /** * Set variables from DOM elements */ publicAPIs.setup = function () { // Get all nav items navItems = document.querySelectorAll(selector); // Create contents array contents = []; // Loop through each item, get it's matching content, and push to the array Array.prototype.forEach.call(navItems, (function (item) { // Get the content for the nav item var content = document.getElementById(decodeURIComponent(item.hash.substr(1))); if (!content) return; // Push to the contents array contents.push({ nav: item, content: content }); })); // Sort contents by the order they appear in the DOM sortContents(contents); }; /** * Detect which content is currently active */ publicAPIs.detect = function () { // Get the active content var active = getActive(contents, settings); // if there's no active content, deactivate and bail if (!active) { if (current) { deactivate(current, settings); current = null; } return; } // If the active content is the one currently active, do nothing if (current && active.content === current.content) return; // Deactivate the current content and activate the new content deactivate(current, settings); activate(active, settings); // Update the currently active content current = active; }; /** * Detect the active content on scroll * Debounced for performance */ var scrollHandler = function (event) { // If there's a timer, cancel it if (timeout) { window.cancelAnimationFrame(timeout); } // Setup debounce callback timeout = window.requestAnimationFrame(publicAPIs.detect); }; /** * Update content sorting on resize * Debounced for performance */ var resizeHandler = function (event) { // If there's a timer, cancel it if (timeout) { window.cancelAnimationFrame(timeout); } // Setup debounce callback timeout = window.requestAnimationFrame((function () { sortContents(contents); publicAPIs.detect(); })); }; /** * Destroy the current instantiation */ publicAPIs.destroy = function () { // Undo DOM changes if (current) { deactivate(current, settings); } // Remove event listeners window.removeEventListener('scroll', scrollHandler, false); if (settings.reflow) { window.removeEventListener('resize', resizeHandler, false); } // Reset variables contents = null; navItems = null; current = null; timeout = null; settings = null; }; /** * Initialize the current instantiation */ var init = function () { // Merge user options into defaults settings = extend(defaults, options || {}); // Setup variables based on the current DOM publicAPIs.setup(); // Find the currently active content publicAPIs.detect(); // Setup event listeners window.addEventListener('scroll', scrollHandler, false); if (settings.reflow) { window.addEventListener('resize', resizeHandler, false); } }; // // Initialize and return the public APIs // init(); return publicAPIs; }; // // Return the Constructor // return Constructor; }));