UNPKG

baseframe-js

Version:

A suite of useful Javascript plugins and functions to help with Front-end Development on websites

227 lines (226 loc) 8.65 kB
import { getDataOptions, isVisible } from "./util/helpers"; import $ from 'cash-dom'; import Store from "./core/Store"; import UrlState from "./core/UrlState"; const VERSION = "1.0.1"; const DATA_NAME = 'ScrollSpy'; const EVENT_NAME = 'scrollSpy'; const DEFAULTS = { observerOptions: { rootMargin: "0px", threshold: 1 }, cssPrefix: 'scroll-spy', spyNavElems: 'a', spyBody: '.scroll-spy-body', spyElems: 'h2', setActiveCssToLi: true, locationFilter: null, urlFilterType: 'hash', historyType: 'push', loadLocation: true }; export default class ScrollSpy { #pairedElems = new Map(); inViewEntries = new Set(); backUpInViewEntry; #lastSpyElem = null; #observer = null; params; element; $spyBody; spyContents; static defaults = DEFAULTS; static version = VERSION; static pluginName = DATA_NAME; constructor(element, options) { const s = this; const dataOptions = getDataOptions(element, EVENT_NAME); s.element = element; s.params = $.extend({}, ScrollSpy.defaults, options, dataOptions); s.$spyBody = $(s.params.spyBody); s.spyContents = Array.from(s.$spyBody.find(s.params.spyElems)).filter((el) => isVisible(el) && el.id); if (s.spyContents.length > 0) { $(element).addClass(`${s.params.cssPrefix}-nav`); s.$spyBody.addClass(`${s.params.cssPrefix}-body`); s.#lastSpyElem = s.spyContents[s.spyContents.length - 1]; s.backUpInViewEntry = s.#getInitialBackUpInViewEntry(); s.#pairElementsToNavEls(); s.handleEvents(); } else { console.warn(`there is nothing to spy, check your 'spyBody' for those elements`, s.params.spyBody); } return s; } handleEvents() { const s = this; const p = s.params; const observerProcess = (entries) => { s.#setInViewEntries(entries); s.#spyElements(); }; s.loadFromUrl(); s.#observer = new IntersectionObserver(observerProcess, p.observerOptions); for (const elem of s.spyContents) { s.#observer.observe(elem); } $(s.element).on(`click.${EVENT_NAME}`, p.spyNavElems, function (e) { const clickElem = this; const paramVal = (this.nodeName === 'A' ? this.hash : clickElem.dataset.hash).replace(/^\#/, ''); const $bodyElem = s.scrollToElement(paramVal, 'smooth'); if ($bodyElem.length) { if (typeof p.urlFilterType === 'string') { if (p.locationFilter) { if (p.urlFilterType === 'hashVal') { UrlState.setHashVal(paramVal, p.historyType); } else if (p.urlFilterType === 'hash' || p.urlFilterType === 'search') { UrlState.set(p.urlFilterType, p.locationFilter, paramVal, p.historyType); } } } e.preventDefault(); } }); } scrollToElement(elemId, behavior) { const s = this; const p = s.params; const scrollRoot = p.observerOptions.root || window; const observerOffsetTop = parseFloat(p.observerOptions.rootMargin.split(' ')[0]) || 0; const $bodyElem = $(elemId ? '#' + elemId : document.body); if ($bodyElem.length) { scrollRoot.scrollTo({ top: $bodyElem.offset().top - observerOffsetTop, behavior }); } return $bodyElem; } loadFromUrl() { const s = this; const p = s.params; if (p.locationFilter !== null && p.loadLocation && p.urlFilterType !== 'none') { const spyId = UrlState.get(p.urlFilterType, p.locationFilter); if (spyId) { s.scrollToElement(spyId, 'instant'); } } } #getInitialBackUpInViewEntry() { const s = this; const sortedByTop = s.spyContents .map(el => ({ el, top: el.getBoundingClientRect().top })) .sort((a, b) => a.top < b.top ? -1 : 1); for (let i = 0, l = sortedByTop.length; i < l; i++) { const curr = sortedByTop[i]; const prev = sortedByTop[i - 1]; if (curr.top >= 0) { if (prev) { return prev.el; } return curr.el; } } } #pairElementsToNavEls() { const s = this; const { spyNavElems, setActiveCssToLi } = s.params; $(s.element).find(spyNavElems).each(function () { const clickEl = this; const hash = this.nodeName === 'A' ? clickEl.hash : clickEl.dataset.hash; if (hash) { const foundElem = s.spyContents.find((el) => el.id && el.id === hash.replace('#', '')); if (foundElem) { const pairedElem = setActiveCssToLi ? (clickEl.closest('li') || clickEl) : clickEl; s.#pairedElems.set(foundElem, pairedElem); s.#pairedElems.set(clickEl, foundElem); } } }); } #setInViewEntries(entries) { const s = this; for (const entry of entries) { const target = entry.target; if (entry.isIntersecting) { s.inViewEntries.add(target); } else { if (s.inViewEntries.has(target)) { s.backUpInViewEntry = target; s.inViewEntries.delete(target); s.#toggleActiveCss(target, 'remove'); } } } } #toggleActiveCss(target, type) { const s = this; const { cssPrefix } = s.params; const clickElem = s.#pairedElems.get(target); if (clickElem) { clickElem.classList[type](`${cssPrefix}-nav__active`); target.classList[type](`${cssPrefix}-body__active`); } } #spyElements() { const s = this; const hasInViewEntries = s.inViewEntries.size > 0; const topMostEntries = hasInViewEntries ? [] : [s.backUpInViewEntry]; for (const entry of s.inViewEntries) { if (topMostEntries.length) { const elTop = entry.getBoundingClientRect().top; for (let i = 0, l = topMostEntries.length; i < l; i++) { const tmEntry = topMostEntries[i]; const tmElTop = tmEntry.getBoundingClientRect().top; if (elTop < tmElTop && elTop >= 0) { topMostEntries.splice(i, 1, entry); s.#toggleActiveCss(tmEntry, 'remove'); } const isLastEntry = s.#lastSpyElem.isSameNode(entry); if (isLastEntry) { const remainingBodyScroll = document.body.scrollHeight - window.scrollY; const canScrollAmount = window.innerHeight; if (canScrollAmount > remainingBodyScroll) { // if we can't get to the last scroll element // then highlight once in the screen topMostEntries.push(entry); } } if (elTop === tmElTop) { topMostEntries.push(entry); } } } else { topMostEntries.push(entry); } } if (hasInViewEntries) { s.#toggleActiveCss(s.backUpInViewEntry, 'remove'); } for (const elem of topMostEntries) { s.#toggleActiveCss(elem, 'add'); } if (s.params.callback) { const navEntries = topMostEntries.map(el => s.#pairedElems.get(el)); s.params.callback(topMostEntries, navEntries); } } static remove(element, plugin) { $(element).each(function () { const s = plugin || Store(this, DATA_NAME); for (const elem of s.spyContents) { s.#toggleActiveCss(elem, 'remove'); } $(s.element) .off(`click.${EVENT_NAME}`) .removeClass(`${s.params.cssPrefix}-nav`); s.$spyBody.removeClass(`${s.params.cssPrefix}-body`); s.#observer.disconnect(); Store(this, DATA_NAME, null); }); } }