@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
JavaScript
/**
* 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');
}
}