bona
Version:
Super lightweight microframework focused on static websites and landing pages.
207 lines (180 loc) • 7.54 kB
JavaScript
import Component from '../component';
import {checkAnchorEligibility, replaceNodesBySelectors} from '../utils';
/**
* @typedef {Object} BonaAjaxProps
* @property {boolean} [bindLinks] Handle all links on the page.
* @property {boolean} [bindHistory] Handle history changes.
* @property {RegExp} [checkLinkUrlRegExp] RegExp to check link URL eligibility.
* @property {boolean} [restoreScrollHistory] Restore the window scroll after the history change.
* @property {string} [scrollRestoration] Scroll restoration behavior on history navigation.
*
* @typedef {Object} BonaReqOptions
* @property {boolean} [checkResponseStatus] Check the response status and redirect if an error occurs.
* @property {string|boolean} [history] Push or replace history.
* @property {object} [historyState] Pass history state object.
* @property {boolean} [preventSame] Prevent goTo to the same URLs.
* @property {boolean} [preventHash] Prevent goTo to hashes.
* @property {boolean} [preventRunning] Prevent goTo when a transition is already running.
* @property {DOMParserSupportedType} [parserType] Specifies the parser type used to parse the DOM.
* @property {string[]} [updateSelectors] Selectors to update.
* @property {boolean} [extendNodes] Extend or replace new nodes.
* @property {boolean} [removeNodes] Remove non-existent nodes.
* @property {boolean} [detachNodes] Detach non-existent nodes from store.
* @property {boolean} [resetScroll] Reset the window scroll after an update.
* @property {boolean} [restoreScroll] Restore the window scroll after an update.
* @property {boolean} [scrollToHash] Scroll to hashes after an update.
* @property {boolean} [fireLeave] Fire leave hook.
* @property {boolean} [fireLoaded] Fire loaded hook.
* @property {boolean} [fireRefresh] Fire refresh ho.ok.
* @property {boolean} [fireEnter] Fire enter hook.
* @property {boolean} [fireComplete] Fire complete hook.
* @property {boolean} [fireDestroy] Fire destroy hook.
* @property {object} [fetch] Fetch options.
*
* @typedef {BonaAjaxProps & BonaReqOptions} BonaAjaxOptions
*/
export default class Ajax extends Component {
constructor() {
super(...arguments);
/** @type {BonaAjaxOptions} **/
this.options = {
bindLinks: true,
bindHistory: true,
checkLinkUrlRegExp: /(\?.*)?\/(?:|[^.]+(?:\.(?:htm|html|php)|))(?:\?.*|)$/, // regex101.com/r/k4iNu1/1
checkResponseStatus: true,
history: 'push',
historyState: {},
preventSame: false,
preventHash: true,
preventRunning: false,
parserType: 'text/html',
scrollRestoration: 'manual',
updateSelectors: ['title', 'meta', '#view-main'],
extendNodes: false,
removeNodes: true,
detachNodes: true,
resetScroll: true,
restoreScroll: false,
restoreScrollHistory: true,
scrollToHash: true,
fireLeave: true,
fireLoaded: true,
fireRefresh: true,
fireEnter: true,
fireComplete: true,
fireDestroy: true,
fetch: {},
...this.options,
};
this.event = {};
this.scroll = {};
this.parser = new DOMParser();
this.running = false;
this.url = new URL(window.location.href);
this.prevUrl = null;
if (this.options.scrollRestoration) window.history.scrollRestoration = this.options.scrollRestoration;
if (this.options.bindLinks) this.bindLinks();
if (this.options.bindHistory) this.bindHistory();
}
/**
* Handle all links on the page.
*/
bindLinks() {
document.addEventListener('click', (e) => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
for (let target = e.target; target && target !== document; target = target.parentNode) {
if (!checkAnchorEligibility(target, this.options.checkLinkUrlRegExp)) continue;
e.preventDefault();
this.goTo(target.href);
break;
}
});
}
/**
* Handle history change.
*/
bindHistory() {
this.event.popstate = () => {
this.goTo(window.location.href, {
history: false,
preventRunning: false,
restoreScroll: this.options.restoreScrollHistory,
});
};
window.addEventListener('popstate', this.event.popstate);
}
/**
* Push or replace url in history.
*
* @param {string} url Url.
* @param {string} [method] Push or replace.
* @param {object} [state] History state object.
*/
pushHistory(url, method = 'push', state = {}) {
window.history[method === 'push' ? 'pushState' : 'replaceState'](state, '', url);
}
/**
* Go to a new location.
*
* @param {string} url URL.
* @param {BonaReqOptions} [options] Request options.
* @return {Promise}
*/
async goTo(url, options) {
options = {...this.options, ...options};
if (options.preventRunning && this.running) {
return false;
}
this.prevUrl = this.url;
this.url = new URL(url, window.location.origin);
if (options.preventSame && this.url.href === this.prevUrl.href) {
return false;
}
if (options.preventHash && this.url.hash !== this.prevUrl.hash) {
const cleanUrl = this.url.href.split('#')[0];
const cleanPrevUrl = this.prevUrl.href.split('#')[0];
if (cleanUrl === cleanPrevUrl) {
return false;
}
}
this.scroll[this.prevUrl.href] = {top: window.scrollY, left: window.scrollX};
this.running = true;
if (options.history) this.pushHistory(this.url.href, options.history, options.historyState);
const data = await Promise.all([
this.executeRequest(url, options),
options.fireLeave ? this.app.executeAll('leave') : null,
]);
if (options.fireLoaded) await this.app.executeAll('loaded');
if (options.updateSelectors) {
replaceNodesBySelectors(options.updateSelectors, data[0], options.removeNodes, options.extendNodes);
}
if (options.resetScroll) {
window.scrollTo(0, 0);
}
await this.app.refresh(options.fireRefresh, options.fireDestroy, options.detachNodes);
if (options.restoreScroll && this.scroll[this.url.href]) {
window.scrollTo(this.scroll[this.url.href]);
} else if (options.scrollToHash && this.url.hash && this.app.query(this.url.hash)) {
this.app.query(this.url.hash)?.scrollIntoView();
}
if (options.fireEnter) await this.app.executeAll('enter');
if (options.fireComplete) await this.app.executeAll('complete');
this.running = false;
}
/**
* Execute prepared request.
*
* @param {string} url Url.
* @param {BonaReqOptions} [options] Request options.
* @return {Promise}
*/
async executeRequest(url, options) {
const req = await fetch(url, options.fetch);
if (options.checkResponseStatus && !req.ok) {
window.location.assign(url);
return;
}
const data = await req.text();
return this.parser.parseFromString(data, options.parserType);
}
}