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
JavaScript
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);
});
}
}