import {html, TemplateResult, render} from 'lit-html/lit-html';
export interface Transition {
event: string;
target: string;
effect?(detail: {[key: string]: any}): {[key: string]: any};
condition?(detail: {[key: string]: any}): boolean;
}
export interface State {
name: string;
transitions: Array;
render?(data: {[key: string]: any}): TemplateResult;
onEntry?(data: {[key: string]: any}): void;
onExit?(data: {[key: string]: any}): void;
}
export interface Machine {
initial: string;
states: { [key: string]: State; };
}
/**
* @description serializes property to a valid attribute
*/
function serializeAttribute(prop:string|boolean):string|null {
if (typeof prop === 'boolean' || prop === undefined) {
return (prop === true) ? '' : null;
} else if (typeof prop === 'object') {
throw new Error('cannot serialize object to attribute');
}
return String(prop);
}
class SMElement extends HTMLElement {
private __data: { [key: string]: any; };
private __state: string;
private __renderRequest: number;
public currentState: State;
public root: Element|DocumentFragment;
public states: { [key: string]: State};
private static __propNamesAndAttributeNames: Map;
constructor() {
super();
this.__data = {};
this.currentState = null;
this.__state = '';
this.states = Object.getPrototypeOf(this).constructor.machine.states;
// setup render target
this.createRenderRoot();
// setup property getter/setters
this.initializeProps_(Object.getPrototypeOf(this).constructor.properties);
// initialze data
this.initializeData_(Object.getPrototypeOf(this).constructor.properties);
}
static get machine(): Machine {
// return a basic, single-state machine by default
return {
initial:'initial',
states: {
initial: {
name: 'initial',
transitions: []
}
}
};
}
static get properties(): {[key: string]: any} {
return {};
}
static get observedAttributes(): Array {
const attributes = [];
if (!this.__propNamesAndAttributeNames) {
this.__propNamesAndAttributeNames = new Map();
}
for(let key in this.properties) {
// every String, Number or Boolean property is observed
const type = this.properties[key].type;
if (type === String || type === Number || type === Boolean) {
const aName = key.toLowerCase();
this.__propNamesAndAttributeNames.set(aName, key);
attributes.push(aName);
}
}
return attributes;
}
get state(): string {
return this.__state;
}
set state(state: string) {
this.__state = state;
// cannot set attributes unless connected
if (this.isConnected) {
this.setAttribute('state', this.__state);
}
// dispatch state-changed event
this.dispatchEvent(new CustomEvent('state-changed', {
detail: {
state: this.state
}
}));
}
get data(): { [s: string]: any; } {
return this.__data;
}
set data(newData: { [key: string]: any; }) {
const props = Object.getPrototypeOf(this).constructor.properties;
// only set props that are defined in 'properties'
const keys = Object.keys(newData);
const update: {[key: string]: any} = keys.reduce((acc, k) => {
if (props[k]) {
acc[k] = newData[k];
}
return acc;
}, {} as {[key: string]: any});
// hold onto old values for a minute
const oldData = Object.assign({}, this.__data);
// update internal data allowing for partial updates
this.__data = Object.assign({}, this.__data, update);
// reflect any attributes that need to be reflected (reflect === true)
// and dispatch any events (notify === true)
for(let key in update) {
const cprop = props[key];
if (cprop.reflect) {
const attribute = serializeAttribute(update[key]);
if (attribute === null) {
this.removeAttribute(key);
} else {
this.setAttribute(key.toLowerCase(), attribute);
}
}
if (cprop.notify) {
this.dispatchEvent(new CustomEvent(`${key}-changed`, {
detail: {
value: newData[key],
oldValue: oldData[key]
}
}));
}
// if prop provides an event, send it along with the new value
if (cprop.event && this.currentState) {
this.send(cprop.event, {
value: newData[key],
oldValue: oldData[key]
});
}
// if prop provides an observer, call it
if(cprop.observer && typeof cprop.observer === 'function') {
cprop.observer.call(this, newData[key], oldData[key]);
}
}
this.requestRender();
}
protected connectedCallback() {
// ensure state attribute is in sync
if (this.getAttribute('state') !== this.__state) {
this.setAttribute('state', this.__state);
}
// set initial state
this.transitionTo_(this.getStateByName_(Object.getPrototypeOf(this).constructor.machine.initial));
// render immediately the first time, so that elements
// can be accessed in connectedCallback()
this.render(this.data);
}
protected disconnectedCallback() {
// nothing to do here. provided for subclasses calling super.disconnectedCallback
}
protected createRenderRoot() {
this.root = this.attachShadow({mode:'open'});
}
protected attributeChangedCallback(name: string, oldVal:string , newVal:string|undefined) {
const propName = Object.getPrototypeOf(this).constructor.__propNamesAndAttributeNames.get(name);
const type = Object.getPrototypeOf(this).constructor.properties[propName].type || String;
let value;
if (type === Boolean) {
if (newVal === ''){
value = true;
} else if (newVal === 'false') {
value = false;
} else {
value = Boolean(newVal);
}
} else {
value = type(newVal);
}
// only update property if it's changed to prevent infinite loops
// with reflected properties.
const self: {[key:string]:any} = this;
if (value !== oldVal && propName && value !== self[propName]) {
self[propName] = value;
}
}
isState(current: State, desired: State) {
return Boolean(current && desired && current.name === desired.name);
}
/**
* @description return true if the current state is one of the supplied states
*/
oneOfState(current: State, ...states: Array) {
return Boolean(states && states.includes(current));
}
/**
* @description reflects the render(data) function of the current state.
*/
currentStateRender(_data:{[key: string]: any}): TemplateResult {
return html``;
}
/**
* @description override in sub classes, defaults to calling the currentStateRender
*/
render(data: {[key: string]: any}): TemplateResult {
return this.currentStateRender(data);
}
/**
* @param {!string} eventName
* @param {object=} detail
*/
send(eventName: string, detail: {[key: string]: any} = {}) {
if (!eventName) {
throw new Error('an event name is required to send!');
}
if (!this.currentState) {
throw new Error(`cannot send with no state: ${eventName}`);
}
// find the appropriate transitions in the current state
const transitions = this.currentState.transitions.filter((t:Transition) => t.event === eventName);
// no matching transitions in this state
if (transitions.length === 0) {
console.warn(`no transitions found in current state: "${this.state}" for event: "${eventName}"`);
return;
}
// with multiple transitions handling the same event,
// check each transition for conditions and throw an error,
// for now, if any transition does not have a condition.
if (transitions.length > 1 && transitions.filter((t:Transition) => !t.condition).length > 0) {
throw new Error(
`multiple transitions found without a condition for event: ${eventName} in state: ${this.state}`);
}
// if multiple transitions, run the first one that has a condition that returns true.
if (transitions.length > 1) {
transitions.some((t:Transition) => {
const passed = t.condition.call(this, detail);
if (passed) {
const nextState = this.getStateByName_(t.target);
// before running the transition, run it's effect
if (t.effect) {
// update data with return from effect
const newData = t.effect.call(this, detail);
this.data = newData ? newData : this.data;
}
// if there is a nextState, transition to it.
if (nextState) {
// run the first passing transition
this.transitionTo_(nextState);
}
}
return passed;// break out of loop if true, before testing more conditions
});
} else {
// only one transition, check for condition first
const transition = transitions[0];
const targetState = this.getStateByName_(transition.target);
// no condition, or condition returns true
if (!transition.condition || (transition.condition && transition.condition.call(this, detail))) {
if (transition.effect) {
// update data with return from effect
const newData = transition.effect.call(this, detail);
this.data = newData ? newData : this.data;
}
// if there is a targetState, transition to it.
if (targetState) {
this.transitionTo_(targetState);
}
}
}
}
/**
* @description convenience for setting event listeners that call send
*/
listenAndSend(eventName: string, detail: {[key: string]: any} = {}) {
return () => this.send(eventName, detail);
}
private transitionTo_(newState: State) {
if (!newState) {
throw new Error('transitionTo_ called without a State');
}
// call onExit if exists
if (this.currentState && this.currentState.onExit) {
this.currentState.onExit.call(this, this.data);
}
this.currentState = newState;
this.currentStateRender = newState.render || function() {return html``};
// udpate state property
this.state = newState.name;
// call onEntry if it exists
if (newState.onEntry) {
newState.onEntry.call(this, this.data);
}
this.requestRender();
}
private getStateByName_(name: string): (State|null) {
// using Object.keys.map instead of Object.values, because not every browser
// supports Object.values
return Object.keys(this.states).map((k: string) => this.states[k]).find(s => s.name === name) || null;
}
private initializeData_(properties: {[key: string]: any}) {
// flatten properties and assign
this.data = Object.keys(properties).reduce((acc: {[key: string]: any}, k) => {
// this happens AFTER props MAY have been set, so use local prop if exists
const self = this as {[key: string]: any};
const local = self[k];
const def = typeof properties[k].value === 'function' ? properties[k].value() : properties[k].value;
acc[k] = local !== undefined ? local : def;
return acc;
}, {});
}
private initializeProps_(properties: {[key: string]: string}) {
// create getter/setter pairs for each property
const init = (key: string) => {
Object.defineProperty(this, key, {
get() {
return this.data[key];
},
set(newVal) {
const update: {[key: string]: any} = {};
update[key] = newVal;
this.data = update;
},
enumerable: true,
});
}
for(let key in properties) {
init(key);
}
}
/**
* @description request a render on the next animation frame
*/
protected requestRender() {
if (this.__renderRequest) {
cancelAnimationFrame(this.__renderRequest);
}
this.__renderRequest = requestAnimationFrame(() => {
if (this.root) {
render(this.render(this.data), this.root);
// should send 'rendered'. how would this work if someone wanted to override requestRender?
// can it be a requirement that requestRender return a promise that resolves
// after render is called? probably not, since multiple things call requestRender..?
// ...
} else {
throw new Error('attempted to render while "this.root" is undefined');
}
});
}
};
export default SMElement;
export {html};