ce-decorators
Version:
Custom Element decorators for typescript
347 lines (286 loc) • 10.9 kB
JavaScript
/**
* Copyright (c) 2018 Mathis Zeiher
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
import { camelToKebapCase, kebapToCamelCase, deserializeValue, serializeValue } from './utils';
import { getClassProperties } from './classproperties';
import { COMPONENT_STATE } from './componentstate';
import { PROPERTY_STATE } from './propertystate';
import { getClassPropertyWatcher } from './classpropertywatcher';
import { getClassPropertyInterceptor } from './classpropertyinterceptors';
import { addComponentToRenderPipeline, removeComponentFromRenderPipeline } from './renderer/pipeRenderer';
import { renderComponent } from './renderer/renderComponent';
/**
* interface for an indexable element
*/
/**
* define the render strategy for the control
*/
export let RENDER_STRATEGY;
/**
* Base class for all custom elements
*/
(function (RENDER_STRATEGY) {
RENDER_STRATEGY[RENDER_STRATEGY["DEFAULT"] = 0] = "DEFAULT";
RENDER_STRATEGY[RENDER_STRATEGY["LAZY"] = 1] = "LAZY";
RENDER_STRATEGY[RENDER_STRATEGY["PIPELINE_EXPERIMENTAL"] = 2] = "PIPELINE_EXPERIMENTAL";
})(RENDER_STRATEGY || (RENDER_STRATEGY = {}));
export class CustomElement extends HTMLElement {
/* tslint:disable-next-line */
static _fromAttribute(name, oldValue, newValue, instance) {
if (instance._propertyState !== PROPERTY_STATE.REFLECTING) {
const propertyName = kebapToCamelCase(name);
const classProperty = getClassProperties(this).get(propertyName);
oldValue = instance[propertyName];
if (classProperty.converter) {
if (classProperty.converter.fromAttribute) {
newValue = classProperty.converter.fromAttribute(newValue, classProperty.type); // tslint:disable-line:no-unsafe-any
} else {
newValue = classProperty.converter(newValue, classProperty.type); // tslint:disable-line:no-unsafe-any
}
} else {
newValue = deserializeValue(newValue, classProperty.type); // tslint:disable-line:no-unsafe-any
}
if (oldValue !== newValue) {
instance._propertyState = PROPERTY_STATE.UPDATE_FROM_ATTRIBUTE;
this._fromProperty(propertyName, oldValue, newValue, instance);
}
}
}
/* tslint:disable-next-line */
static _fromProperty(propertyKey, oldValue, newValue, instance) {
if (oldValue !== newValue) {
const classProperty = getClassProperties(this).get(propertyKey);
const interceptor = getClassPropertyInterceptor(this, propertyKey);
newValue = interceptor.reduce((value, func) => {
return func.apply(instance, [oldValue, value]) || value;
}, newValue);
this._reflectAttributes(classProperty, instance, newValue, propertyKey);
instance._propertyState = PROPERTY_STATE.UPDATE_PROPERTY;
instance[propertyKey] = newValue;
instance._propertyState = PROPERTY_STATE.DIRTY;
const watcher = getClassPropertyWatcher(this, propertyKey);
watcher.forEach(value => value.apply(instance, [oldValue, newValue])); // tslint:disable-line:no-unsafe-any
instance.scheduleRender();
}
}
/* tslint:disable-next-line */
static _reflectAttributes(classProperty, instance, newValue, propertyKey) {
if ((classProperty.reflectAsAttribute || classProperty.reflectAsAttribute === undefined) && instance._componentState !== COMPONENT_STATE.INIT) {
if (classProperty.type === Boolean || classProperty.type === String || classProperty.type === Number || classProperty.reflectAsAttribute === true) {
if (instance._propertyState !== PROPERTY_STATE.UPDATE_FROM_ATTRIBUTE) {
instance._propertyState = PROPERTY_STATE.REFLECTING;
if (newValue === false || newValue === null || newValue === undefined) {
instance.removeAttribute(camelToKebapCase(propertyKey));
} else {
if (classProperty.converter && classProperty.converter.toAttribute) {
instance.setAttribute(camelToKebapCase(propertyKey), classProperty.converter.toAttribute(newValue, classProperty.type));
} else {
instance.setAttribute(camelToKebapCase(propertyKey), serializeValue(newValue, classProperty.type));
}
}
}
}
}
}
static get observedAttributes() {
// filter out states -> type === undefined
return Array.from(getClassProperties(this)).filter(value => value[1].type !== undefined).map(value => camelToKebapCase(value[0].toString()));
}
constructor() {
super();
this._renderStrategy = RENDER_STRATEGY.DEFAULT;
this._renderCallbackResolver = null;
this._componentState = COMPONENT_STATE.INIT;
this._propertyState = PROPERTY_STATE.DIRTY;
this._renderScheduled = false;
this._templateCache = null;
this._firstRender = true;
this._renderCompletedCallbacks = [];
this._constructedCompletedCallbacks = [];
this._layoutRAFReference = null;
Promise.resolve().then(() => {
if (this._componentState === COMPONENT_STATE.INIT) {
this._componentState = COMPONENT_STATE.CONSTRUCTED;
this._constructedCompletedCallbacks.forEach(value => value());
this._constructedCompletedCallbacks = [];
}
});
}
/**
* should return the DOM to be rendered
*/
/**
* is called when the element is attached to the DOM
*/
componentConnected() {} // tslint:disable-line
/**
* is called when the element is dettached from the DOM
*/
componentDisconnected() {} // tslint:disable-line
/**
* is called just before render() will be exexuted
*/
componentWillRender() {} // tslint:disable-line
/**
* is called just after render() will be exexuted
*/
componentDidRender() {} // tslint:disable-line
/**
* is called just after the first render()
*/
componentFirstRender() {} // tslint:disable-line
/**
* is called after render and broser layouting
*/
componentDidLayout() {} // tslint:disable-line
/**
* return element whre the DOM from render will be rendered to
*/
renderToElement() {
if (!this.shadowRoot) {
this.attachShadow({
mode: 'open'
});
}
return this.shadowRoot;
}
/**
* return a Promise which will be resolved after
* construction of the element
*
* @returns Promise<void> promise which will resolve after construction is complete
*/
async waitForConstruction() {
return new Promise(resolve => {
this._constructedCompletedCallbacks.push(resolve);
});
}
/**
* return a Promise which will be resolved after a
* successfull render
*
* @returns Promise<void>
*/
async waitForRender() {
return new Promise(resolve => {
this._renderCompletedCallbacks.push(resolve);
});
}
/**
* Schedule a new render (the render will only be scheduled) if
* the componentstate is CONNECTED and propertystate is DIRTY
*
* force will force a re-render
*
* @param force force the re-render
*/
scheduleRender(force = false) {
if (this._componentState === COMPONENT_STATE.CONNECTED && this._propertyState === PROPERTY_STATE.DIRTY && !this._renderScheduled) {
this._renderScheduled = true;
switch (this._renderStrategy) {
case RENDER_STRATEGY.PIPELINE_EXPERIMENTAL:
addComponentToRenderPipeline(this);
break;
case RENDER_STRATEGY.LAZY:
if (!force) {
new Promise(resolve => {
setTimeout(resolve);
this._renderCallbackResolver = resolve;
}).then(() => {
renderComponent.apply(this);
this._renderCallbackResolver = null;
});
break;
}
default:
Promise.resolve().then(() => {
renderComponent.apply(this);
});
break;
}
} else if (force) {
if (this._renderScheduled) {
if (this._renderCallbackResolver) {
Promise.resolve().then(() => {
this._renderCallbackResolver();
});
return;
} else if (this._renderStrategy === RENDER_STRATEGY.PIPELINE_EXPERIMENTAL) {
removeComponentFromRenderPipeline(this);
} else {
return; // render already scheduled as microtask
}
}
Promise.resolve().then(() => {
renderComponent.apply(this);
});
}
}
/**
* build-in function please do not override
*/
connectedCallback() {
if (this._componentState === COMPONENT_STATE.INIT || this._componentState === COMPONENT_STATE.CONSTRUCTED) {
// on first connected reflect attributes
this._componentState = COMPONENT_STATE.CONNECTED;
const _originalPropertyState = this._propertyState;
this._propertyState = PROPERTY_STATE.REFLECTING;
const properties = getClassProperties(this.constructor);
properties.forEach((value, key) => {
const propValue = this[key.toString()];
if (propValue || propValue === 0) {
this.constructor._reflectAttributes(value, this, propValue, key.toString());
}
});
this._propertyState = _originalPropertyState;
} else {
this._componentState = COMPONENT_STATE.CONNECTED;
}
this.componentConnected();
this.scheduleRender();
}
/**
* build-in function please do not override
*/
disconnectedCallback() {
this._componentState = COMPONENT_STATE.DISCONNECTED;
this.componentDisconnected();
}
/**
* build-in function please do not override
*/
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.constructor._fromAttribute(name, oldValue, newValue, this);
}
}
}
/**
* shorthand for lazy rendered custom element
*/
export class LazyCustomElement extends CustomElement {
constructor() {
super();
this._renderStrategy = RENDER_STRATEGY.LAZY;
}
}
/** helper for shimmed browsers */
(function () {
// tslint:disable-line
if ('ShadyCSS' in window && typeof window.ShadyCSS.ScopingShim.prepareAdoptedCssText === 'undefined') {
console.error('Please check your "@webcomponents/webcomponentsjs" polyfill, minimum version 2.2.6 required'); // tslint:disable-line
}
})();
//# sourceMappingURL=customelement.js.map