@ribajs/core
Version:
Core module of Riba.js
442 lines (390 loc) • 13 kB
text/typescript
/**
* 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);
}
}
}
}