UNPKG

@ribajs/core

Version:

Core module of Riba.js

442 lines (390 loc) 13 kB
/** * Auto parse custom element attributes * * @see https://developer.mozilla.org/de/docs/Web/Web_Components/Using_custom_elements */ import "@ribajs/types"; import { EventHandler, ObservedAttributesToCheck, TemplateFunction, ObserverSyncCallback, } from "../types/index.js"; import type { JsxElement } from "@ribajs/jsx"; import { renderElement } from "@ribajs/jsx"; import { Binder } from "../binder.js"; import { Observer } from "../observer.js"; import { parseJsonString, camelCase } from "@ribajs/utils/src/type.js"; import { getRandomColor, consoleColoured } from "@ribajs/utils/src/color.js"; export abstract class BasicComponent extends HTMLElement { public static tagName: string; public _debug = false; public _color?: string; protected templateLoaded = false; /** * Used to check if all passed observedAttributes are initialized */ protected observedAttributesToCheck: ObservedAttributesToCheck = {}; protected observedAttributes: string[] = []; public abstract scope: any; constructor() { super(); if (this._debug) { this._color = getRandomColor(); } } /** * Remove this custom element */ public remove() { if (this && this.parentElement) { this.parentElement.removeChild(this); } } protected abstract template(): ReturnType<TemplateFunction>; protected _log(mode: "debug" | "info" | "log" | "error", ...args: unknown[]) { const namespace = this.constructor.name || this.tagName; if (!this._color) { this._color = getRandomColor(); } consoleColoured({ namespace, color: this._color, mode }, ...args); } protected info(...args: unknown[]) { return this._log("info", ...args); } protected debug(...args: unknown[]) { if (!this._debug) { return; } return this._log("debug", ...args); } protected error(...args: unknown[]) { console.error(...args); } /** * returns a list of attributes which are required until the riba binding starts */ protected requiredAttributes(): string[] { return []; } protected async init(observedAttributes: string[]) { // getPassedObservedAttributes must run first so that observedAttributesToCheck // is populated before loadAttributes triggers attributeChangedCallback calls. // Otherwise the empty dict causes ready() to return true prematurely. this.getPassedObservedAttributes(observedAttributes); this.loadAttributes(observedAttributes); return; } protected ready() { return ( this.allPassedObservedAttributesAreInitialized() && this.checkRequiredAttributes() ); } /** * Check if the attribute (e.g. `src`) is passed to this custom element * @param observedAttribute */ protected attributeIsPassed(observedAttribute: string) { return this.hasAttribute(observedAttribute); } /** * Get passed observed attributes, used to check if all passed attributes are initialized * @param observedAttributes */ protected getPassedObservedAttributes(observedAttributes: string[]) { const oa2c = this.observedAttributesToCheck; for (const observedAttribute of observedAttributes) { if (!oa2c[observedAttribute]) { oa2c[observedAttribute] = { passed: this.attributeIsPassed(observedAttribute), initialized: false, }; } else { if (!oa2c[observedAttribute].passed) { oa2c[observedAttribute].passed = this.attributeIsPassed(observedAttribute); } } } } /** * Checks if all passed observed attributes are initialized */ protected allPassedObservedAttributesAreInitialized() { return Object.keys(this.observedAttributesToCheck).every( (key) => !this.observedAttributesToCheck[key]?.passed || this.observedAttributesToCheck[key]?.initialized, ); } /** * Required attributes before the view is bound * * The attributeChangedCallback is called for each attribute which updates the riba view each time * which can have a big impact on performance or required attributes are not yet available which can lead to errors. * So define required attributes and the view is ony bind the first time after all this attributes are transmitted. */ protected checkRequiredAttributes() { return this.requiredAttributes().every((requiredAttribute) => { requiredAttribute = camelCase(requiredAttribute); if (this.scope.hasOwnProperty(requiredAttribute)) { if ( this.scope[requiredAttribute] === null || typeof this.scope[requiredAttribute] === "undefined" ) { return false; } if (Array.isArray(this.scope[requiredAttribute])) { return this.scope[requiredAttribute].length > 0; } else if (typeof this.scope[requiredAttribute] === "object") { return Object.keys(this.scope[requiredAttribute]).length > 0; } else { return true; } } return false; }); } protected parseAttribute(attr: string | null) { let value: any = attr; if (typeof attr !== "string") { return attr; } if (attr === "true") { value = true; } else if (attr === "false") { value = false; } else if (attr === "null") { value = null; } else if (attr === "undefined") { value = undefined; } else if (attr === "") { value = undefined; } else if (!isNaN(Number(attr))) { value = Number(attr); // If number is too large store the value as string if (value >= Number.MAX_SAFE_INTEGER) { value = attr; } } else { const jsonString = parseJsonString(value); value = jsonString ? jsonString : value; } return value; } /** * Returns an event handler for the bindings (most on-*) inside this component. */ protected eventHandler(self: BasicComponent): EventHandler { // IMPORTANT this must be a function and not a Arrow Functions return function ( context: Binder, event: Event, binding: Binder, el: HTMLElement, ) { if (!this || !this.call) { const error = new Error( `[rv-${binding.type}="${binding.keypath}"] Event handler "${binding.keypath}" not found!"`, ); console.error(binding, el); throw error; } this.call(self, event, binding.view.models, el); }; } /** * Default custom Element method * Invoked when the custom element is first connected to the document's DOM. */ protected connectedCallback() { // console.warn('connectedCallback called'); } /** * Default custom Element method * Invoked when the custom element is disconnected from the document's DOM. */ protected disconnectedCallback() { // if (this.bound && this.view) { // // IMPORTANT ROUTE FIXME, if we unbind the component then it will no longer work if it is retrieved from the cache and the connectedCallback is called // // because the riba attributes are removed. We need a solution for that, maybe we do not remove the attributes or we recreate the attributes // // See view bind / unbind methods for that. // // only unbind if cache is not enabled? // this.unbind(); // } } /** * Default custom Element method * Invoked when one of the custom element's attributes is added, removed, or changed. * @param attributeName * @param oldValue * @param newValue * @param namespace */ protected async attributeChangedCallback( attributeName: string, oldValue: any, newValue: any, namespace: string | null, ) { // this.debug("attributeChangedCallback", attributeName, newValue); if ( this.observedAttributesToCheck && this.observedAttributesToCheck[attributeName] ) { this.observedAttributesToCheck[attributeName].initialized = true; } newValue = this.parseAttribute(newValue); const parsedAttributeName = camelCase(attributeName); if (this.scope && this.scope[parsedAttributeName]) { oldValue = this.scope[parsedAttributeName]; } // automatically inject observed attributes to view scope this.scope[parsedAttributeName] = newValue; // call custom attribute changed callback with parsed values this.parsedAttributeChangedCallback( parsedAttributeName, oldValue, newValue, namespace, ); } /** * Similar to attributeChangedCallback but attribute arguments are already parsed as they are stored in the scope * @param attributeName * @param oldValue * @param newValue * @param namespace */ protected parsedAttributeChangedCallback( attributeName: string, oldValue: any, newValue: any, namespace: string | null, ) { this.debug( "parsedAttributeChangedCallback called", attributeName, oldValue, newValue, namespace, ); } /** * Default custom Element method * Invoked when one of the custom element's attributes is added, removed, or changed. * Note: Not supported on polyfill: https://github.com/webcomponents/custom-elements#known-bugs-and-limitations * @param oldDocument * @param newDocument */ protected adoptedCallback(oldDocument: Document, newDocument: Document) { this.debug("adoptedCallback called", oldDocument, newDocument); } protected async loadTemplate(): Promise< JsxElement | HTMLElement | string | null > { if (this.templateLoaded === true) { // this.debug("template already loaded"); return null; } if (!this.checkRequiredAttributes()) { this.debug("Not all required attributes are set to load the template"); return null; } this.templateLoaded = true; const template = await this.template(); // if innerHTML is null this component uses the innerHTML which he already has! try { if (template) { if (template instanceof HTMLElement) { this.innerHTML = ""; this.appendChild(template as HTMLElement); } else if (typeof template === "string") { this.innerHTML = template; } else if ((template as JsxElement).tag) { this.innerHTML = renderElement(template as JsxElement); } } return template; } catch (error) { this.templateLoaded = false; throw error; } } protected async beforeTemplate(): Promise<any> { // this.debug('beforeTemplate'); } protected async afterTemplate( template: JsxElement | HTMLElement | string | null, ): Promise<any> { this.debug("afterTemplate", template); } protected async onReady(): Promise<any> { // this.debug('onReady', this.bound); } /** * Observes a object keypath in the scope * @param keypath * @param callback */ public observe(keypath: string, callback: ObserverSyncCallback): Observer { return new Observer(this.scope, keypath, callback); } /** * Observes a attributeName * @param attributeName * @param callback */ public observeAttribute( attributeName: string, callback: ObserverSyncCallback, ): Observer { const parsedAttributeName = camelCase(attributeName); return this.observe(parsedAttributeName, callback); } /** * Directly set a attribute value in the scope. * This method is meant to be called from the outside, e.g. from a binder or another component. * @note This method is used in the componentAttributeBinder * @param attributeName * @param newValue * @param namespace */ public setBinderAttribute( attributeName: string, newValue: any, namespace: string | null = null, ) { const parsedAttributeName = camelCase(attributeName); const oldValue = this.scope[parsedAttributeName]; this.attributeChangedCallback(attributeName, oldValue, newValue, namespace); } /** * Directly get a attribute value from the scope. * This method is meant to be called from the outside, e.g. from a binder or another component. * @note This method is used in the componentAttributeBinder * @param attributeName * @returns */ public getBinderAttribute(attributeName: string) { const parsedAttributeName = camelCase(attributeName); const value = this.scope[parsedAttributeName]; return value; } /** * Load all attributes and calls the attributeChangedCallback for each attribute. * Please note: Normally the browser calls the attributeChangedCallback for you */ protected loadAttributes(observedAttributes: string[]) { const attributes = this.attributes; for (const i in attributes) { const attribute: Node = attributes[i]; const name = attribute.nodeName; if (observedAttributes.indexOf(name) !== -1) { const newValue = attribute.nodeValue; this.attributeChangedCallback(name, undefined, newValue, null); } } } }