UNPKG

jolt-ui

Version:

A web components based SPA framework

1,264 lines (1,124 loc) 39.3 kB
/** * Base class for custom elements */ import App, { dataEventEnum, camelToKebab } from "./app.js"; import Authenticator from "./authenticator.js"; import Router from "./router.js"; import doT from "./dot.js"; import { routeEventsEnum } from "./router.js"; /** * @typedef {import('./types.js').ElementFactoryConfigs} ElementFactoryConfigs */ const templateSettings = { evaluate: /\{\{([\s\S]+?)\}\}/g, interpolate: /\{\{=([\s\S]+?)\}\}/g, encode: /\{\{!([\s\S]+?)\}\}/g, use: /\{\{#([\s\S]+?)\}\}/g, define: /\{\{##\s*([\w\.$]+)\s*(\:|=)([\s\S]+?)#\}\}/g, conditional: /\{\{\?(\?)?\s*([\s\S]*?)\s*\}\}/g, iterate: /\{\{~\s*(?:\}\}|([\s\S]+?)\s*\:\s*([\w$]+)\s*(?:\:\s*([\w$]+))?\s*\}\})/g, varname: 'it', strip: true, append: true, selfcontained: false, dataBinds: new Map(), def: {} } /** * Generates message id with random number * @param {number} max * @param {number} min * @returns {number} */ function randomId(max = 10000, min = 1){ return Math.floor(Math.random() * (max - min + 1)) + min; } /** * @this {CustomElement} * @typedef {Object} ValueAccessor * @property {() => any} get - Retrieves the value. * @property {(value: any) => void} set - Updates the value and refreshes bound elements. */ /** * Factory function that creates a getter/setter accessor function. * @this {CustomElement} * @param {any} initValue - The initial value to be assigned. * @returns {(name: string) => ValueAccessor} A function that takes a key name and returns an accessor with `get` and `set` methods. */ function defineValue(initValue){ return function defineValueInner(name){ const _initValue = window.structuredClone(initValue); if(![undefined, null].includes(_initValue)){ this["_values"][name] = _initValue; } return { /** * @this {CustomElement} * @returns {any} */ get(){ return this["_values"][name] }, /** * @this {CustomElement} * @param {any} value */ set(value){ this["_values"][name] = value; this._refreshBoundElements(camelToKebab(name)); } } } } /** * @typedef {Object} QuerySelectorAccessor * @property {() => Element|null} get - Retrieves the first matching element using `querySelector`. */ /** * Factory function that creates a getter for `querySelector`. * @this {CustomElement} * @param {string} selector - A valid CSS selector string. * @returns {() => QuerySelectorAccessor} A function that returns an accessor object with a `get` method. * @throws {Error} If the selector is missing, null, or an empty string. */ function querySelector(selector){ if([undefined, null, ""].includes(selector)){ throw new Error("Missing or invalid query selector for querySelector getter factory") } return function querySelectorInner(){ const _selector = selector; return { /** * Gets the first matching element within the current context. * @this {CustomElement} * @returns {Element|null} The first matching element or `null` if none is found. */ get(){ return this.querySelector(_selector); } } } } /** * @typedef {Object} QuerySelectorAllAccessor * @property {() => NodeListOf<Element>} get - Retrieves all matching elements using `querySelectorAll`. */ /** * Factory function that creates a getter for `querySelectorAll`. * @this {CustomElement} * @param {string} selector - A valid CSS selector string. * @returns {() => QuerySelectorAllAccessor} A function that returns an accessor object with a `get` method. * @throws {Error} If the selector is missing, null, or an empty string. */ function querySelectorAll(selector){ if([undefined, null, ""].includes(selector)){ throw new Error("Missing or invalid query selector for querySelector getter factory") } return function querySelectorAllInner(){ const _selector = selector; return { /** * Gets all matching elements within the current context. * @this {CustomElement} * @returns {NodeListOf<Element>} */ get(){ return this.querySelectorAll(_selector); } } } } const html = String.raw; const css = String.raw export { html, css, randomId, defineValue, querySelector, querySelectorAll } class CustomElement extends HTMLElement{ constructor(){ super(); this.resolveInitialization = null; this.initComplete = null; this._startInitilization(); } _startInitilization = () => { this.resolveInitialization = null; this.initComplete = new Promise((resolve) => { this.resolveInitialization = resolve; }); } /** * @type {Promise<void>} */ initComplete; /** * Element tagName * @type {string} */ static tagName; /** * Component methods * @type {Object<string, CallableFunction} */ _methods; /** * Component markup method * @type {CallableFunction} * @async */ markup; /** * Component css method * @type {Object<string, CallableFunction|string>} */ css; /** * If the style should be scoped or not * @type {boolean} */ #scopedCss; /** * CSS style string * @type {string} */ #style; /** * Methods that should run before initialization * @type {Object<string, CallableFunction>} */ _beforeInit; /** * Methods that should run after initialization * @type {Object<string, CallableFunction>} */ _beforeInitResolve; /** * Methods that should run after initialization * @type {Object<string, CallableFunction>} */ _afterInit; /** * Methods that should run before rerender * @type {Object<string, CallableFunction>} */ _beforeRerender; /** * Methods that should run after rerender * @type {Object<string, CallableFunction>} */ _afterRerender; /** * Methods that should run after element unmounts (in disconnectedCallback) * @type {Object<string, CallableFunction>} */ _afterDisconnect; /** * App element (main wrapper) * @type {App} */ app; /** * @type {CustomElement} */ _parent; /** * Class for virtual render div * @type {string} */ #virtualRenderDiv = "virtual-render-div"; /** * Getter/setter defined values * @type {Object<string, any>} */ _values = {}; /** * Array with protected properties of the object * @type {Array<string>} */ #protectedProperties; /** * Template engine settings * @type {Object<string, any>} */ #templateSettings; /** * @type {Object<string, CallableFunction>} */ _templateFunctions = {}; /** * @type {Object<string, any>} */ _templateVariables; /** * @type {Object<string, Object<string, CallableFunction>>} */ _define; async connectedCallback(){ if([true, "true", "", "defer"].includes(this.getAttribute("defer"))){ this.resolveInitialization(); return; } try{ await this.#init(); }catch(e){ this._abort = true; this.resolveInitialization(); console.log(`Failed to initilize element ${this.tagName}`); console.error(e); } } initElement = () => { this.#init(); } /** * Initilizer method of element. Triggered in the connectedCallback * @returns {Promise<void>} */ #init = async () => { this._startInitilization(); this._abort = false; //finds app container and gets the app instance object from it // @ts-ignore const app = this.closest("[app-id]")?.app; //app-identifier="app" if(!app){ throw new Error("Could not find container with application.") } this.app = app; this.app.addEventListener(routeEventsEnum.ABORTROUTETRANSITION, this.#abortInitilization); this.rndId = randomId(); this.hashId = this.getAttribute("data-hash-id") || this.generateHash(); if(!this.getAttribute("data-hash-id")){ this.setAttribute("data-hash-id", this.hashId); } if(!this.getAttribute("data-parent-id")){ let parent = this.parentElement.closest("[data-hash-id]"); if(parent){ this.setAttribute("data-parent-id", parent.getAttribute("data-hash-id")); // @ts-ignore this._parent = parent; } } this._templateVariables = {}; this.#templateSettings = window.structuredClone(templateSettings) this.#assignTemplateFunction(); this.#protectedProperties = Object.getOwnPropertyNames(this); this.#assignMethods(); this.#assignDefinedGettersAndSetters(); await this.#runMethods(this._beforeInit); this.app.addEventListener(dataEventEnum.CHANGE, this._updateBoundAppDataParts); this.app.addEventListener(dataEventEnum.QUERYCHANGE, this._updateBoundQueryDataParts); await this.#handleCssStyle(); await this.render(); if(this._abort){ this.resolveInitialization(); return; } await this.#waitForSubelements(); await this.#runMethods(this._beforeInitResolve); this.resolveInitialization(); await this.#runMethods(this._afterInit); } /** * Disconnected callback for element */ disconnectedCallback(){ this.app.removeEventListener(dataEventEnum.CHANGE, this._updateBoundAppDataParts); this.app.removeEventListener(dataEventEnum.QUERYCHANGE, this._updateBoundQueryDataParts); const style = document.head.querySelector(`style[data-parent-id="${this.hashId}"]`); if(style){ style.remove(); } this.#runMethods(this._afterDisconnect) } #abortInitilization = (event) => { this._abort = true; this.resolveInitialization(); } #assignTemplateFunction = () => { for(const [name, func] of Object.entries(this._templateFunctions)){ this.#templateSettings.def[name] = func.bind(this); } } #assignDefinedGettersAndSetters = () => { for(const [prop, methods] of Object.entries(this._define)){ if(this.#protectedProperties.includes(prop) || prop.startsWith("#") || prop.startsWith("_")){ throw new Error(`Illegal or protected property name. Can't assign property with name (${prop}) that is protected or if it is of illegal format (startswith: # or _) to element ${this.tagName}`); } if(typeof methods === "function"){ // @ts-ignore Object.defineProperty(this, prop, methods.bind(this)(prop)); }else{ let modifiedGetterAndSetter = { get(){ return methods.get.bind(this)(); }, } if(methods.set){ modifiedGetterAndSetter = { ...modifiedGetterAndSetter, set(value){ methods.set.bind(this)(value); this._refreshBoundElements(prop); } } } Object.defineProperty(this, prop, modifiedGetterAndSetter); } } } /** * Refreshes inner html of all elements bound to this property * @param {string} propertyName */ _refreshBoundElements = (propertyName) => { this.renderTime = Date.now() this.querySelectorAll(`[data-bind="${propertyName}"]`)?.forEach((elem) => { const mapObject = this.#templateSettings.dataBinds.get(propertyName); if(!mapObject){ return; } const elemId = elem.getAttribute("data-bind-id"); if(!elemId){ return; } const elementTemplate = mapObject[elemId]; if(!elementTemplate){ return; } elem.setAttribute("data-render-time", `${this.renderTime}`); elem.innerHTML = elementTemplate; }) } _updateBoundAppDataParts = (event) => { this._refreshBoundElements(`app.${event.detail.field}`) } _updateBoundQueryDataParts = (event) => { if(event?.detail?.key){ this._refreshBoundElements(`query.${event.detail.key}`) }else{ this._refreshBoundElements("query"); } } #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 element ${this.tagName}`); } try{ this[name] = method.bind(this); }catch(e){ throw new Error(`${method} is probably not a function. Failed to bind method ${method} to element ${this.tagName}.` + e) } } this._methods = null; } #waitForSubelements = async () => { const subelementPromises = []; const allCustomElements = Array.from(this.querySelectorAll('*')).filter( (el) => { return el instanceof CustomElement } ); allCustomElements.forEach(elem => { // @ts-ignore subelementPromises.push(elem.initComplete); }) return await Promise.all(subelementPromises); } awaitElementsActivation = async () => { return await this.#waitForSubelements(); } /** * Runs all methods in object * @param {Object<string, CallableFunction>} methods */ #runMethods = async (methods) => { for(const [key, func] of Object.entries(methods)){ await func.bind(this)(); } } #handleCssStyle = async () => { if(!css){ return; } // @ts-ignore this.#scopedCss = this?.css?.scoped || false; // @ts-ignore this.#style = await this.css?.style.bind(this)() || null; this.#parseCss(); } #parseCss = () => { if(!this.#style){ return; } const attributeSelector = `[data-element="${this.tagName.toLowerCase()}"]` if(document.head.querySelector(attributeSelector)){ return; } // Create a <style> element const style = document.createElement('style'); style.textContent = this.#style; // Disable the style element to prevent it from applying styles style.setAttribute('disabled', ''); style.setAttribute('data-element', this.tagName.toLowerCase()); // Append the style to the <head> temporarily so that the CSS is parsed document.head.appendChild(style); //if CSS is not scoped it activated the style if(!this.#scopedCss){ style.removeAttribute('disabled'); return; } // Access the CSSStyleSheet object const sheet = style.sheet; // Function to insert the attribute before pseudo-classes or combinators const insertAttribute = (selector, attribute) => { // Split the selector by spaces to handle individual parts (e.g., combinators) return selector.split(' ').map(part => { // Handle pseudo-classes like :hover, :nth-child, etc. return part.replace(/([a-zA-Z0-9\.\#\-_]+)([:].*)?/, (match, base, pseudo) => { // Append the attribute to the base part, and preserve any pseudo-classes return base + attribute + (pseudo || ''); }); }).join(' '); }; // Loop over each CSS rule and modify the selector const newCSSRules = []; for (let rule of sheet.cssRules) { if (rule instanceof CSSStyleRule) { // Modify the selector to append the custom attribute in the correct place const scopedSelector = rule.selectorText .split(',') .map(selector => insertAttribute(selector.trim(), attributeSelector)) .join(', '); newCSSRules.push(`${scopedSelector} { ${rule.style.cssText} }`); } // Handle media queries or other types of rules else if (rule instanceof CSSMediaRule) { const scopedMediaRules = []; for (let mediaRule of rule.cssRules) { if (mediaRule instanceof CSSStyleRule) { const scopedSelector = mediaRule.selectorText .split(',') .map(selector => insertAttribute(selector.trim(), attributeSelector)) .join(', '); scopedMediaRules.push(`${scopedSelector} { ${mediaRule.style.cssText} }`); } } newCSSRules.push(`@media ${rule.media.mediaText} { ${scopedMediaRules.join(' ')} }`); } } // Remove the original disabled style element style.textContent = newCSSRules.join('\n'); style.removeAttribute('disabled'); } /** * Should be implemented for template rendering * @returns {Promise<string>} * @async */ #markup = async () => { if(!this.markup){ throw new Error(`Missing markup method for element ${this.tagName}`); } try{ return await this.markup(); }catch(e){ throw new Error(`Failed to run markup method of element: ${this.tagName} - ` + e.message); } } /** * Renders template of element. Uses markup method. * Adds event listeners to elements with appropriate attributes (df-<event>) * @async * @returns {Promise<void>} */ render = async () => { let html = await this.#markup(); this.innerHTML = html; } /** * Parses the provided string template. Adds all * required data-parent tags etc... * @param {string} html * @returns {string} */ #parseStringTemplate = (html) => { html = this.#replaceAtWithDf(html); html = this.#replaceColonWithData(html); html = this.#parseCustomElementTags(html); const div = document.createElement("div"); div.classList.add(this.#virtualRenderDiv); this.app._originalInsertAdjacentHTML.call(div, "afterbegin", html); this.lastRender = Date.now(); this.#parseConditionals(div); return this.#tagAllelementsWithParent(div); } /** * @param {string} html * @returns {string} */ #dotJSengine = (html) => { try{ const templateFn = doT.template.bind(this)(html, this.#templateSettings, undefined); let renderedTemplate = templateFn.bind(this)(this._templateVariables); this._templateVariables = {}; return this.#parseStringTemplate(renderedTemplate); }catch(e){ console.error(`Failed to run #dotJSengine for element: `, this); } } /** * Runs the dotJS render engine with the provided html string * @param {string} html * @returns {string} */ _dotJSengine = (html) => { return this.#dotJSengine(html); } /** * Activates methods of all elements inside the designated HTML element * that are part of the current CustomElement and have the data-render-time attribute the same as this.lastRender * @param {HTMLElement|string} elem - element or valid querySelector string */ activateElement(elem) { if(typeof elem === "string"){ // @ts-ignore elem = this.querySelector(elem); } const elementsWithEvents = this.#allElementsWithEvents( // @ts-ignore elem.querySelectorAll(`[data-parent-id="${this.hashId}"][data-render-time="${this.lastRender}"]`)); this.#setEventListeners(elementsWithEvents); } get attrs(){ return this.getAttrs(this); } getAttrs = (elem) => { const data = elem.dataset; const parsedMap = {}; for(const [key, value] of Object.entries(data)){ if(this.app._filterAttributeNames.includes(key)){ continue; } try{ parsedMap[key] = JSON.parse(value.trim()); }catch{ parsedMap[key] = value; } } return parsedMap; } /** * Adds variable for template rendring * @param {string} name * @param {any} value */ addTemplateVariable = (name, value) => { this._templateVariables[name] = value; } clearTemplateVariables = () => { this._templateVariables = {}; } get variables(){ return this._templateVariables; } /** * Replaces shorthand "@<eventName>=" synatx with jolt-<eventName> * @param {string} inputString * @returns */ #replaceAtWithDf = (inputString) => { return inputString.replace(/@(\w+)=/g, "jolt-$1="); } #replaceColonWithData = (inputString) => { return inputString.replace(/:(\w+)=/g, "data-$1=") } #parseCustomElementTags = (inputString) => { return inputString.replace(/<([A-Z][a-zA-Z0-9]*|[a-z][a-zA-Z0-9]*)([^>]*)\s*(\/?)>/g, (match, tagName, attributes, selfClosing) => { if (this.app._elements[tagName]) { const element = this.app._elements[tagName]; const tag = element.tagName; return selfClosing ? `<${tag}${attributes}/>` : `<${tag}${attributes}></${tag}>`; } return match; // Return unchanged if no mapping is found }); } /** * Parses conditionals in html elements (df-if) * @param {HTMLElement} elem - element whose contents should be parsed */ #parseConditionals = (elem) => { elem.querySelectorAll("[jolt-show-if]").forEach((child) => { const value = child.getAttribute("jolt-show-if"); if([false, "false", null, "null", undefined, "undefined"].includes(value)){ child.remove(); } }) } /** * Gets all arguments on the element with an df-{eventName} attribute. Parses all * arguments that starts with a ":" and collects them into an object with key-value pairs. * @param {HTMLElement|CustomElement} elem * @returns {Object<string, string|number|object>} */ #getAllCustomAttributes = (elem) => { return this.getAttrs(elem); } /** * Returns the type-method pair of the assigned event * or null if no event was assigned to the element * @param {HTMLElement} elem * @returns {Array<string, string>|null} */ getEventTypeAndMethod = (elem) => { const attributes = elem.attributes; if(!attributes){return [null, null];} for(const attr of elem.attributes){ if(attr.name.startsWith("jolt-")){ const value = elem.getAttribute(attr.name); return [attr.name, value]; } } return [null, null]; } /** * Sets event listeners on all elements * @param {{element: HTMLElement, eventName: string, methodName: string}[]} elementsWithevents */ #setEventListeners = (elementsWithevents) => { for(let elementWithEvent of elementsWithevents){ const elem = elementWithEvent.element; const eventName = elementWithEvent.eventName; const methodName = elementWithEvent.methodName; //Checks if the element is already active //This attribute apparently disappears if the element is taken out of the DOM //and reappended again. Maybe just the consequence of manipulation by the SimpleDataTable library if(elem[`jolt-${eventName}:active`]){ return; } const listener = this._createEventListenerMethod(elem, methodName); // @ts-ignore elem.addEventListener(eventName, listener); elem[`jolt-${eventName}:active`] = true; elem[`jolt-${eventName}:active-method-${methodName}`] = listener; } } /** * Creates listener method for event listener of element * @param {HTMLElement} elem - the HTMLElement with eventListener * @param {string} methodName - name of the method * @returns {CallableFunction} */ _createEventListenerMethod = (elem, methodName) => { return async (event) => { let attrs = this.#getAllCustomAttributes(elem) try{ if(attrs && Object.keys(attrs).length != 0){ await this[methodName](elem, event, attrs); }else{ await this[methodName](elem, event); } }catch(e){ console.error(e); throw new Error(`Could not run method ${methodName} on element ${this.tagName}`); } } } /** * Public acces to _createEventListenerMethod * @param {HTMLElement} elem - the HTMLElement with eventListener * @param {string} methodName - name of the method * @returns {CallableFunction} */ createEventListenerMethod = (elem, methodName) => { return this._createEventListenerMethod(elem, methodName); } /** * Sets event listeners to elements in array * @param {{element: HTMLElement, eventName: string, methodName: string}[]} elementsWithEvents */ _setEventListeners = (elementsWithEvents) => { this.#setEventListeners(elementsWithEvents); } /** * Finds all elements with event listeners * @param {NodeListOf} allElements * @returns {{element: HTMLElement, eventName: string, methodName: string}[]} */ #allElementsWithEvents = (allElements) => { const elementsWithEvents = []; allElements.forEach(element => { elementsWithEvents.push(...this.#elementWithEvent(element)); }); return elementsWithEvents; } /** * Gathers event metadata from an element's attributes. * @param {HTMLElement} elem - The DOM element from which to extract events. * @returns {{element: HTMLElement, eventName: string, methodName: string}[]} * An array of event objects, each containing the element, event name, and method name. */ #elementWithEvent = (elem) => { const events = []; Array.from(elem.attributes).forEach(attr => { if(attr.name.startsWith("jolt-") && !attr.name.startsWith("jolt-show-if")){ events.push({element: elem, eventName: attr.name.replace("jolt-", ""), methodName: attr.value}) } }); return events; } /** * * @param {HTMLElement} elem * @returns {{element: HTMLElement, eventName: string, methodName: string}[]} */ _elementWithEvent = (elem) => { return this.#elementWithEvent(elem); } _allElementsWithEvents = (allElements) => { return this.#allElementsWithEvents(allElements); } /** * Adds functionality to all elements in the markup of the element * @returns {void} */ #hydrate = () => { const elementsWithEvents = this.#allElementsWithEvents( this.querySelectorAll(`[data-parent-id="${this.hashId}"][data-render-time="${this.lastRender}"]`)); this.#setEventListeners(elementsWithEvents); } _hydrate = () => { this.#hydrate(); } /** * Adds the parent name to each html element as a custom attribute (parent-name) * @param {HTMLElement} div * @returns {string} */ #tagAllelementsWithParent = (div) => { div.querySelectorAll(":not([data-parent-id]:not(data-render-time))").forEach((elem) => { elem.setAttribute("data-parent-id", this.hashId); elem.setAttribute("data-render-time", `${this.lastRender}` ); }); return div.innerHTML; } /** * Triggers rerender of entire element * @abstract * @param {CustomEvent} [event] */ rerender = async (event) => { this.#templateSettings.dataBinds = new Map(); await this.#runMethods(this._beforeRerender); await this.render(); await this.#waitForSubelements(); return await this.#runMethods(this._afterRerender); } /** * Generates random hash * @param {Number} length * @returns {string} */ generateHash = (length = 16) => { return this.app.generateHash(length); } /** * Convenience method for getting data from application storage * @param {string} field * @returns {any|undefined} */ getData = (field) => { return this.app.getData(field); } /** * Convenience method for setting data to application storage * @param {string} field * @param {any} data * @throws {Error} - Missing field in app data structure */ setData = (field, data) => { this.app.setData(field, data); } /** * Returns location.search params either as object (true) or as a string (false) * Default: false * @param {boolean} toObject * @returns {string|Object<string, string>} */ getQueryParams = (toObject = false) => { return this.app.getQueryParams(toObject); } /** * Getter for query parameters */ get queryParams(){ return this.app.queryParams } /** * Sets new query(search) params to url based on provided * query parameters object * @param {Object<string, string|number|boolean>} queryParamsObject */ set queryParams(queryParamsObject){ this.app.queryParams = queryParamsObject; } /** * Adds query parameters provided in object * as key-value pairs * @param {Object<string, string|number|boolean>} params */ addQueryParams(params){ this.queryParams = { ...this.queryParams, ...params } } /** * Removes query parameters in provided array * @param {Array<string>} names */ removeQueryParams(names){ this.app.removeQueryParams(names); } /** * Returns the parent CustomElement of current CustomElement if it exists. Top-level * elements (direct children of the app container) don't have this property * @returns {CustomElement|undefined} */ get parent(){ return this._parent; } /** * Returns application router * @returns {Router} */ get router(){ return this.app.router; } /** * Returns url hash * @returns {string} */ get hash(){ return this.app.hash; } /** * Returns url port * @returns {string} */ get port(){ return this.app.port; } /** * Returns url hostname * @returns {string} */ get hostname(){ return this.app.hostname; } /** * Returns url host * @returns {string} */ get host(){ return this.app.host; } /** * Returns url pathname * @returns {string} */ get pathname(){ return this.app.pathname; } /** * Returns url origin * @returns {string} */ get origin(){ return this.app.origin; } /** * Returns route parameters (query string) as object * @returns {string} */ get routeParameters(){ return this.app.router.routeParameters; } /** * Returns data from application storage * based on elements dataField property * @returns {Object} */ get data(){ return this.app.getAllData(true); } /** * Returns render functions defined on the app object * @returns {Object<string, CallableFunction>} */ get functions(){ return this.app.renderFunctions; } /** * Returns application properties * @returns {Object} */ get properties(){ return this.app.properties; } /** * Returns object with registered app extensions * @returns {Object<string, Extension>} */ get ext(){ return this.app.ext; } /** * Getter for authenticator * @returns {Authenticator} */ get authenticator(){ return this.app.authenticator; } /** * Makes fetch (GET) request for markup * @param {string} url * @returns {Promise<string>} */ getHTMLtemplate = async (url) => { try{ const response = await fetch(url, { redirect: "manual" }); if(response.status == 200){ return await response.text(); } this._abort = true; if(this.app?.router){ this.app.router._abortPageLoad(response.status) } else{ console.error(`Failed to fetch html markup for ${this.tagName} with response code ${response.status}`); } return ""; }catch(e){ this._abort = true; if(this.app?.router){ this.app.router._abortPageLoad(500) } else{ console.error(`Failed to fetch html markup for ${this.tagName}. Server failed to respond.`); } return ""; } } /** * Static method to generate html of this element * @param {string} [hashId] * @returns {string} * @static */ static generate(hashId, attrs = null){ if(!attrs){ attrs = {}; } let attrsArray = []; for(const [key, value] of Object.entries(attrs)){ attrsArray.push(`:${key}="${value}"`); } let stringAttrs = attrsArray.length > 0 ? attrsArray.join(" ") : ""; if(!hashId){ return html`<${this.tagName} ${stringAttrs}></${this.tagName}>` } return html`<${this.tagName} data-hash-id="${hashId}" ${stringAttrs}></${this.tagName}>` } } export {CustomElement}; /** * @typedef {Object<string, (name: string) => ValueAccessor>} DefineMethods * An object containing methods that return a getter/setter pair. */ /** * @typedef {Object} ElementConfig * @property {string} tagName - The tag name for the custom element (must be kebab-case). * @property {() => Promise<string>} markup - A function that returns the element's HTML structure. * @property {string|null} [css] - Optional CSS for styling the element. * @property {Object<string, Function>} [methods] - Methods to be added to the element. * @property {Object<string, Function>} [beforeInit] - Lifecycle hooks executed before initialization. * @property {Object<string, Function>} [beforeInitResolve] - Lifecycle hooks executed before resolving initialization. * @property {Object<string, Function>} [afterInit] - Lifecycle hooks executed after initialization. * @property {Object<string, Function>} [beforeRerender] - Hooks executed before re-rendering. * @property {Object<string, Function>} [afterRerender] - Hooks executed after re-rendering. * @property {Object<string, Function>} [afterDisconnect] - Hooks executed after the element is disconnected. * @property {DefineMethods} [define] - Object containing `defineValue` factory functions that return getter/setter pairs. * @property {Object<string, Function>} [templateFunctions] - Functions for dynamic template rendering. */ /** * @template {Record<string, Function>} M * @template {Record<string, (name: string) => { get: () => any, set: (value: any) => void }>} D * @typedef {CustomElement & M & { [K in keyof D]: ReturnType<D[K]> }} ElementType */ /** * Factory function that creates a custom web component class extending `CustomElement`. * @template {Record<string, Function>} M - Methods object. * @template {Record<string, (name: string) => { get: () => any, set: (value: any) => void }>} D - Defined values. * @param {ElementConfig} config - Configuration object for the custom element. * @returns {typeof CustomElement & ElementType<M, D>} A class extending `CustomElement`, dynamically typed. * @throws {Error} If `tagName` or `markup` is missing, or if `tagName` is not in kebab-case. */ function ElementFactory({ tagName, markup, css = null, methods = {}, beforeInit = {}, beforeInitResolve = {}, afterInit = {}, beforeRerender = {}, afterRerender = {}, afterDisconnect = {}, define = {}, templateFunctions = {} }){ if(!tagName || !markup){ throw new Error(`Missing tagName or markup method in ElementFactory`); } const isValidKebabCase = (str) => { const kebabCaseRegex = /^[a-z]+(-[a-z]+)*$/; return kebabCaseRegex.test(str); } /** * Validates if a string is in kebab-case format. * @param {string} str * @returns {boolean} */ if(!isValidKebabCase(tagName)){ throw new Error("Element tagName must be in a valid kebab-case synatx") } return class extends CustomElement{ /** @type {string} */ static tagName = tagName; /** @type {Object<string, Function>} */ _methods = methods; /** @type {() => Promise<string>} */ markup = markup; css = css; /** @type {Object<string, Function>} */ _beforeInit = beforeInit; /** @type {Object<string, Function>} */ _beforeInitResolve = beforeInitResolve; /** @type {Object<string, Function>} */ _afterInit = afterInit; /** @type {Object<string, Function>} */ _beforeRerender = beforeRerender; /** @type {Object<string, Function>} */ _afterRerender = afterRerender; /** @type {Object<string, Function>} */ _afterDisconnect = afterDisconnect; /** @type {DefineMethods} */ _define = define; /** @type {Object<string, Function>} */ _templateFunctions = templateFunctions constructor(){ super(); } } } export default ElementFactory;