const abortRegistry = new FinalizationRegistry(controller => controller.abort()) const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase()) export class ChangeEvent extends Event { #final #values constructor(...changes) { super('change') this.changes = changes } get values() { if (!this.#values) { const values = new Map() for (const {property, from, to} of this.changes) { let list = values.get(property) if (!list) { list = [from] values.set(property, list) } list.push(to) } this.#values = values } return this.#values } get final() { if (!this.#final) { this.#final = new Map() for (const [property, list] of this.values) { if (list[0] !== list[list.length-1]) { this.#final.set(property, list[list.length-1]) } } } return this.#final } } export class SimpleState extends EventTarget { #synchronous #queue #nested = new Map() #weakRef = new WeakRef(this) #abortController = new AbortController constructor({synchronous, methods}={}) { super() this.#synchronous = !!synchronous abortRegistry.register(this, this.#abortController) // Try running a "Changed" method for every changed property // Can be disabled to maybe squeeze out some performance if (methods ?? true) { this.addEventListener("change", ({final}) => { final.forEach((value, prop) => { if (`${prop}Changed` in this) this[`${prop}Changed`](value) }) }) } } subscribe(prop, callback) { if (!callback) return this.subscribe("value", prop) const controller = new AbortController() this.addEventListener("change", ({final}) => { if (final.has(prop)) return callback(final.get(prop)) }, {signal: controller.signal}) callback(this.value) return () => controller.abort() } emit(property, from, to, options={}) { const change = {property, from, to, ...options} if (!this.synchronous) { if (!this.#queue) { this.#queue = [] queueMicrotask(() => { this.dispatchEvent(new ChangeEvent(...this.#queue)) this.#queue = undefined }) } this.#queue.push(change) } else { this.dispatchEvent(new ChangeEvent([change])) } } adopt(prop, state) { let handlers = this.#nested.get(state) if (!handlers) { // Actual adoption handlers = new Map() this.#nested.set(state, handlers) } const ref = this.#weakRef const handler = () => ref.deref()?.emit(prop, state, state, {state: true}) handlers.set(prop, handler) state.addEventListener("change", handler, {signal: this.ignal}) } disown(prop, state) { const handlers = this.#nested.get(state) const handler = handlers.get(prop) state.removeEventListener("change", handler) handlers.delete(prop) if (handlers.size == 0) { this.#nested.delete(state) } } get signal() { return this.#abortController.signal } get synchronous() { return this.#synchronous } } export class State extends SimpleState { #target #shallow #forwardCache static isState(object) { return SimpleState.prototype.isPrototypeOf(object) } constructor(target={}, {shallow, ...options}={}) { super(options) this.#shallow = !!shallow this.#target = target this.values = new Proxy(target, { set: (_target, prop, value) => { const old = this.get(prop) if (old !== value) { this.emit(prop, old, value) if (this.#shallow) { if (State.isState(old)) this.disown(prop, old) if (State.isState(value)) this.adopt(prop, value) } this.set(prop, value) } return true }, get: (_target, prop) => this.get(prop), }) } set value(value) { this.values.value = value } get value() { return this.values.value } forward(property="value", fallback) { if (!this.#forwardCache) this.#forwardCache = new Map() const cached = this.#forwardCache.get(property)?.deref() if (cached) { return cached } else { const forwarded = new ForwardState(this, property, fallback) const ref = new WeakRef(forwarded) this.#forwardCache.set(property, ref) forwardFinalizationRegistry.register(forwarded, [this.#forwardCache, property]) return forwarded } } set(...args) { if (args.length === 1) return this.set("value", ...args) const [prop, value] = args this.#target[prop] = value } get(...args) { if (args.length === 0) return this.get("value") const prop = args[0] return this.#target[prop] } } const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => { cache.remove(name) }) export class ForwardState extends SimpleState { #backend #property #fallback constructor(backend, property, fallback) { super() this.#backend = backend this.#property = property this.#fallback = fallback const ref = new WeakRef(this) const abortController = new AbortController() backend.addEventListener("change", event => { const state = ref.deref() if (state) { const relevantChanges = event.changes .filter(({property: name}) => name === property) .map(({from, to}) => ({property: "value", from, to})) if (relevantChanges.length > 0) state.dispatchEvent(new ChangeEvent(...relevantChanges)) } else { abortController.abort() } }, {signal: abortController.signal}) } get value() { return this.#backend.values[this.#property] ?? this.#fallback } set value(value) { this.#backend.values[this.#property] = value } } class StorageChangeEvent extends Event { constructor(storage, key, value, targetState) { super("storagechange") this.storageArea = storage this.key = key this.newValue = value this.targetState = targetState } } export class StoredState extends State { #storage constructor(init, options={}) { super({}, options) this.#storage = options.storage ?? localStorage ?? new MapStorage() // Initialise storage from defaults for (const [prop, value] of Object.entries(init)) { if (this.#storage[prop] === undefined) this.set(prop, value) } // Emit change events for any changed keys for (let i=0; i { if (event.targetState !== this && event.storageArea == this.#storage) { this.emit(event.key, JSON.parse(event.newValue)) } } addEventListener("storage", handler) addEventListener("storagechange", handler) } set(prop, value) { const json = JSON.stringify(value) dispatchEvent(new StorageChangeEvent(this.#storage, prop, json, this)) this.#storage[prop] = json } get(prop) { const value = this.#storage[prop] return value && JSON.parse(value) } } const attributeObserver = new MutationObserver(mutations => { for (const {type, target, attributeName: name} of mutations) { if (type == "attributes") { const next = target.getAttribute(name) const camelName = kebabToCamel(name) if (String(target.state.values[camelName]) !== next) target.state.values[camelName] = next } } }) export const component = (generator, name) => { name = name ?? camelToKebab(generator.name) const Element = class extends HTMLElement{ constructor() { super() this.state = new State(Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value]))) this.state.addEventListener("change", event => { for (const {property, to: value} of event.changes) { const kebabName = camelToKebab(property) if (this.getAttribute(kebabName) !== String(value)) this.setAttribute(kebabName, value) } }) attributeObserver.observe(this, {attributes: true}) this.replaceChildren(generator(this)) } } customElements.define(name, Element) return Element; } class ComposedState extends SimpleState { #func #states constructor(func, options, ...states) { super(options) this.#func = func this.#states = states const abortController = new AbortController() abortRegistry.register(this, abortController) const ref = new WeakRef(this) states.forEach(state => { state.addEventListener("change", event => { const value = event.final.get("value") if (value) ref.deref()?.scheduleUpdate() }, {signal: abortController.signal}) }) this.update() } #microtaskQueued scheduleUpdate() { if (this.defer) { if (!this.#microtaskQueued) { queueMicrotask(() => { this.#microtaskQueued = false this.update() }) } this.#microtaskQueued = true } else { this.update() } } update() { const value = this.#func(...this.#states.map(state => state.value)) const change = {property: "value", from: this.value, to: value} this.value = value this.dispatchEvent(new ChangeEvent([change])) } } export const compose = func => (...states) => new ComposedState(func, {defer: true}, ...states) const eventName = "mutation" class MutationEvent extends Event { constructor() { super(eventName, {bubbles: true}) } } const mutationObserver = new MutationObserver(mutations => { for (const mutation of mutations) { mutation.target.dispatchEvent(new MutationEvent()) } }) export class DOMState extends SimpleState { #target #getValue #equal #old #changedValue = false constructor(target, options) { super(options) this.#target = target this.#getValue = options.get ?? (target => target.value) this.#equal = options.equal ?? ((a, b) => a===b) this.#old = this.#getValue(target) const controller = new AbortController() target.addEventListener(eventName, event=>{this.update(event)}, {signal: controller.signal}) abortRegistry.register(this, controller) mutationObserver.observe(target, { attributes: true, childList: true, characterData: true, subtree: true, }) } get value() { return this.#old } update() { const current = this.#getValue(this.#target) if (this.#equal(this.#old, current)) return this.#old = current if (this.defer) { if (!this.#changedValue) { queueMicrotask(() => { this.#changedValue = false this.dispatchEvent(new ChangeEvent(["value", this.#changedValue])) }) this.#changedValue = current } } else { this.dispatchEvent(new ChangeEvent(["value", current])) } } } export class MapStorage extends Storage { #map = new Map() key(index) { return [...this.#map.keys()][index] } getItem(keyName) { if (this.#map.has(keyName)) return this.#map.get(keyName) else return null } setItem(keyName, keyValue) { this.#map.set(keyName, String(keyValue)) } removeItem(keyName) { this.#map.delete(keyName) } clear() { this.#map.clear() } } export default State