UNPKG

@domx/statecontroller

Version:

A StateController base class for handling data state changes on a LitElement

204 lines (162 loc) 6.7 kB
import { LitElement, ReactiveController } from "lit"; export class RootStateChangeEvent extends Event { static eventType = "rootstate-change"; event:Event|string; rootState:RootStateContainer; controller:any; statePath:string; state:any; constructor(event:Event|string, rootState:RootStateContainer, controller:any, statePath:string, state:any) { super(RootStateChangeEvent.eventType) this.event = event; this.rootState = {...rootState}; this.controller = controller; this.statePath = statePath; this.state = {...state}; } }; class StatePathChangeEvent extends Event { controller:any; statePath:string; state:any; constructor(controller:any, statePath:string, state:any) { super(statePath); this.controller = controller; this.statePath = statePath; this.state = state; } } type RootStateContainer = {[key:string]: any}; type StateChangeEventListener = (event:StatePathChangeEvent) => void; type RootStateChangeEventListener = (event:RootStateChangeEvent) => void; let rootState:RootStateContainer = {}; /** * Class used to track root state changes */ export class RootState { private static bus = new EventTarget(); private static listenerCounts:{[statePath:string]:number|undefined} = {}; static addStateChangeEventListener(statePath:string, listener:StateChangeEventListener, signal:AbortSignal) { this.bus.addEventListener(statePath, listener as EventListener, {signal} as AddEventListenerOptions); const count = this.listenerCounts[statePath]; this.listenerCounts[statePath] = count === undefined ? 1 : count + 1; // delete state path when aborted signal.addEventListener("abort", () => { const count = this.listenerCounts[statePath]! - 1; this.listenerCounts[statePath] = count; if (count === 0) { delete rootState[statePath]; } }); } /** * Adds an event listener for any changes to the root state. * @param listener * @param signal provide a signal for removing the listener */ static addRootStateChangeEventListener(listener:RootStateChangeEventListener, signal?:AbortSignal) { this.bus.addEventListener(RootStateChangeEvent.eventType, listener as EventListener, {signal} as AddEventListenerOptions); } static get<T>(name:string):T|null { const value = rootState[name]; return value === undefined ? null : value as T; } static change<T>(controller:any, event:Event|string, statePath:string, state:T):true { rootState[statePath] = state; this.bus.dispatchEvent(new StatePathChangeEvent(controller, statePath, state)); this.bus.dispatchEvent(new RootStateChangeEvent(event, rootState, controller, statePath, state)); return true; } static push(state:RootStateContainer) { rootState = state; Object.keys(state).forEach(key => this.bus.dispatchEvent(new StatePathChangeEvent(null, key, rootState[key]))); } static get current():RootStateContainer { return rootState; }; } const stateControllerIndexedProxyHandler:ProxyHandler<StateController> = { get: (target:StateController, property:string) => target[property], set: (target:StateController, property:string, value:any) => target[property] = value }; export class StateController implements ReactiveController { static stateProperties:Array<string> = []; [name:string]: any; /** Returns the stateId property from the host element if defined */ get stateId():string|null { return this.host.stateId !== undefined ? this.host.stateId : null }; /** * Initializes the StateController */ constructor(host: LitElement) { this.host = host; this.abortController = new AbortController(); this.host.addController(this); return new Proxy(this, stateControllerIndexedProxyHandler); } /** The element that the controller is attached to */ host: LitElement & {stateId?:string}; /** Used to signal when the host has been disconnected */ abortController:AbortController; private get stateProperties():Array<string> { return (this.constructor as typeof StateController).stateProperties; } /** * Notifies the root state of the change and calls requestUpdate on the host. * @param event The event responsible for the update */ requestUpdate(event:Event|string) { this.stateProperties.forEach(name => RootState.change(this, event, this.getStateName(name), this[name])); this.host.requestUpdate(); } hostConnected() { // store the stateId this._stateId = this.stateId; this.stateProperties.forEach(name => this.initState(name)); } hostDisconnected() { this.abortController.abort(); this.abortController = new AbortController(); } refreshState(force?:boolean) { if (force === true || this._stateId !== this.stateId) { this.hostDisconnected(); this.hostConnected(); this.host.requestUpdate(); } } /** Override this method to react to state changes */ stateUpdated() {} private _stateId:string|null = null; private initState(name:string) { this.syncStateValue(name); this.addStateListeners(name); } private syncStateValue(name:string) { const statePath = this.getStateName(name); const initialState = RootState.get(statePath); if (initialState === null) { RootState.change(this, `Init.${this.constructor.name}("${name}")`, statePath, this[name]); } else { this[name] = initialState; } } private addStateListeners(name:string) { const statePath = this.getStateName(name); RootState.addStateChangeEventListener(statePath, (event:StatePathChangeEvent) => { if (event.controller !== this) { this[name] = event.state; this.stateUpdated(); this.host.requestUpdate(); } }, this.abortController.signal); } private getStateName(name:string) { const stateIdPath = this.stateId ? `.${this.stateId}` : ""; return `${this.constructor.name}${stateIdPath}.${name}`; } }