UNPKG

smoothscroll-anchor-polyfill

Version:

Apply smooth scroll to anchor links to replicate CSS scroll-behavior

341 lines (271 loc) 12.3 kB
(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(); });