smoothscroll-anchor-polyfill
Version:
Apply smooth scroll to anchor links to replicate CSS scroll-behavior
341 lines (271 loc) • 12.3 kB
JavaScript
(function (global, factory) {
if (typeof define === "function" && define.amd) {
define("SmoothscrollAnchorPolyfill", ["exports"], factory);
} else if (typeof exports !== "undefined") {
factory(exports);
} else {
var mod = {
exports: {}
};
factory(mod.exports);
global.SmoothscrollAnchorPolyfill = mod.exports;
}
})(typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : this, function (_exports) {
"use strict";
Object.defineProperty(_exports, "__esModule", {
value: true
});
_exports.default = void 0;
_exports.destroy = destroy;
_exports.polyfill = polyfill;
/** @license MIT smoothscroll-anchor-polyfill@1.3.4 (c) 2021 Jonas Kuske */
// @ts-check
/**
* @typedef {Object} GlobalFlag
* @prop {boolean} [__forceSmoothscrollAnchorPolyfill__]
* **DEPRECATED**: use `polyfill({ force: boolean })`
*
* @typedef {typeof globalThis & Window & GlobalFlag} WindowWithFlag
*/
/***/
var isBrowser = typeof window !== 'undefined';
var w = isBrowser &&
/** @type {WindowWithFlag} */
window;
var d = isBrowser && document;
var docEl = isBrowser && d.documentElement;
var mockEl = isBrowser && d.createElement('a');
var hasNativeSupport = function hasNativeSupport() {
return isBrowser && 'scrollBehavior' in mockEl;
};
/**
* @param {HTMLElement} el
* @returns {el is HTMLAnchorElement}
*/
var isAnchor = function isAnchor(el) {
return /^a$/i.test(el.tagName);
};
/**
* Check if an element is an anchor pointing to a target on the current page
* @param {HTMLAnchorElement} anchor
*/
var targetsLocalElement = function targetsLocalElement(anchor) {
// False if element isn't "a" or href has no #fragment
if (!/#/.test(anchor.href)) return false; // Fix bug in IE9 where anchor.pathname misses leading slash
var pathname =
/** @type {HTMLAnchorElement} */
anchor.pathname;
if (pathname[0] !== '/') pathname = '/' + pathname; // False if target isn't current page
if (anchor.hostname !== location.hostname || pathname !== location.pathname) return false; // False if anchor targets a ?query that is different from the current one
// e.g. /?page=1 → /?page=2#content
if (anchor.search && anchor.search !== location.search) return false;
return true;
};
/**
* @param {HTMLElement} el
* @returns {?HTMLAnchorElement} The found element or null
*/
var getAnchor = function getAnchor(el) {
if (isAnchor(el) && targetsLocalElement(el)) return el;
return el.parentElement ? getAnchor(el.parentElement) : null;
};
/**
* Returns the element whose id matches the hash or
* document.body if the hash is "#top" or "" (empty string)
* @param {string} hash
*/
var getScrollTarget = function getScrollTarget(hash) {
if (typeof hash !== 'string') return null;
try {
hash = decodeURIComponent(hash); // "#%F0%9F%91%8D%F0%9F%8F%BB" -> "#👍🏻"
} catch (_unused) {} // Retrieve target if an id is specified in the hash, otherwise use body.
// If hash is "#top" and no target with id "top" was found, also use body
// See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-href
var target = hash ? d.getElementById(hash.slice(1)) : d.body;
if (hash === '#top' && !target) target = d.body;
return target;
};
/**
* Focuses an element, if it's not focused after the first try,
* allow focusing by adjusting tabIndex and retry
* @param {HTMLElement} el
*/
var focusElement = function focusElement(el) {
var focusOptions = {
preventScroll: true
};
el.focus(focusOptions);
if (d.activeElement !== el) {
var prevTabIndex = el.getAttribute('tabindex');
el.setAttribute('tabindex', '-1');
if (getComputedStyle(el).outlineStyle === 'none') {
var prevOutline = el.style.outlineStyle;
el.style.outlineStyle = 'none';
el.addEventListener('blur', function undoOutlineChange() {
el.style.outlineStyle = prevOutline || '';
prevTabIndex != null ? el.setAttribute('tabindex', prevTabIndex) : el.removeAttribute('tabindex');
el.removeEventListener('blur', undoOutlineChange);
});
}
el.focus(focusOptions);
}
}; // Stores the setTimeout id of pending focus changes, allows aborting them
var pendingFocusChange; // Check if browser supports focus without automatic scrolling (preventScroll)
var supportsPreventScroll = false;
if (isBrowser) {
try {
// Define getter for preventScroll to find out if the browser accesses it
var preppedFocusOption = Object.defineProperty({}, 'preventScroll', {
// eslint-disable-next-line getter-return
get: function get() {
supportsPreventScroll = true;
}
}); // Trigger focus – if browser uses preventScroll the const will be set to true
mockEl.focus(preppedFocusOption);
} catch (_unused2) {}
}
/**
* Scrolls to a given element or to the top if the given element
* is document.body, then focuses the element
* @param {HTMLElement} target
*/
var triggerSmoothscroll = function triggerSmoothscroll(target) {
// Clear potential pending focus change triggered by a previous scroll
if (!supportsPreventScroll) clearTimeout(pendingFocusChange); // Use JS scroll APIs to scroll to top (if target is body) or to the element
// This allows polyfills for these APIs to do their smooth scrolling magic
var scrollTop = target === d.body;
if (scrollTop) w.scroll({
top: 0,
left: 0,
behavior: 'smooth'
});else target.scrollIntoView({
behavior: 'smooth',
block: 'start'
}); // If the browser supports preventScroll: immediately focus the target
// Otherwise schedule the focus so the smoothscroll isn't interrupted
if (supportsPreventScroll) focusElement(target);else pendingFocusChange = setTimeout(focusElement.bind(null, target), 450);
};
/**
* Returns true if scroll-behavior: smooth is set and not overwritten
* by a higher-specifity declaration, else returns false
*/
var shouldSmoothscroll = function shouldSmoothscroll() {
// Regex to extract the value following the scroll-behavior property name
var extractValue = /scroll-behavior:[\s]*([^;"']+)/;
var docElStyle = getComputedStyle(docEl); // Values to check for set scroll-behavior in order of priority/specificity
var valuesToCheck = [// Priority 1: behavior assigned to style property
// Allows toggling smoothscroll from JS (docEl.style.scrollBehavior = ...)
docEl.style.scrollBehavior, // Priority 2: behavior specified inline in style attribute
(extractValue.exec(docEl.getAttribute('style')) || [])[1], // Priority 3: custom property
// Behaves like regular CSS, e.g. allows using media queries
docElStyle.getPropertyValue('--scroll-behavior'), // Priority 4: behavior specified in fontFamily
// Same use case as priority 3, but supports legacy browsers without CSS vars
(extractValue.exec(docElStyle.fontFamily) || [])[1]]; // Loop over values in specified order, return once a valid value is found
for (var i = 0; i < valuesToCheck.length; i++) {
var value = valuesToCheck[i] && valuesToCheck[i].trim();
if (/^smooth$/.test(value)) return true;
if (/^(initial|inherit|auto|unset)$/.test(value)) return false;
} // No value found? Return false, no set value = no smoothscroll :(
return false;
}; // @ts-check
var defaultExport = {
polyfill: polyfill,
destroy: destroy
};
/**
* Starts the polyfill by attaching the neccessary EventListeners
*
* Bails out if scrollBehavior is natively supported and the force flag
* isn't set on the options argument or globally on window
* @param {PolyfillOptions} [opts] Options for invoking the polyfill
*
* @typedef {Object} PolyfillOptions
* @prop {boolean} [force] Enable despite native support, overrides global flag
*/
_exports.default = defaultExport;
function polyfill() {
var opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
destroy(); // Remove previous listeners
if (isBrowser) {
var globalFlag = w.__forceSmoothscrollAnchorPolyfill__;
var force = typeof opts.force === 'boolean' ? opts.force : globalFlag; // Abort if smoothscroll has native support and force flag isn't set
if (hasNativeSupport() && !force) return;
d.addEventListener('click', handleClick, false);
d.addEventListener('scroll', trackScrollPositions);
w.addEventListener('hashchange', handleHashChange);
}
return defaultExport;
}
/** Stops the polyfill by removing all EventListeners */
function destroy() {
if (isBrowser) {
d.removeEventListener('click', handleClick, false);
d.removeEventListener('scroll', trackScrollPositions);
w.removeEventListener('hashchange', handleHashChange);
}
return defaultExport;
}
/**
* Checks if the clicked target is an anchor pointing to a local element.
* If so, prevents default behavior and handles the scroll using the
* native JavaScript scroll APIs so smoothscroll-polyfill applies
* @param {MouseEvent} evt
*/
function handleClick(evt) {
var notPrimaryClick = evt.metaKey || evt.ctrlKey || evt.shiftKey || evt.button !== 0;
if (evt.defaultPrevented || notPrimaryClick) return; // scroll-behavior not set to smooth? Bail out, let browser handle it
if (!shouldSmoothscroll()) return; // Check the DOM from the click target upwards if a local anchor was clicked
var anchor = getAnchor(
/** @type {HTMLElement} */
evt.target);
if (!anchor) return; // Find the element targeted by the hash
var target = getScrollTarget(anchor.hash);
if (target) {
// Prevent default browser behavior to avoid a jump to the anchor target
evt.preventDefault(); // Trigger the smooth scroll
triggerSmoothscroll(target); // Append the hash to the URL
if (history.pushState) history.pushState(null, d.title, anchor.href);
}
} // To enable smooth scrolling on hashchange, we need to immediately restore
// the scroll pos after a hashchange changed it, so we track it constantly.
// Some browsers don't trigger a scroll event before the hashchange,
// so to undo, the position from last scroll is the one we need to go back to.
// In others (e.g. IE) the scroll listener is triggered again before the
// hashchange occurs and the last reported position is already the new one
// updated by the hashchange – we need the second last to undo there.
// Because of this we don't track just the last, but the last two positions.
var lastTwoScrollPos = [];
/** Returns the scroll offset towards the top */
var getScrollTop = function getScrollTop() {
return docEl.scrollTop || d.body.scrollTop;
};
/**
* Tries to undo the automatic, instant scroll caused by a hashchange
* and instead scrolls smoothly to the new hash target
*/
function handleHashChange() {
// scroll-behavior not set to smooth or body not parsed yet? Abort
if (!d.body || !shouldSmoothscroll()) return;
var target = getScrollTarget(location.hash);
if (!target) return; // If the position last reported by the scroll listener is the same as the
// current one caused by a hashchange, go back to second last – else last
var currentPos = getScrollTop();
var top = lastTwoScrollPos[lastTwoScrollPos[1] === currentPos ? 0 : 1]; // @ts-ignore
// Undo the scroll caused by the hashchange...
// Using {behavior: 'instant'} even though it's not in the spec anymore as
// Blink & Gecko support it – once an engine with native support doesn't,
// we need to disable scroll-behavior during scroll reset, then restore
w.scroll({
top: top,
behavior: 'instant'
}); // ...and instead smoothscroll to the target
triggerSmoothscroll(target);
}
/** Update the last two scroll positions */
function trackScrollPositions() {
if (!d.body) return; // Body not parsed yet? Abort
lastTwoScrollPos[0] = lastTwoScrollPos[1];
lastTwoScrollPos[1] = getScrollTop();
}
polyfill();
});