UNPKG

@domx/dataelement

Version:

A DataElement base class for handling data state changes

272 lines (228 loc) 9.22 kB
import { EventMap } from "@domx/eventmap"; import { Middleware } from "@domx/middleware"; export { customDataElements, DataElement, DataElementCtor, DataElementMetaData, DataProperties }; import { RootState } from "./RootState"; /** Add custom element methods to HTMLElement */ declare global { interface HTMLElement { connectedCallback(): void; disconnectedCallback(): void; } } /** Defines the static fields of a DataElement */ interface DataElementCtor extends Function { __elementName: string, dataProperties: DataProperties; stateIdProperty: string } /** Generic object key index accessor */ interface StringKeyIndex<T> { [key:string]:T } /** The static DataProperties as defined on the DataElement */ interface DataProperties extends StringKeyIndex<DataProperty> {} interface DataProperty { changeEvent?: string } /** DataProperty MetaData parsed per stateId */ interface DataPropertiesMetaData extends StringKeyIndex<DataPropertyMetaData> {} interface DataPropertyMetaData { changeEvent: string, statePath: string, windowEventName: string, localEventHandler: EventListener, windowEventHandler: EventListener } interface DataElementMetaData { elementName: string, element: DataElement, dataProperties: DataPropertiesMetaData }; /** * Used to keep track of how many data elements * are using the same state key. */ const stateRefs:StringKeyIndex<number> = {}; const connectedMiddleware = new Middleware(); const disconnectedMiddleware = new Middleware(); /** * Base class for data elements. */ class DataElement extends EventMap(HTMLElement) { static eventsStopImmediatePropagation = true; static __elementName = "data-element"; static stateIdProperty:string = "stateId"; static dataProperties:DataProperties = { state: {changeEvent:"state-changed"} }; static applyMiddlware(connectedFn:Function, disconnectedFn: Function) { connectedMiddleware.use(connectedFn); disconnectedMiddleware.use(disconnectedFn); } static clearMiddleware() { connectedMiddleware.clear(); disconnectedMiddleware.clear(); } __isConnected = false; __dataPropertyMetaData:DataPropertiesMetaData = {}; connectedCallback() { super.connectedCallback && super.connectedCallback(); elementConnected(this); } disconnectedCallback() { super.disconnectedCallback && super.disconnectedCallback(); elementDisconnected(this); } /** * Resets the state and dispatches the changes; * useful when changing the stateId property. * @param states {StateMap} the key is the name of * the state property, and the value is the state to set it to */ refreshState(states:{[key:string]:object}) { const dp = (this.constructor as DataElementCtor).dataProperties; Object.keys(states).forEach((stateName) => { const thisEl = this as unknown as {[key:string]:object}; const changeEvent = dp[stateName].changeEvent as string; thisEl[stateName] = states[stateName]; this.dispatchEvent(new CustomEvent(changeEvent, { detail: {isSyncUpdate: true} })); }); if (this.__isConnected === true) { elementDisconnected(this); elementConnected(this); } } /** * Dispatches a change event on this DataElement. * @param prop {string} the name of the property to dispatch the change event on; default is "state" * @param change {object} checks for JSON equals before dispatching (props must be in same order). */ dispatchChange(prop:string = "state", change?:object) { const dp = this.__dataPropertyMetaData; if (change) { if (JSON.stringify(change) === JSON.stringify((this as any)[prop])) { return; } (this as any)[prop] = change; } this.dispatchEvent(new CustomEvent(dp[prop].changeEvent as string)); } } /** * Custom DataElement Registry */ const customDataElements = { /** * Defines the custom element with window.customElements.define * and tags the element name for use in RootState. * @param elementName {string} * @param element {CustomElementConstructor} */ define: (elementName:string, element:CustomElementConstructor) => { setProp(element, "__elementName", elementName); window.customElements.define(elementName, element); } }; const elementConnected = (el:DataElement) => { const ctor = el.constructor as DataElementCtor; const stateId = getProp(el, ctor.stateIdProperty); const stateIdPath = stateId ? `.${stateId}` : ""; Object.keys(ctor.dataProperties).forEach((propertyName) => { // determine the statePath and window event name const statePath = `${ctor.__elementName}${stateIdPath}.${propertyName}`; const windowEventName = `${statePath}-changed`; // set/update the change event const dp = ctor.dataProperties[propertyName]; const changeEvent = dp.changeEvent || `${propertyName}-changed`; ctor.dataProperties[propertyName] = { changeEvent }; // add to the stateRefs stateRefs[statePath] = stateRefs[statePath] ? stateRefs[statePath] + 1 : 1; // define the local event handler to push changes to RootState // and other elements with the same statePath const localEventHandler = ((event: CustomEvent) => { if (event.detail?.isSyncUpdate !== true) { RootState.set(statePath, getProp(el, propertyName)); triggerGlobalEvent(el, windowEventName); } }) as EventListener; // define the global event handler const windowEventHandler = (event: Event) => { if (getProp<any>(event, "detail")?.sourceElement !== el) { setProp(el, propertyName,RootState.get(statePath)); triggerSyncEvent(el, changeEvent); } }; // store the meta data on the element el.__dataPropertyMetaData[propertyName] = { statePath, changeEvent, localEventHandler, windowEventHandler, windowEventName }; // set initial state const initialState = RootState.get(statePath); if (initialState === null) { RootState.set(statePath, getProp<object>(el, propertyName)); } else { setProp(el, propertyName, initialState); triggerSyncEvent(el, changeEvent); } // add the event handlers el.addEventListener(changeEvent, localEventHandler); window.addEventListener(windowEventName, windowEventHandler); }); connectedMiddleware.mapThenExecute(getMiddlewareMetaData(el), () => {}, []); el.__isConnected = true; }; const getMiddlewareMetaData = (el:DataElement):DataElementMetaData => { const ctor = el.constructor as DataElementCtor; const metaData:DataElementMetaData = { elementName: ctor.__elementName, element: el, dataProperties: el.__dataPropertyMetaData }; return metaData; }; const triggerSyncEvent = (el:DataElement, changeEvent:string) => el.dispatchEvent(new CustomEvent(changeEvent, {detail:{isSyncUpdate:true}})); const triggerGlobalEvent = (el: DataElement, changeEvent:string) => window.dispatchEvent(new CustomEvent(changeEvent, {detail: {sourceElement:el}})) /** * Decrements each data properties state path reference. * If 0, then removes the state from RootState. * Also removes the window event handler * @param el {DataElement} */ const elementDisconnected = (el:DataElement) => { const dpmd = el.__dataPropertyMetaData; Object.keys(dpmd).forEach((propertyName) => { const dp = el.__dataPropertyMetaData[propertyName]; const statePath = dp.statePath; const changeEvent = dp.changeEvent; stateRefs[statePath] = stateRefs[statePath] - 1; stateRefs[statePath] === 0 && RootState.delete(statePath); el.removeEventListener(changeEvent, dp.localEventHandler); window.removeEventListener(dp.windowEventName, dp.windowEventHandler); dp.windowEventHandler = (event:Event) => {} dp.localEventHandler = (event:Event) => {} }); disconnectedMiddleware.mapThenExecute(getMiddlewareMetaData(el), () => {}, []); el.__isConnected = false; }; /** Helper for getting dynamic properties */ const getProp = <T>(obj:object, name:string):T => { //@ts-ignore TS7053 access dynamic property return obj[name] as T; }; /** Helper for setting dyanmic properties */ const setProp = <T>(obj:object, name:string, value:T) => { //@ts-ignore TS7053 access dynamic property obj[name] = value; };