UNPKG

bona

Version:

Super lightweight microframework focused on static websites and landing pages.

931 lines (780 loc) 24.4 kB
/** * @typedef {Object} BonaEventOptions * @property {boolean} [once] Trigger callback once. */ class Base { constructor() { this._events = {}; } /** * Attach an event handler function. * * @param {string} event Event name. * @param {function} callback Callback. * @param {BonaEventOptions} [options] Options. */ on(event, callback, options) { if (options === void 0) { options = {}; } if (!this._events[event]) this._events[event] = []; this._events[event].push({ callback, options }); } /** * Remove an event handler. * * @param {string} event Event name. * @param {function} [callback] Callback. */ off(event, callback) { if (callback) { this._events[event] = this._events[event].filter(e => e.callback !== callback); } else { this._events[event] = []; } } /** * Execute all handlers for the given event type. * * @param {string} event Event name. * @param params Extra parameters. */ trigger(event) { if (!this._events[event]) return; this._events[event].forEach(e => { e.callback.call(this, ...[].slice.call(arguments, 1)); if (e.options.once) this.off(event, e.callback); }); } } function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } class Core extends Base { /** * @typedef {Object} BonaOptions * @property {boolean} [init] Initialize an app immediately. * @property {Object[]} [define] Array of components to define. */ /** * Initializes the core. * * @param {BonaOptions} [options] Options. */ constructor(options) { super(); /** @type {BonaOptions} **/ this.options = _extends({ init: true, define: null }, options); this.store = new Map(); this.registry = new Map(); this.isFullyLoaded = false; if (this.options.define) this.defineAll(this.options.define); if (this.options.init) this.init(); } /** * Performs basic hooks. * * @return {Promise} */ init() { try { const _this = this; return Promise.resolve(_this.waitFullLoad()).then(function () { return Promise.resolve(_this.executeAll('init')).then(function () { return Promise.resolve(_this.executeAll('enter')).then(function () { return Promise.resolve(_this.executeAll('complete')).then(function () {}); }); }); }); } catch (e) { return Promise.reject(e); } } /** * Refresh component store. * * @param {boolean} [fireRefresh] Fire refresh hook in instances. * @param {boolean} [fireDestroy] Fire destroy hook when detach non-existent nodes. * @param {boolean} [detachNodes] Detach non-existent nodes from store. * @return {Promise} */ refresh(fireRefresh, fireDestroy, detachNodes) { if (fireRefresh === void 0) { fireRefresh = true; } if (fireDestroy === void 0) { fireDestroy = true; } if (detachNodes === void 0) { detachNodes = true; } try { const _this2 = this; const preExecutors = []; const executors = []; _this2.registry.forEach((options, namespace) => { const instances = _this2.store.get(namespace) || []; const els = new Set(options.assign ? _this2.queryAll(options.assign) : []); instances.forEach(instance => { if (options.assign) { var _instance$el; if ((_instance$el = instance.el) != null && _instance$el.isConnected) { if (fireRefresh) executors.push(_this2.executeInstance(instance, 'refresh')); els.delete(instance.el); } else { if (detachNodes) preExecutors.push(_this2.detach(instance, fireDestroy)); } } else { if (fireRefresh) executors.push(_this2.executeInstance(instance, 'refresh')); } }); els.forEach(el => { executors.push(_this2.attach(namespace, el)); }); }); return Promise.resolve(Promise.all(preExecutors)).then(function () { if (fireRefresh) _this2.trigger('refresh'); return Promise.all(executors); }); } catch (e) { return Promise.reject(e); } } /** * Execute hooks in all instances. * * @param {string} [hook] Hook name. * @return {Promise} */ executeAll(hook) { if (hook === void 0) { hook = 'init'; } try { const _this3 = this; const executors = []; _this3.trigger(hook); _this3.store.forEach(instances => { instances.forEach(instance => executors.push(_this3.executeInstance(instance, hook))); }); return Promise.all(executors); } catch (e) { return Promise.reject(e); } } /** * Execute hook in an instance. * * @param {Component} instance Component instance. * @param {string} [hook] Hook name. * @return {Promise} */ executeInstance(instance, hook) { if (hook === void 0) { hook = 'init'; } try { const method = 'on' + hook.charAt(0).toUpperCase() + hook.slice(1); if (instance[method]) return Promise.resolve(instance._executors[hook] = instance[method]()); return Promise.resolve(); } catch (e) { return Promise.reject(e); } } /** * Wait until the component hook is completed. * * @param {string} namespace Namespace. * @param {string} [hook] Hook name. * @param {number} [index] Instance index. * @return {Promise} */ wait(namespace, hook, index) { if (hook === void 0) { hook = 'init'; } if (index === void 0) { index = 0; } try { const _this4 = this; return Promise.resolve(_this4.waitInstance(_this4.get(namespace, index), hook)); } catch (e) { return Promise.reject(e); } } /** * Wait until hooks are completed in all instances. * * @param {string} namespace Namespace. * @param {string} [hook] Hook name. * @return {Promise} */ waitAll(namespace, hook) { if (hook === void 0) { hook = 'init'; } try { const _this5 = this; const executors = []; _this5.store.get(namespace).forEach(instance => { executors.push(_this5.waitInstance(instance, hook)); }); return Promise.all(executors); } catch (e) { return Promise.reject(e); } } /** * Wait until the hook is completed in an instance. * * @param {Component} instance Component instance. * @param {string} [hook] Hook name. * @return {Promise} */ waitInstance(instance, hook) { if (hook === void 0) { hook = 'init'; } try { return Promise.resolve(instance._executors[hook]); } catch (e) { return Promise.reject(e); } } /** * Wait until the page has fully loaded. * * @return {Promise} */ waitFullLoad() { try { const _this6 = this; return Promise.resolve(new Promise(resolve => { if (document.readyState === 'complete') { resolve(); } else { window.addEventListener('load', () => resolve()); } })).then(function () { _this6.isFullyLoaded = true; }); } catch (e) { return Promise.reject(e); } } /** * Create component instance in the store. * * @param {string} namespace Namespace. * @param {HTMLElement} [el] Element. * @param {Object} [options] Options that will pass to instance. * @param {boolean} [fireInit] Fire init hook. * @return {Component} Component instance. */ attach(namespace, el, options, fireInit) { if (fireInit === void 0) { fireInit = true; } try { const _this7 = this; const factory = _this7.registry.get(namespace).component; const instance = new factory(_this7, el, _extends({}, _this7.registry.get(namespace).options, options)); if (!_this7.store.has(namespace)) _this7.store.set(namespace, new Set()); _this7.store.get(namespace).add(instance); instance._namespace = namespace; const _temp = function () { if (fireInit) return Promise.resolve(_this7.executeInstance(instance, 'init')).then(function () {}); }(); return Promise.resolve(_temp && _temp.then ? _temp.then(function () { return instance; }) : instance); } catch (e) { return Promise.reject(e); } } /** * Remove component instance from store. * * @param {Component} instance Component instance. * @param {boolean} [fireDestroy] Fire destroy hook. * @return {Component} Component instance. */ detach(instance, fireDestroy) { if (fireDestroy === void 0) { fireDestroy = true; } try { const _this8 = this; _this8.store.get(instance._namespace).delete(instance); const _temp2 = function () { if (fireDestroy) return Promise.resolve(_this8.executeInstance(instance, 'destroy')).then(function () {}); }(); return Promise.resolve(_temp2 && _temp2.then ? _temp2.then(function () { return instance; }) : instance); } catch (e) { return Promise.reject(e); } } /** * Register component. * * @param {string} namespace Namespace. * @param {Object} component Component class. * @param {string} [assign] Assigned DOM selector. * @param {Object} [options] The options object that will be passed to each instance. */ define(namespace, component, assign, options) { this.registry.set(namespace, { assign, component, options }); if (assign) { const els = this.queryAll(assign); els.forEach(el => { this.attach(namespace, el, null, this.isFullyLoaded); }); } else { this.attach(namespace, null, null, this.isFullyLoaded); } } /** * Register array of components. */ defineAll(arr) { arr.forEach(item => { this.define(item.namespace, item.component, item.assign, item.options); }); } /** * Return component instance. * * @param {string} namespace Namespace. * @param {number} [index] Instance index. * @return {Component|*} Component instance. */ get(namespace, index) { if (index === void 0) { index = 0; } const instances = this.store.get(namespace); return instances ? [...instances][index] : null; } /** * Return all component instances. * * @param {string} namespace Namespace. * @return {Component[]|*[]} Array of component instances. */ getAll(namespace) { return this.store.get(namespace); } /** * Find component instance that matches a CSS selector. * * @param {string|Element} selector Selector or node. * @param {string} [namespace] Namespace. * @param {number} [index] Instance index. * @return {Component|*} Component instance. */ find(selector, namespace, index) { if (index === void 0) { index = 0; } const instance = this.findAll(selector, namespace); return instance ? instance[index] : null; } /** * Find all component instances that match a CSS selector. * * @param {string|Element} selector Selector or node. * @param {string} [namespace] Namespace. * @return {Component[]|*[]} Array of component instances. */ findAll(selector, namespace) { const store = namespace ? [this.store.get(namespace) || []] : this.store; const result = []; store.forEach(instances => { instances.forEach(instance => { if (!instance.el) return; if (typeof selector === 'string' ? instance.el.matches(selector) : instance.el === selector) { result.push(instance); } }); }); return result; } /** * Returns the first element that matches a CSS selector. * * @param {string|Object} selector Selector. * @return {null|Element|Object} */ query(selector) { if (typeof selector === 'string') return document.querySelector(selector); if (typeof selector === 'object') return selector; return null; } /** * Returns all elements that match a CSS selector. * * @param {string|Object} selector Selector. * @return {Element[]|Object} */ queryAll(selector) { if (typeof selector === 'string') return Array.from(document.querySelectorAll(selector)); if (typeof selector === 'object') return selector; return []; } } class Component extends Base { /** * @param {Core} app Application instance. * @param {HTMLElement} el Element. * @param {object} options Options passed to instance. */ constructor(app, el, options) { super(); this.app = app; this.el = el; this.options = options; this._namespace = null; this._executors = {}; } onInit() { return Promise.resolve(); } onRefresh() { return Promise.resolve(); } onEnter() { return Promise.resolve(); } onComplete() { return Promise.resolve(); } onLeave() { return Promise.resolve(); } onDestroy() { return Promise.resolve(); } onLoaded() { return Promise.resolve(); } } /** * Replace nodes from a given document by selector. * * @param {string} selector Selector. * @param {Document} doc Source document. * @param {boolean} [removeNodes] Remove non-existent nodes. * @param {boolean} [extendNodes] Extend or replace new nodes. */ const replaceNodesBySelector = function replaceNodesBySelector(selector, doc, removeNodes, extendNodes) { if (removeNodes === void 0) { removeNodes = true; } if (extendNodes === void 0) { extendNodes = false; } const nodes = document.querySelectorAll(selector); const newNodes = doc.querySelectorAll(selector); nodes.forEach((el, index) => { if (newNodes[index]) { if (extendNodes) { el.append(...newNodes[index].childNodes); } else { // DOMParser.parseFromString fails <source> in <picture> and <video> in Safari/FF, so wrap it in div // https://stackoverflow.com/questions/58240755/ // el.replaceWith(newNodes[index]); const temp = document.createElement('div'); temp.innerHTML = newNodes[index].outerHTML; el.replaceWith(temp.firstElementChild); } } else if (removeNodes) { el.remove(); } }); }; /** * Replace nodes from a given document by selectors. * * @param {string[]} selectors Selectors. * @param {Document} doc Source document. * @param {boolean} [removeNodes] Remove non-existent nodes. * @param {boolean} [extendNodes] Extend or replace new nodes. */ const replaceNodesBySelectors = function replaceNodesBySelectors(selectors, doc, removeNodes, extendNodes) { if (removeNodes === void 0) { removeNodes = true; } if (extendNodes === void 0) { extendNodes = false; } selectors.forEach(selector => { replaceNodesBySelector(selector, doc, removeNodes, extendNodes); }); }; /** * Check an anchor for eligibility. * * @param {HTMLAnchorElement} el Anchor element. * @param {RegExp} [checkUrlRegExp] Check the URL using RegExp. * @param {string} [host] Current host. * @return {boolean} */ const checkAnchorEligibility = function checkAnchorEligibility(el, checkUrlRegExp, host) { if (host === void 0) { host = window.location.host; } if (el.tagName !== 'A' || !el.href || el.host !== host) return false; if (el.getAttribute('target') || el.hasAttribute('download')) return false; if (checkUrlRegExp) { const match = el.href.match(checkUrlRegExp); if (!match || match[1]) return false; } return true; }; /** * @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 */ class Ajax extends Component { constructor() { super(...arguments); /** @type {BonaAjaxOptions} **/ this.options = _extends({ 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, state) { if (method === void 0) { method = 'push'; } if (state === void 0) { 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} */ goTo(url, options) { try { const _this = this; options = _extends({}, _this.options, options); if (options.preventRunning && _this.running) { return Promise.resolve(false); } _this.prevUrl = _this.url; _this.url = new URL(url, window.location.origin); if (options.preventSame && _this.url.href === _this.prevUrl.href) { return Promise.resolve(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 Promise.resolve(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); return Promise.resolve(Promise.all([_this.executeRequest(url, options), options.fireLeave ? _this.app.executeAll('leave') : null])).then(function (data) { function _temp6() { if (options.updateSelectors) { replaceNodesBySelectors(options.updateSelectors, data[0], options.removeNodes, options.extendNodes); } if (options.resetScroll) { window.scrollTo(0, 0); } return Promise.resolve(_this.app.refresh(options.fireRefresh, options.fireDestroy, options.detachNodes)).then(function () { function _temp4() { function _temp2() { _this.running = false; } const _temp = function () { if (options.fireComplete) return Promise.resolve(_this.app.executeAll('complete')).then(function () {}); }(); return _temp && _temp.then ? _temp.then(_temp2) : _temp2(_temp); } 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)) { var _this$app$query; (_this$app$query = _this.app.query(_this.url.hash)) == null ? void 0 : _this$app$query.scrollIntoView(); } const _temp3 = function () { if (options.fireEnter) return Promise.resolve(_this.app.executeAll('enter')).then(function () {}); }(); return _temp3 && _temp3.then ? _temp3.then(_temp4) : _temp4(_temp3); }); } const _temp5 = function () { if (options.fireLoaded) return Promise.resolve(_this.app.executeAll('loaded')).then(function () {}); }(); return _temp5 && _temp5.then ? _temp5.then(_temp6) : _temp6(_temp5); }); } catch (e) { return Promise.reject(e); } } /** * Execute prepared request. * * @param {string} url Url. * @param {BonaReqOptions} [options] Request options. * @return {Promise} */ executeRequest(url, options) { try { const _this2 = this; return Promise.resolve(fetch(url, options.fetch)).then(function (req) { if (options.checkResponseStatus && !req.ok) { window.location.assign(url); return; } return Promise.resolve(req.text()).then(function (data) { return _this2.parser.parseFromString(data, options.parserType); }); }); } catch (e) { return Promise.reject(e); } } } /*! * Bona * https://cuberto.com/ * * @version 1.1.0 * @author Cuberto, Artem Dordzhiev (Draft) */ const Bona = new Core(); export { Ajax, Base, Bona, Component, Core, Core as default };