UNPKG

@contentpilot/sticky-navigation

Version:

Add identifiers and activate the anchors that have these identifier and are intercepted with the scroll.

322 lines (279 loc) 9.5 kB
/** * Add identifiers and activate the anchors that have these identifier * and are intercepted with the scroll. It needs the proper markup. * @version 1.7.2 * @package * @author Content Pilot, Dallas, Texas * @copyright Copyright © 2018 Content Pilot */ /** * Initialize the function and execute the methods. * @param {Array | undefined} options Custom options object */ exports.init = function (options) { var polyfillURL = 'https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js?features=NodeList.prototype.forEach,IntersectionObserver,smoothscroll'; if (_isInternetExplorer()) { /** * The functions required for this script not available in IE * are loaded through the Polyfill.io service. */ _loadScript(polyfillURL, options); } else { /** * Starts the call stack as it is a modern browser. */ _callStack(options); } }; /** * Check if the current browser is Internet Explorer. * @returns {boolean} - True if is IE */ function _isInternetExplorer() { var ua = window.navigator.userAgent; var ie = ua.indexOf('MSIE '); return ie > 0 || !!navigator.userAgent.match(/Trident.*rv\:11\./); } /** * Load the script and add it in the header and then start the functions. * @param {string} src - Script source * @param {Array | undefined} options Custom options object */ function _loadScript(src, options) { var js = document.createElement('script'); js.src = src; js.onload = function () { _callStack(options); }; js.onerror = function () { new Error('Failed to load script ' + src); }; document.head.appendChild(js); } /** * Call stack once browser support was verified. * @param {Array | undefined} options Custom options object */ function _callStack(options) { options = options || {}; options = _applyRemainingDefaultOptions(options); if (null !== document.querySelector(options.clause)) { _addScrollBehavior(options.offsetAnchor, options.offsetElement); const elements = _getElements(options.identifiers); if (elements.length === 0) { return; } _addIdentifiers(elements); _addIntersectionObserver( options.interceptors, options.inactiveUpperZone, options.interceptionPercentage ); } } /** * Assigns options to the internal options object, and provides defaults. * @param {object} opts - Options object * @returns {object} - Full options object */ function _applyRemainingDefaultOptions(opts) { opts.clause = opts.hasOwnProperty('clause') ? opts.clause : '.sticky-navigation'; opts.identifiers = opts.hasOwnProperty('identifiers') ? opts.identifiers : 'h2, h3, h4, h5, h6'; opts.interceptors = opts.hasOwnProperty('interceptors') ? opts.interceptors : 'h2[id], h3[id], h4[id], h5[id], h6[id]'; opts.offsetAnchor = opts.hasOwnProperty('offsetAnchor') ? opts.offsetAnchor : 0; opts.offsetElement = opts.hasOwnProperty('offsetElement') ? opts.offsetElement : ''; opts.inactiveUpperZone = opts.hasOwnProperty('inactiveUpperZone') ? opts.inactiveUpperZone : 0; opts.interceptionPercentage = opts.hasOwnProperty('interceptionPercentage') ? 100 - opts.interceptionPercentage : 80; return opts; } /** * Add the ID attribute to each of the elements. * @param {Array} elements - Array containing the elements. */ function _addIdentifiers(elements) { let index = undefined; // We produce a list of existing IDs so we don't generate a duplicate. elsWithIds = document.querySelectorAll('[id]'); idList = [].map.call(elsWithIds, function assign(el) { return el.id; }); for (i = 0; i < elements.length; i++) { if (elements[i].hasAttribute('id')) { elementID = elements[i].getAttribute('id'); } else { tidyText = _urlify(elements[i].textContent); // Compare our generated ID to existing IDs (and increment it if needed) // before we add it to the page. newTidyText = tidyText; count = 0; do { if (index !== undefined) { newTidyText = tidyText + '-' + count; } index = idList.indexOf(newTidyText); count += 1; } while (index !== -1); index = undefined; idList.push(newTidyText); elements[i].setAttribute('id', newTidyText); elementID = newTidyText; } } } /** * Turns a selector, nodeList, or array of elements into an array of elements (so we can use array methods). * It also throws errors on any other inputs. Used to handle inputs to .add and .remove. * @param {string | Array } input - A CSS selector string targeting elements with anchor links, * OR a nodeList / array containing the DOM elements. * @returns {Array} - An array containing the elements we want. */ function _getElements(input) { var elements; if (typeof input === 'string' || input instanceof String) { // See https://davidwalsh.name/nodelist-array for the technique transforming nodeList -> Array. elements = [].slice.call(document.querySelectorAll(input)); // I checked the 'input instanceof NodeList' test in IE9 and modern browsers and it worked for me. } else if (Array.isArray(input) || input instanceof NodeList) { elements = [].slice.call(input); } else { throw new Error('The selector provided was invalid.'); } return elements; } /** * Get the element full height including margins. * @param {string} selector - A CSS selector string. * @returns {number} - The element height including margins. */ function _getElementFullHeight(selector) { const element = document.querySelector(selector); if (!element) { return 0; } const height = element.offsetHeight; const styles = window.getComputedStyle(element); const margin = parseInt(styles.getPropertyValue('margin-top')) + parseInt(styles.getPropertyValue('margin-bottom')); return (height + margin); } /** * Add smooth scroll behavior to the proper anchor. * @param {number} offsetFixed - Offset distance when scrolling. * @param {string} offsetElement - The element to calculate the offset from. */ function _addScrollBehavior(offsetFixed, offsetElement) { document.querySelectorAll('a[href^="#"]').forEach(function (anchor) { anchor.addEventListener('click', function (e) { e.preventDefault(); const id = this.getAttribute('href').replace('#', ''); const rect = document.getElementById(id).getBoundingClientRect(); // Position of element relative to screen + Window scrolling position. const top = rect.top + window.scrollY; // Fixed offset or offset element height including margins. const offset = offsetElement ? _getElementFullHeight(offsetElement) : offsetFixed; window.location.hash = id; window.scroll({ top: top - offset, left: 0, behavior: 'smooth', }); }); }); } /** * Urlify - Refine text so it makes a good ID. * * To do this, we remove apostrophes, replace non-safe characters with hyphens, * remove extra hyphens, truncate, trim hyphens, and make lowercase. * @param {string} text - Any text. Pulled from the webpage element we are linking to. * @returns {string} - hyphen-delimited text for use in IDs and URLs. */ function _urlify(text) { // Regex for finding the non-safe URL characters (many need escaping): & +$,:;=?@"#{}|^~[`%!'<>]./()*\ (newlines, tabs, backspace, & vertical tabs) var nonsafeChars = /[& +$,:;=?@"#{}|^~[`%!'<>\]\.\/\(\)\*\\\n\t\b\v]/g, urlText; // Note: we trim hyphens after truncating because truncating can cause dangling hyphens. urlText = text .trim() .replace(/\'/gi, '') .replace(nonsafeChars, '-') .replace(/-{2,}/g, '-') .substring(0, 64) .replace(/^-+|-+$/gm, '') .toLowerCase(); return urlText; } /** * Watch at the intersection with the elements and add or remove the active class. * @param {string} observables - Observable elements selector. * @param {string} inactiveUpperZone - Distance in pixels from the top where items are not activated. * @param {string} interceptionPercentage - Distance in percentage from the top for the items to activate. */ function _addIntersectionObserver( observables, inactiveUpperZone, interceptionPercentage ) { const rootMargin = `-${inactiveUpperZone}px 0px -${interceptionPercentage}% 0px`; window.addEventListener('DOMContentLoaded', function () { const observer = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { const id = entry.target.getAttribute('id'); const anchor = document.querySelector( 'nav li a[href="#' + id + '"]' ); if (anchor) { if (entry.isIntersecting) { _removeActiveClass(anchor); _addActiveClass(anchor); } } }); }, { rootMargin: rootMargin } ); // Track all the elements defined as observables that have an `id` applied document.querySelectorAll(observables).forEach(function (observable) { observer.observe(observable); }); }); } /** * Removes the active class from all elements and adds it to the current node. * @param {object} node - Current node. */ function _addActiveClass(node) { if (node) { const activeElements = node.parentElement.parentElement.querySelectorAll( '.current-menu-item' ); activeElements.forEach(function (element) { element.classList.remove('current-menu-item'); }); node.parentElement.classList.add('current-menu-item'); } } /** * Removes the active class from the current node. * @param {object} node - Current node. */ function _removeActiveClass(node) { if (node) { node.parentElement.classList.remove('current-menu-item'); } }