UNPKG

@ribajs/core

Version:

Core module of Riba.js

397 lines (362 loc) 10.4 kB
/** * This implementation of components replaces the old components of rivets following the Web Components v1 specs * * @see https://developer.mozilla.org/de/docs/Web/Web_Components/Using_custom_elements */ import { View } from "../view.js"; import { Riba } from "../riba.js"; import { Binder } from "../binder.js"; import { BasicComponent } from "./basic-component.js"; import { Formatter } from "../types/index.js"; import { EventDispatcher } from "@ribajs/events"; import type { ComponentLifecycleEventData, ComponentLifecycleOptions, } from "../types/index.js"; export abstract class Component extends BasicComponent { protected view?: View | null = null; protected riba?: Riba; /** true when binding is in progress */ protected _binds = false; /** true when binding is done */ protected _bound = false; /** true when component is connected to the dom */ protected _connected = false; /** true when component is disconnected from the dom */ protected _disconnected = false; protected lifecycleEvents = EventDispatcher.getInstance("lifecycle"); protected lifecycleOptions: ComponentLifecycleOptions = {}; /** true when binding is in progress */ public get binds() { return this._binds; } /** true when binding is done */ public get bound() { return this._bound; } /** true when component is connected to the dom */ public get connected() { return this._connected; } /** true when component is disconnected from the dom */ public get disconnected() { return this._disconnected; } /** * If true the component will automatically bind the component to riba if all required attributes are set. */ protected autobind = true; constructor() { super(); this.lifecycleEvents.trigger( "Component:constructor", this.getLifecycleEventData(), ); this.lifecycleEvents.on( "ComponentLifecycle:allBound", this.afterAllBind, this, ); } protected async init(observedAttributes: string[]) { try { await super.init(observedAttributes); this.lifecycleEvents.trigger( "Component:init", this.getLifecycleEventData(), ); return await this.bindIfReady(); } catch (error) { this.throw(error as Error); } } /** * Use this to throw critical errors within the component * * @protected * @param {Error} error * @memberof Component */ protected throw(error: any) { if (error.message) { console.error(`[${this.tagName}] ${error.message}`); } this.lifecycleEvents.trigger( "Component:error", error, this.getLifecycleEventData(), ); this.error(error); } /** * If `autobind` is true this component will bind riba automatically in this component if all all passed observed and required attributes are initialized */ protected async bindIfReady() { /** * After all required and passed attributes are set we load the template and bind the component */ if (this.ready()) { await this.beforeTemplate(); const template = await this.loadTemplate(); await this.afterTemplate(template); if (this.autobind) { await this.bind(); } await this.onReady(); return; } this.debug( `Not all required or passed attributes are set to load and bind the template`, this.observedAttributesToCheck, this.scope, ); return; } /** Only internal used */ private async _beforeBind(): Promise<any> { this._binds = true; this.debug("Start to bind Riba"); this.lifecycleEvents.trigger( "Component:beforeBind", this.getLifecycleEventData(), ); } /** Used to handle stuff before binding starts */ protected async beforeBind(): Promise<any> { // this.debug("beforeBind", this.scope); } /** Only internal used */ private async _afterBind(): Promise<any> { this._binds = false; this._bound = true; this.lifecycleEvents.trigger( "Component:afterBind", this.getLifecycleEventData(), ); } /** Used to handle stuff after binding is done */ protected async afterBind(): Promise<any> { // this.debug("afterBind", this.scope); } /** * Called after component binding is done for all current components. * Please note: this method is called every time a route changes, so be careful with this method. */ protected async afterAllBind(): Promise<any> { // this.debug("afterBind", this.scope); } protected getLifecycleEventData() { const data: ComponentLifecycleEventData = { tagName: this.tagName.toLowerCase(), // scope: this.scope, component: this, // id: this.id, options: this.lifecycleOptions, }; return data; } /** * Event handler to listen for publish binder event for two-way-binding in web components */ // protected publish(name: string, newValue: any, namespace: string | null) { // this.dispatchEvent( // new CustomEvent("publish-binder-change:" + name, { // detail: { // name, // newValue, // namespace: null, // TODO // }, // }) // ); // } /** * Default custom Element method * Invoked when the custom element is disconnected from the document's DOM. */ protected disconnectedCallback() { try { this._disconnected = true; this._connected = false; super.disconnectedCallback(); // 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? // if (this._binds && this.view) { // this.unbind(); // } this.lifecycleEvents.trigger( "Component:disconnected", this.getLifecycleEventData(), ); this.lifecycleEvents.off( "ComponentLifecycle:allBound", this.afterAllBind, this, ); this.lifecycleEvents.off( "ComponentLifecycle:error", this.afterAllBind, this, ); } catch (error) { this.throw(error as Error); } } /** * Default custom Element method * Invoked when the custom element is first connected to the document's DOM. */ protected connectedCallback() { try { this._disconnected = false; this._connected = true; super.connectedCallback(); this.lifecycleEvents.trigger( "Component:connected", this.getLifecycleEventData(), ); } catch (error) { this.throw(error as Error); } } /** * 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, ) { try { super.attributeChangedCallback( attributeName, oldValue, newValue, namespace, ); await this.bindIfReady(); } catch (error) { this.throw(error as Error); } } /** * Extra call formatter to avoid the "this" context problem */ protected callFormatterHandler(self: this): any { return { name: "call", read: (fn: (...args: any[]) => any, ...args: any[]) => { if (!fn) { throw new Error( `[${self.tagName}] Can not use "call" formatter: fn is undefined!`, ); } return fn.apply(self, args); }, }; } /** * Extra args formatter to avoid the "this" context problem * * Sets arguments to a function without directly call them * @param fn The function you wish to call * @param args the parameters you wish to call the function with */ protected argsFormatterHandler(self: this): Formatter { return { name: "args", read: (fn: (...args: any[]) => any, ...fnArgs: any[]) => { return (event: Event, scope: any, el: HTMLElement, binding: Binder) => { if (!fn) { throw new Error( `[${self.tagName}] Can not use "args" formatter: fn is undefined!`, ); } // append the event handler args to passed args fnArgs.push(event); fnArgs.push(scope); fnArgs.push(el); fnArgs.push(binding); return fn.apply(self, fnArgs); }; }, }; } protected async bind() { if (this.binds || this.bound) { // this.debug("component already bound"); return this.view; } if (!this.checkRequiredAttributes()) { this.debug("Not all required attributes are set for bind"); return; } try { await this._beforeBind(); await this.beforeBind(); this.riba = new Riba(); this.view = this.getView(); if (this.view) { this.scope = this.view.models; this.view.bind(); } await this._afterBind(); await this.afterBind(); } catch (error) { this._binds = false; this._bound = false; this.throw(error as Error); } return this.view; } protected getView() { try { const viewOptions = this.riba?.getViewOptions({ handler: this.eventHandler(this), formatters: { call: this.callFormatterHandler(this), args: this.argsFormatterHandler(this), }, }); if (viewOptions) { const view = new View( Array.prototype.slice.call(this.childNodes), this.scope, viewOptions, ); return view; } return null; } catch (error) { this.throw(error as Error); } } public async unbind() { try { if (this.view) { this._binds = false; this._bound = false; this.view.unbind(); this.view = null; } } catch (error) { this.throw(error as Error); } } protected async build() { try { if (this.view) { this.view.build(); } } catch (error) { this.throw(error as Error); } } }