UNPKG

jolt-ui

Version:

A web components based SPA framework

816 lines (732 loc) 23.8 kB
/** * Base app component that wraps everything */ import Router from "./router.js"; import Authenticator from "./authenticator.js"; import { transformSelector } from "./selectorParser/index.js" import { CustomElement } from "./baseCore.js"; //import { DiffDOM, nodeToObj, stringToObj } from "diff-dom/dist/index.js"; import Diff from "./diff/diff.js"; /** * @typedef {import('./types.js').AppConfigs} AppConfigs */ /** * @typedef {Object} DataEventNum * @property {string} CHANGE, * @property {string} QUERYCHANGE */ /** * @type {DataEventNum} */ const dataEventEnum = { "CHANGE": "app-data-change", "QUERYCHANGE": "query-data-change" } /** * Converts a camelCase string to kebab-case. * @param {string} str - The input camelCase string. * @returns {string} - The converted kebab-case string. */ export function camelToKebab(str){ return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } /** * SVG element attributes that must remain in camalCase */ const svgCamelCaseAttributes = new Set([ "viewBox", "preserveAspectRatio", "patternTransform", "clipPathUnits" ]); class QueryClass{ /** * Container element of the app * @type {HTMLElement|Element} * @property {App} app */ container; constructor(){ } /** * @param {string} selector * @returns {HTMLElement|undefined} */ querySelector = (selector) => { return this.container.querySelector(selector); } /** * @param {string} selector * @returns {NodeList} */ querySelectorAll = (selector) => { return this.container.querySelectorAll(selector); } /** * @param {string} selector * @returns {HTMLElement|undefined} */ getElementById = (selector) => { return this.container.querySelector(`#${selector}`); } /** * @param {string} selector * @returns {HTMLCollectionOf} */ getElementsByClassName = (selector) => { return this.container.getElementsByClassName(selector); } /** * @param {string} selector * @returns {HTMLCollectionOf} */ getElementsByTagName = (selector) => { return this.container.getElementsByTagName(selector); } /** * @param {string} selector * @returns {boolean} */ matches = (selector) => { return this.container.matches(selector); } /** * Adds event listener to application container * @param {string} eventType - name of event * @param {(event: Event) => void} func - callback function for event */ addEventListener = (eventType, func) => { this.container.addEventListener(eventType, func) } /** * Removes event listener from application container * @param {string} eventType - name of event * @param {(event: Event) => void} func - callback function for event */ removeEventListener = (eventType, func) => { this.container.removeEventListener(eventType, func); } /** * @param {InsertPosition} position * @param {string} markup */ insertAdjacentHTML = (position, markup) => { this.container.insertAdjacentHTML(position, markup); } /** * @returns {HTMLCollection} */ get children(){ return this.container.children; } /** * @returns {NodeListOf} */ get childNodes(){ return this.container.childNodes; } } class App extends QueryClass{ /** * @type {string} */ identifier = "app"; /** * @type {string} */ #appName; /** * @type {Map<string, any>} */ #dataStructure = new Map(); /** * @type {Object<string, CallableFunction>} */ #beforeInit /** * @type {Object<string, CallableFunction>} */ #afterInit /** * Application properties * @type {Object<string, any>} */ #properties /** * @type {CallableFunction} */ _router; /** * App router * @type {Router} */ #router; /** * @type {CallableFunction} */ _authenticator; /** * App authenticator * @type {Authenticator */ #authenticator /** * @type {Object<string, CallableFunction>} */ _extensions; /** * Application extensions * @type {Object<string, CallableFunction>|undefined} */ #extensions; /** * @type {Object<string, HTMLElement>} */ #elements; /** * Custom render functions * @type {Object<string, CallableFunction>} * @private */ _renderFunctions; /** * @type {string[]} */ #protectedProperties; /** * Reserved attribute names that should not be manually set * @type {Array<string>} */ _filterAttributeNames = ["hashId", "data-hash-id", "hash-id", "parentId", "data-parent-id", "parent-id", "renderTime", "data-render-time", "render-time", "bind", "data-bind", "bindId", "data-bind-id", "bind-id"]; /** * Creates app html element * @param {AppConfigs} configs */ constructor({ appName, dataStructure = {}, elements = {}, renderFunctions = {}, router = null, authenticator = null, extensions = {}, properties = {}, methods = {}, beforeInit = {}, afterInit = {}, errorPages = null }){ super(); if(!appName){ throw new Error("Missing appName parameter"); } this.#appName = appName; this.#createDataStructure(dataStructure) this.#elements = elements; this._router = router; this._authenticator = authenticator; this._extensions = extensions; this._renderFunctions = renderFunctions; this._errorPages = errorPages; this.#properties = properties; this.#beforeInit = beforeInit; this.#afterInit = afterInit; this._methods = methods; this.#protectedProperties = Object.getOwnPropertyNames(this); } /** * Initilizer function for application * Kicks of all neccessary steps * @param {string} target - query selector for application container */ init = async (target) => { const container = document.querySelector(target); if(!container){ throw new Error("Could not find application container with selector: " + target); } this.containerId = target; this.container = container; this.container.setAttribute("app-id", this.generateHash()); // @ts-ignore this.container.app = this; this.registerCustomElements(this.#elements); this.registerCustomElements(this._errorPages); this._modifyPrototypeMethods(); if(this._authenticator){ this.#authenticator = this._authenticator(this); } this.#assignMethods(); await this.#runMethods(this.#beforeInit); if(this._router){ this.#router = this._router(this); } await this.#initializeExtensions(); if(this.#router){ await this.#router.route(); } await this.#waitForSubelements(); await this.#runMethods(this.#afterInit); } #assignMethods = () => { for(const [name, method] of Object.entries(this._methods)){ if(this.#protectedProperties.includes(name) || name.startsWith("#") || name.startsWith("_")){ throw new Error(`Illegal or protected method name. Can't assign method with name (${name}) that is protected or if it is of illegal format (startswith: # or _) to application`); } try{ this[name] = method.bind(this); }catch(e){ throw new Error(`${method} is probably not a function. Failed to bind method ${method} to application.` + e) } } this._methods = null; } isCustomElement(node){ return ( node instanceof CustomElement && customElements.get(node.tagName.toLowerCase()) ) } /** * Modifies prototype methods of HTMLElement * insertAdjacentHTML * innerHTML * outerHTML * appendChild * setAttribute * removeAttribute * * Modifies prototype method for query selectors on Element and Document * querySelector * querySelectorAll */ _modifyPrototypeMethods = () => { this._originalInnerHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML').set; //copy of original innerHTML property setter this._originalOuterHTML = Object.getOwnPropertyDescriptor(Element.prototype, 'outerHTML').set; //copy of original outerHTML property setter this._originalInsertAdjacentHTML = Element.prototype.insertAdjacentHTML; //copy of the original insertAdjacentHTML method this._originalAppendChild = Element.prototype.appendChild; this._originalSetAttribute = Element.prototype.setAttribute; // copy of original setAttribute method this._originalRemoveAttribute = Element.prototype.removeAttribute; //copy of removeAttribute method const appInstance = this; //closure to retain reference to app instance Element.prototype.insertAdjacentHTML = function(position, html, ){ //"this" refers to the element the operation is being performed on /** * @type {CustomElement} */ const customElement = this.closest("[data-hash-id]"); if(!customElement){ return appInstance._originalInsertAdjacentHTML.call(this, position, html); } //html = customElement._processDataBinds(html); const renderedTemplate = customElement._dotJSengine(html) appInstance._originalInsertAdjacentHTML.call(this, position, renderedTemplate); customElement._hydrate(); customElement.clearTemplateVariables(); } Object.defineProperty(Element.prototype, 'innerHTML', { set: function(html){ //this points to the element whos innerHTML changes. //appInstance._originalInnerHTML.call(this, ""); //this.insertAdjacentHTML("afterbegin", html); //DiffDOM const customElement = this.closest("[data-hash-id]"); if(!customElement){ appInstance._originalInnerHTML.call(this, html); return; } const diff = new Diff({ targetElement: this, newMarkup: html, customElement: customElement }); diff.setInnerHTML(); customElement._hydrate(); customElement.clearTemplateVariables(); } }); Object.defineProperty(Element.prototype, 'outerHTML', { set: function(html){ //this points to the element which outerHTML is changed //tries to find the parent (custom) element of the element that changes outerHTML const customElement = this.parent?.closest("[data-hash-id]"); if(customElement){ const renderedTemplate = customElement._dotJSengine(html) appInstance._originalOuterHTML.call(this, renderedTemplate); customElement._hydrate(); return; } appInstance._originalOuterHTML.call(this, html); } }); // Override setAttribute Element.prototype.setAttribute = function (name, value) { let attrName = name.startsWith(":") ? `data-${name.substring(1)}` : name; if (!(this instanceof SVGElement && svgCamelCaseAttributes.has(attrName))) { attrName = camelToKebab(attrName); } appInstance._originalSetAttribute.call(this, attrName, value); if(this instanceof CustomElement){ this._refreshBoundElements(`attrs.${attrName.replace("data-", "")}`) } }; //adds setAttributes method to Element prototype /** * maps key-value pairs to element attributes * @param {Object<string, any>} attrs */ // @ts-ignore Element.prototype.setAttributes = function (attrs) { for (const [key, value] of Object.entries(attrs)) { this.setAttribute(key, value); } } // Override removeAttribute Element.prototype.removeAttribute = function (name, value) { const attrName = name.startsWith(":") ? `data-${name.substring(1)}` : name; appInstance._originalRemoveAttribute.call(this, attrName, value); if(this instanceof CustomElement){ this._refreshBoundElements(`attrs.${attrName.replace("data-", "")}`) } }; this._originalDocQS = Document.prototype.querySelector; this._originalDocQSA = Document.prototype.querySelectorAll; this._originalElQS = Element.prototype.querySelector; this._originalElQSA = Element.prototype.querySelectorAll; // 2) Patch Document methods Document.prototype.querySelector = function(selector) { const newSelector = transformSelector(selector) return appInstance._originalDocQS.call(this, newSelector); }; Document.prototype.querySelectorAll = function(selector) { const newSelector = transformSelector(selector) return appInstance._originalDocQSA.call(this, newSelector); }; // 3) Patch Element methods Element.prototype.querySelector = function(selector) { const newSelector = transformSelector(selector) return appInstance._originalElQS.call(this, newSelector); }; Element.prototype.querySelectorAll = function(selector) { const newSelector = transformSelector(selector) return appInstance._originalElQSA.call(this, newSelector); }; } /** * Initializes all application extensions * @returns {Promise<void>} */ #initializeExtensions = async () => { if(this._extensions){ this.#extensions = {}; for(const [key, initializer] of Object.entries(this._extensions)){ this.#extensions[key] = await initializer(this); } } } /** * Runs all methods in provided object * @param {Object<string, CallableFunction>} methods * @returns {Promise<void>} */ #runMethods = async (methods) => { for(const [key, func] of Object.entries(methods)){ await func.bind(this)() } } /** * Maps provided data structure object to data map * @param {Object<string, any>} dataStructure */ #createDataStructure = (dataStructure) => { for(const [field, value] of Object.entries(dataStructure)){ this.#dataStructure.set(field, value); } } /** * Method to wait for all custom subelements to finish loading/rendering * Returns array with subelement promises to be resolved upon initialization * @returns {Promise<Array<Promise>>} */ #waitForSubelements = async () => { const subelementPromises = []; const allCustomElements = Array.from(this.querySelectorAll('*')).filter( (el) => { return el instanceof CustomElement } ); allCustomElements.forEach(elem => { subelementPromises.push(elem.initComplete); }) return await Promise.all(subelementPromises); } /** * Sets data to application data storage * @param {string} field * @param {any} data */ setData = (field, data) => { if(!this.#dataStructure.has(field)){ throw new Error(`Failed to set data. Missing data field ${field} in app data structure`); } this.#dataStructure.set(field, data); this.#dataChangeEvent(field); } /** * Removes data from app data (sets as null). Convenience method for setData(field, null); * @param {string} field */ removeData = (field) => { if(!this.#dataStructure.has(field)){ throw new Error(`Failed to set data. Missing data field ${field} in app data structure`); } this.#dataStructure.set(field, null); this.#dataChangeEvent(field); } /** * Gets data from application data storage * @param {string} field * @returns {any|undefined} */ getData = (field) => { if(!this.#dataStructure.has(field)){ throw new Error(`Failed to fetch data for field ${field}. Data field does not exist`); } return this.#dataStructure.get(field); } /** * Returns entire data structure as Map or object * @returns {Map|Object<string, any>} */ getAllData = (asObject = false) => { if(asObject){ return Object.fromEntries(this.#dataStructure); } return this.#dataStructure; } /** * Emits event for application dta change * @param {string} field */ #dataChangeEvent = (field) => { const event = new CustomEvent(dataEventEnum.CHANGE, { detail: {field: camelToKebab(field)} }); this.container.dispatchEvent(event); } /** * Emits event for application dta change * @param {string} key * @param {string} value */ #queryChangeEventKey = (key, value) => { const event = new CustomEvent(dataEventEnum.QUERYCHANGE, { detail: {key, value} }); this.container.dispatchEvent(event); } #queryChangeEvent = () => { const event = new CustomEvent(dataEventEnum.QUERYCHANGE, { detail: {query: this.queryParams} }); this.container.dispatchEvent(event); } /** * Performs redirect * @param {string} pathname */ redirect = (pathname) => { if(!this.router){ throw new Error("Redirect is only available with Router") } this.router.redirect(pathname); } /** * Returns the app instance (this). Implemented for * compatibility with customElement instances */ get app(){ return this; } /** * Getter for application properties set as an object */ get properties(){ return this.#properties; } /** * Getter for application name */ get appName(){ return this.#appName; } /** * Getter for router */ get router(){ if(!this.#router){ throw new Error("Router is not installed with Application.") } return this.#router; } /** * Getter for authenticator */ get authenticator(){ if(!this.#authenticator){ throw new Error("Authenticator is not installed with the Application.") } return this.#authenticator; } get authenticatorInstalled(){ if(!this.#authenticator){ return false; } return true; } /** * Returns object with initialized extensions */ get ext(){ return this.#extensions; } /** * Registers custom elements * @param {Object<string|number, HTMLElement>} elements */ registerCustomElements = (elements) => { if(!elements){ return; } for(const elem of Object.values(elements)){ //In case of error pages the same error page might be used for different //error codes. This prevents the duplicate custom element tag error. if(!customElements.get(elem.tagName)){ customElements.define(elem.tagName, elem); } } } /** * Converts the location.search string to an object of key-value pairs * @param {string} search - location.search string * @returns {Object<string, string>|{}} */ queryParamsToObject = (search) => { const searchParams = new URLSearchParams(search); const params = {}; for (const [key, value] of searchParams.entries()) { params[key] = value; } return params; } /** * Returns location.search params (query params) either as object (true) or as a string (false) * Default: false * @param {boolean} toObject * @returns {string|Object<string, string>|{}} */ getQueryParams = (toObject = false) => { if(!toObject){ return location.search; } return this.queryParamsToObject(location.search); } /** * @returns {Object<string, string>} */ get queryParams(){ return this.queryParamsToObject(location.search); } /** * Sets new query(search) parameters to url based on the * provided queryParamsObject * @param {Object<string, string>|{}} queryParamsObject */ set queryParams(queryParamsObject){ const url = new URL(window.location.origin + window.location.pathname); for(const [key, value] of Object.entries(queryParamsObject)){ url.searchParams.set(key, value); } window.history.replaceState(null, null, url); // or pushState for(const [key, value] of Object.entries(queryParamsObject)){ this.#queryChangeEventKey(key, value); } this.#queryChangeEvent(); } /** * Removes query parameters in provided array * @param {Array<string>} names */ removeQueryParams(names){ const queryParams = this.queryParams; const newQueryParams = {}; for(const [key, value] of Object.entries(queryParams)){ if(!names.includes(key)){ newQueryParams[key] = value; } } this.queryParams = newQueryParams; } /** * Generates a random hash with provided length. Default is 16 * @param {number} length * @returns {string} */ generateHash = (length = 16) => { const array = new Uint8Array(length); window.crypto.getRandomValues(array); return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); } get _elements(){ return this.#elements; } /** * Getter for url hash * @returns {string} */ get hash(){ return location.hash; } /** * Getter for port number * @returns {string} */ get port(){ return location.port; } /** * Getter for hostname * @returns {string} */ get hostname(){ return location.hostname; } /** * Getter for host * @returns {string} */ get host(){ return location.host; } /** * Getter for pathname * @returns {string} */ get pathname(){ return location.pathname; } /** * Getter for origin * @returns {string} */ get origin(){ return location.origin; } /** * Returns object with route parameters * @returns {Object<string, string|number>} */ get routeParameters(){ return this.router.routeParameters; } /** * Returns object with all available render functions * @returns {Object<string, CallableFunction>} */ get renderFunctions(){ return this._renderFunctions; } } export default App; export { dataEventEnum }