import { LifeCycle } from './lifecycle.enum';
// import { GlobalProvider } from '../code/globalprovider';
import { uuidv4, isObject, isNumber, isBoolean, isArray, isString } from '../code/utils';
import { IDirective } from '../types/common';
import { CTOR, CustomElement_Symbol_Selector, ShadowDOM_Symbol_WithShadow, UseParentStyles_Symbol, InjectServices_Symbol, TheParentStyles_Symbol } from '../consts/decorator.props';
/**
* The structure that defines the state object.
*/
export interface ComponentData {
[key: string]: any;
}
const globalStyle_Symbol = Symbol();
/**
* Base class for components. Use in derived classes with a path to a template file, and additional setup steps callback.
* Override 'render' method (mandatory) for event wiring and data/dom manipulation or creation (dynamic part).
*
* If the component shall show nothing or has temporarily nothing to render just return `null`.
*
* Components must be decorated with at least the {@link {CustomElement}} decorator. That defines the name is required to render properly.
* Additional class decorators are available:
*
* * {@link InjectService:} Injects a service class und a singleton instance becomes avaiable through the property `services`.
*
* After the render method has been called the first time the property `initialized` becomes `true`.
* All properties can be bound, so any change will re-render the content. See {@link {Properties}} decorator.
* If you use *jsx* in the render method you must import JSX function. This is same behavior as in React. It isn't React, though.
*
* https://www.mikedoesweb.com/2017/dynamic-super-classes-extends-in-es6/
*/
export abstract class BaseComponent
extends HTMLElement {
/**
* A copy of the global styles statically set for all components. First access fills it in, then it's cached.
*/
private static readonly [globalStyle_Symbol]: string;
/**
* Set by decorator {@link {UseShadowDOM}}. A shadowed component is technically isolated.
*/
public static readonly [ShadowDOM_Symbol_WithShadow]: boolean;
/**
* Set by decorator {@link {CustomElement}}. It's the element's name in CSS selector style.
*/
public static readonly [CustomElement_Symbol_Selector]: string;
/**
* Global directive registration that all component have access to.
*/
public static registeredDirectives: Map;
/**
* Observe all registered attributes. The source field is set by the {@link {Properties}} decorator.
*/
protected static get observedAttributes(): string[] {
if (isArray((this as any).__observedAttributes__)) {
return ['onlifecycle', ...((this as any).__observedAttributes__ as Array)];
} else {
return ['onlifecycle'];
}
}
/**
* Promote the lifecycle changes:
* * first by a sync call of the {@link LifeCycle} method
* * second by dispatching the 'onlifecycle' event. Use addEventListener('lifecycle', ...) to catch the event.
* * third a property 'onLifecycle' is being called if a handler function is attached.
*/
protected set lifeCycleState(lc: LifeCycle) {
this._lifeCycleState = lc;
// for async apps also an event
const lifeCycleEvent = new CustomEvent('lifecycle', {
bubbles: true,
cancelable: false,
composed: false,
detail: lc
});
// handlers first, so they could cancel
if (this.dispatchEvent(lifeCycleEvent)) {
// sync lifecycle callback
this.lifeCycle(lc);
}
}
private _lifeCycleState: LifeCycle;
private _data: P; // Proxy to intercept access and get smart render
private __data = {}; // hidden backup field
public isInitalized = false;
public onlifecycle: Function;
/**
* The constructor checks the various decorator settings and calls initializer routines that
* have been marked as ctor dependency.
*/
constructor() {
super();
this.__uniqueId__ = uuidv4();
this.__data = Object.assign({}, (this.constructor).__proxyInitializer__);
this._data = new Proxy(this.__data as P, this.proxyAttributeHandler);
this.lifeCycleState = LifeCycle.Init;
window.addEventListener('message', this.receiveMessage.bind(this), false);
if (this.constructor[UseParentStyles_Symbol] && this.constructor[ShadowDOM_Symbol_WithShadow] && !this.constructor[globalStyle_Symbol]) {
this.constructor[globalStyle_Symbol] = '';
// decorator exists and has no parameter = use parent styles
if (isBoolean(this.constructor[UseParentStyles_Symbol]) && this.constructor[UseParentStyles_Symbol] === true) {
for (let i = 0; i < this.ownerDocument.styleSheets.length; i++) {
const css: CSSStyleSheet = this.ownerDocument.styleSheets[i] as CSSStyleSheet;
try {
if (!css.rules || css.rules.length === 0 || css.rules[0].cssText.startsWith(':ignore')) {
continue;
}
this.constructor[globalStyle_Symbol] += Object.keys(css.cssRules)
.map(k => css.cssRules[k].cssText ?? ' ')
.join(' ');
} catch {
console.warn('CORS violation while loading external style sheet. Adapt CORS or load from own server.');
}
}
} else {
const css: StyleSheet = this.constructor[TheParentStyles_Symbol] as StyleSheet;
this.constructor[globalStyle_Symbol] += Object.keys(css)
.map(k => css[k])
.join(' ');
}
}
// look for plugins that require ctor initialization, __ctor__ pattern
if (CTOR in Object.getPrototypeOf(this) && isArray(this[CTOR])) {
// this is an array in case multiple decorators which to be called
(this[CTOR] as any[]).forEach((propName: string) => {
this[propName] = this;
// tslint:disable-next-line: no-unused-expression
void this[propName];
});
}
}
__uniqueId__: any;
proxyAttributeHandler = {
get: (obj: P, prop: string) => {
return this.__data[prop] ?? this.getAttribute(prop);
},
set: (obj: P, prop: string, value: any, receiver: any): boolean => {
(this).__data[prop] = value;
// track changes to properties accessed from code directly, this triggers the change callback
// this.setAttribute(prop, value);
this.refresh();
return true;
}
};
// ability to send data to elements from main window (for Electron only)
private receiveMessage(event: any) {
if (event.data.type === 'setData' && (event.data.target === this.readAttribute('id', '') || this.localName === event.data.target)) {
this.setData.apply(this, event.data.args);
}
}
/**
* Inform caller about reaching a state in life cycle.
*
* @param cycle The state reached.
*/
protected lifeCycle(cycle: LifeCycle): void { }
/**
* Return the last state the life cycle has reached. This is being set immediately before the event {@link {lifeCycle}} is fired.
*/
protected get currentLifeCycle() {
return this._lifeCycleState;
}
/**
* Implement and return a string with HTML. Ideally use JSX to create elements.
*/
public abstract render(): any;
/**
* Clean up any resources here.
*/
protected dispose(): void { }
/**
* Get the assigned state data the component holds internally.
* @param key Optionally pull just one key from the dictionary.
*/
protected get data(): P {
return this._data;
}
/**
* Returns the service's instance. Defined using the {@link InjectService} decorator. The decorator uses a local name for the custom service.
* @param service The name of a registered service
*/
protected services(serviceId: string): S {
if (!this[InjectServices_Symbol]) {
console.error(`The service with id ${serviceId} was requested, but no setup could be found. Add @InjectService decorator on class level.`)
}
return this[InjectServices_Symbol].get(serviceId) as S;
}
/**
* Call this method to dispatch a custom event. Returns the result of the event handler conform to default ECMAScript events.
* @param name Dispatch a custom event bound to the current component.
* @param data Some custom data and settings.
*/
protected dispatch(name: string, data: CustomEventInit): boolean {
const thisEvent = new CustomEvent(name + '_' + this.constructor.name, data);
return super.dispatchEvent(thisEvent);
}
/**
* Refresh the content after changes. Called automatically after changes of observed attributes.
* Fires the {@link LifeCycle}.PreRender before and the {@link LifeCycle}.Load event after the content is being written.
* The {@link lifeCycle} hook is called accordingly.
*/
private setup(): void {
this.lifeCycleState = LifeCycle.PreRender;
const childLoaders: Array = [];
this.lifeCycleDetector(this.childNodes, childLoaders);
let $this: BaseComponent = null;
if ((this.constructor)[ShadowDOM_Symbol_WithShadow] !== undefined) {
if (!this.shadowRoot) {
const mode = (this.constructor)[ShadowDOM_Symbol_WithShadow] ? 'open' : 'closed';
// first time call we create the shadow root
this.attachShadow({ mode: mode });
// any style to copy to shadow
if ((this.constructor)[globalStyle_Symbol]) {
// copy styles to shadow if shadowed and there is something to add
const style = document.createElement('style');
style.textContent = (this.constructor)[globalStyle_Symbol];
(this.shadowRoot || this).appendChild(style);
}
} else {
const hasStyle = (this.constructor)[globalStyle_Symbol] ? 1 : 0;
for (let index = this.shadowRoot.childNodes.length - 1; index >= hasStyle; index--) {
(this.shadowRoot || this).childNodes.item(index).remove();
}
}
$this = this.shadowRoot as unknown as BaseComponent || this;
} else {
$this = this;
}
this.refresh();
if (childLoaders.length > 0) {
Promise.all(childLoaders).then((state) => {
this.lifeCycleState = LifeCycle.Render;
});
} else {
this.lifeCycleState = LifeCycle.Render;
}
}
/**
* Refresh content after changes without re-creating the whole component
*/
protected refresh(): void {
this.lifeCycleState = LifeCycle.PreRender;
const renderTree = this.render();
const renderNewContent = isArray(renderTree) ? [...renderTree] : [renderTree];
const target = (this.shadowRoot ?? this);
let preservedStyle: ChildNode = null;
if ((this.constructor)[globalStyle_Symbol]) {
// assume we have a style element and need to protect it
preservedStyle = target.firstChild;
}
while (target.firstChild) {
target.removeChild(target.lastChild);
}
if (preservedStyle) {
target.prepend(preservedStyle);
}
const finishRenderProcessing = () => {
if (BaseComponent.registeredDirectives) {
BaseComponent.registeredDirectives.forEach((directive: IDirective, selector: string) => {
target.querySelectorAll(selector).forEach((hostElement) => {
const d = new directive(hostElement);
// TODO: Keep instance and dispose
});
});
}
this.lifeCycleState = LifeCycle.Load;
};
const processSingleRender = (nodes: Node[]) => {
nodes.forEach(node => {
if (node instanceof Node) {
target.appendChild(node);
}
});
};
if (renderNewContent) {
if (renderNewContent.every((c: Promise) => c instanceof Promise)) {
Promise.all(renderNewContent)
.then(nodes => processSingleRender(nodes))
.finally(() => finishRenderProcessing());
} else {
renderNewContent.forEach((c: Element) => {
try {
if (c instanceof Node) {
target.appendChild(c);
} else {
console.warn('Unknown render type. ', c);
}
} catch (err) {
console.error(err);
}
});
finishRenderProcessing();
}
// attach directives, if any
}
}
// observe all web components of the first level and wait for lifecycle === load
private lifeCycleDetector(children: NodeListOf, childLoaders: Array) {
children.forEach((childNode) => {
if (childNode instanceof BaseComponent) {
const p = new Promise(resolve => {
childNode.addEventListener('lifecycle', (e: CustomEvent) => {
if (e.detail === LifeCycle.Render) {
resolve(e.detail);
}
});
});
childLoaders.push(p);
} else {
if (childNode.childNodes.length > 0) {
this.lifeCycleDetector(childNode.childNodes, childLoaders);
}
}
});
}
/**
* Change the state of the internal data object. If necessary, the component re-renders. Render can be enforced.
* Omit the @param reRender to not enforce render. Observed attributes will still render.
*
* @param name Name of the property. Must be defined in the generic P to assure being observed.
* @param newValue The actual new value.
*/
public setData(name: keyof (P), newValue: any): void {
this.lifeCycleState = LifeCycle.SetData;
(this.data)[name] = newValue;
}
private attributeChangedCallback(name: string, oldValue: any, newValue: any) {
if (this._lifeCycleState === LifeCycle.SetData) {
return;
}
if (oldValue !== newValue) {
(this.data as ComponentData)[name] = newValue;
}
}
private connectedCallback() {
this.lifeCycleState = LifeCycle.Connect;
this.setup();
this.isInitalized = true;
}
/**
* Returns the attributes value. If it doesn't exists it can return the given default. The attribute does not change, though.
*/
protected readAttribute(name: string, defaultValue?: any) {
return this.attributes[name] === undefined ? defaultValue : this.attributes[name].value;
}
private disconnectedCallback() {
this.lifeCycleState = LifeCycle.Disconnect;
// run internal disposings
Object.keys(this).forEach(k => {
if (k.startsWith('__dispose__')) {
try {
if (this[k]) {
this[k]();
}
} catch (err) {
// dispose function error
}
}
});
this.dispose();
this.lifeCycleState = LifeCycle.Disposed;
}
private adoptedCallback() {
this.lifeCycleState = LifeCycle.Adopted;
}
}