Compare commits

..

14 commits

Author SHA1 Message Date
9983c57f23 Refactor 2024-02-01 14:00:26 +01:00
017cd9fbd2 Fix and refactor skooma.js 2024-02-01 14:00:26 +01:00
515ba550cf Fix bug in composed state 2024-02-01 14:00:26 +01:00
8cca0813d9 Refactor state module 2024-02-01 14:00:26 +01:00
97a89627b9 Make domLense error on non-element targets 2024-02-01 14:00:26 +01:00
904a5daf7c Stop removing "on" prefix from event names in skooma 2024-02-01 14:00:26 +01:00
b25a3013d2 Change skooma to use svelte store contract
Since States are now also valid svelte stores, skooma now uses that API
to decide whether something is an observable state and to interact with
it.
2024-02-01 14:00:26 +01:00
e293594edb Refactor skooma.js 2024-02-01 14:00:26 +01:00
e75d90073e Add methods param to component generator 2024-02-01 14:00:26 +01:00
38b2127920 Add get/set filters to forward state & remove cache
Forwarded states are no longer cached, because caching would have to
happen individually for every attribute and methods combination.
2024-02-01 14:00:26 +01:00
3f838821e4 Add support for child generators as skooma args
Function arguments to skooma generators are now only treated as
initialisers if they take at least one named argument. Otherwise they
are called and their return value is evaluated as their replacement.

The process is recursive and can happen repeatedly for functions that
themselves return functions.
2024-02-01 14:00:26 +01:00
75fc1a7ce7 Refactor get/set to use arguments instead of ... 2024-02-01 13:35:57 +01:00
71e086cf04 Change domLense method semantics 2024-02-01 13:35:57 +01:00
2db5871cc9 Refactor API and Code
* Unified more features in SimpleState
* Flip "defer" into "synchronous" option
* Remove fallback from forward states
2024-02-01 13:35:57 +01:00
5 changed files with 542 additions and 629 deletions

View file

@ -1,5 +1,19 @@
export const domArray = (methods, extra) => { class ChildObserver extends MutationObserver {
if (extra) return domArray(extra)(methods) constructor() {
super(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new CustomEvent("change", {detail: mutation}))
}
})
}
observe(element) {
MutationObserver.prototype.observe.call(this, element, { childList: true })
}
}
export const lense = (methods, extra) => {
if (extra) return lense(extra)(methods)
const traps = { const traps = {
get(target, prop) { get(target, prop) {
@ -55,20 +69,9 @@ export const domArray = (methods, extra) => {
} }
return element => { return element => {
if (!(element instanceof Element)) throw(new Error("Creating domArray on non-element")) if (!(element instanceof Element)) throw(new Error("Creating domLense on non-element"))
return new Proxy(element, traps) return new Proxy(element, traps)
} }
} }
export const meta = (element=document.head) => new Proxy(element, { export default lense
get: (target, name) => target.querySelector(`meta[name="${name}"]`)?.content,
set: (target, name, value) => {
let meta = target.querySelector(`meta[name="${name}"]`)
if (!meta) {
meta = document.createElement("meta")
meta.name = name
target.append(meta)
}
meta.content = value
}
})

View file

@ -1,6 +0,0 @@
{
"compilerOptions": {
"checkJs": true,
"target": "es2021"
}
}

View file

@ -1,428 +0,0 @@
/** @type FinalizationRegistry<AbortController> */
const abortRegistry = new FinalizationRegistry(controller => controller.abort())
/** @param {String} string */
const camelToKebab = string => string.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`)
/** @param {String} string */
const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a+b.toUpperCase())
const identity = object=>object
const target = Symbol("Proxy Target")
/* Custom Event Classes */
export class SynchronousChangeEvent extends Event {
constructor(change) {
super('synchronous', {cancelable: true})
this.change = change
}
}
export class MultiChangeEvent 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 ValueChangeEvent extends MultiChangeEvent {
get value() {
return this.final.value
}
}
/* Observable Classes */
export class Observable extends EventTarget {
#synchronous
/** @type Array<{name:string, from, to}> */
#queue
#abortController = new AbortController
#ref = new WeakRef(this)
get ref() { return this.#ref }
observable = true
constructor({synchronous}={}) {
super()
if (this.constructor === Observable) {
throw new TypeError("Cannot instantiate abstract class")
}
this.#synchronous = !!synchronous
abortRegistry.register(this, this.#abortController)
this.proxy = new Proxy(this.constructor.prototype.proxy, {
get: (target, prop) => target.call(this, prop)
})
}
proxy(prop, {get, set, ...options}={}) {
const proxy = new ProxiedObservableValue(this, prop, options)
if (get) proxy.get = get
if (set) proxy.set = set
return proxy
}
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()
}
enqueue(property, from, to, mutation=false) {
const change = {property, from, to, mutation}
if (!this.dispatchEvent(new SynchronousChangeEvent(change))) return false
if (!this.synchronous) {
if (!this.#queue) {
this.#queue = []
queueMicrotask(() => {
this.emit(...this.#queue)
this.#queue = undefined
})
}
this.#queue.push(change)
} else {
this.emit(change)
}
return true
}
emit() {
throw new TypeError(`${this.constructor.name} did not define an 'emit' method`)
}
get signal() { return this.#abortController.signal }
get synchronous() { return this.#synchronous }
}
export class ObservableObject extends Observable {
#shallow
constructor(target={}, {shallow, ...options}={}) {
super(options)
this.#shallow = !!shallow
this[target] = target
this.values = new Proxy(target, {
set: (target, prop, value) => {
const old = target[prop]
if (old === value) {
return true
} else {
if (this.enqueue(prop, old, value)) {
if (!this.#shallow) {
if (old instanceof Observable) this.disown(prop, old)
if (value instanceof Observable) this.adopt(prop, value)
}
target[prop] = value
return true
} else {
return false
}
}
},
get: (target, prop) => target[prop],
})
}
proxy(prop, {get, set, ...options}={}) {
const proxy = new ProxiedObservableValue(this, prop, {values: this.values, ...options})
if (get) proxy.get = get
if (set) proxy.set = set
return proxy
}
emit(...changes) {
this.dispatchEvent(new MultiChangeEvent(...changes))
}
/** @type Map<Observable, Map<String, Function>> */
#nested = new Map()
adopt(prop, observable) {
let handlers = this.#nested.get(observable)
if (!handlers) {
// Actual adoption
handlers = new Map()
this.#nested.set(observable, handlers)
}
const ref = this.ref
const handler = () => ref.deref()?.emit(prop, observable, observable, {observable: true})
handlers.set(prop, handler)
observable.addEventListener("change", handler, {signal: this.signal})
}
disown(prop, observable) {
const handlers = this.#nested.get(observable)
const handler = handlers.get(prop)
observable.removeEventListener("change", handler)
handlers.delete(prop)
if (handlers.size == 0) {
this.#nested.delete(observable)
}
}
}
export class ObservableValue extends Observable {
#value
constructor(value, options) {
super(options)
this.#value = value
}
get value() { return this.#value }
set value(value) {
if (this.enqueue("value", this.#value, value)) {
this.#value = value
}
}
emit(...changes) {
this.dispatchEvent(new ValueChangeEvent(...changes))
}
}
class ProxiedObservableValue extends ObservableValue {
#backend
#values
#prop
constructor(backend, prop, {values=backend, ...options}={}) {
super(options)
this.#backend = backend
this.#values = values
this.#prop = prop
const ref = this.ref
backend.addEventListener("synchronous", event => {
const {property, from, to, ...rest} = event.change
if (property == this.#prop) {
ref.deref()?.enqueue({
property,
from: this.get(from),
to: this.get(to),
...rest
})
}
}, { signal: this.signal })
}
get = identity
set = identity
get value() { return this.get(this.#values[this.#prop]) }
set value(value) { this.#values[this.#prop] = this.set(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 = (name, generator, methods) => {
if (typeof name === "function") {
methods = generator
generator = name
name = camelToKebab(generator.name)
}
const Element = class extends HTMLElement{
constructor() {
super()
const target = Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value]))
this.state = new ObservableObject(target)
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})
const content = generator.call(this, this.state)
if (content) this.replaceChildren(content)
}
}
if (methods) {
Object.defineProperties(Element.prototype, Object.getOwnPropertyDescriptors(methods))
}
customElements.define(name, Element)
return Element;
}
class ObservableComposition extends ObservableValue {
#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", () => {
ref.deref()?.scheduleUpdate()
}, {signal: abortController.signal})
})
this.update()
}
#microtaskQueued
scheduleUpdate() {
if (this.synchronous) {
this.update()
} else {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
this.update()
})
this.#microtaskQueued = true
}
}
}
update() {
const value = this.#func(...this.#states.map(state => state.value))
const change = {property: "value", from: this.value, to: value}
this.value = value
this.emit(change)
}
}
export const compose = func => (...states) => new ObservableComposition(func, {}, ...states)
class MutationEvent extends Event {
constructor() {
super("mutation", {bubbles: true})
}
}
const mutationObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new MutationEvent())
}
})
export class ObservableElement extends Observable {
#getValue
#equal
#value
#changedValue = false
constructor(target, {get, equal, ...options}={}) {
super(options)
this[target] = target
this.#getValue = get ?? (target => target.value)
this.#equal = equal ?? ((a, b) => a===b)
this.#value = this.#getValue(target)
const controller = new AbortController()
target.addEventListener("mutation", 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.#value }
update() {
const current = this.#getValue(this[target])
if (this.#equal(this.#value, current)) return
this.#value = current
if (this.synchronous) {
this.dispatchEvent(new MultiChangeEvent(["value", current]))
} else {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new MultiChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = 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()
}
}

251
skooma.js
View file

@ -12,37 +12,63 @@ class MultiAbortController {
abort() { this.#controller.abort(); this.#controller = new AbortController() } abort() { this.#controller.abort(); this.#controller = new AbortController() }
} }
/** A symbol representing nothing to be appended to an element */
export const empty = Symbol("Explicit empty argument for Skooma") export const empty = Symbol("Explicit empty argument for Skooma")
/** Converts a snake-case string to a CSS property name
* @param {string} key
* @return {string}
*/
const snakeToCSS = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase()) const snakeToCSS = key => key.replace(/^[A-Z]/, a => "-"+a).replace(/[A-Z]/g, a => '-'+a.toLowerCase())
/** const insertStyles = (rule, styles) => {
* @param {CSSStyleDeclaration} style The style property of a node for (const [key, value] of Object.entries(styles))
* @param {object} rules A map of snake case property names to css values
*/
const insertStyles = (style, rules) => {
for (const [key, value] of Object.entries(rules))
if (typeof value == "undefined") if (typeof value == "undefined")
style.removeProperty(snakeToCSS(key)) rule.removeProperty(snakeToCSS(key))
else else
style.setProperty(snakeToCSS(key), value.toString()) rule.setProperty(snakeToCSS(key), value.toString())
} }
/** @typedef SpecialAttributeDescriptor const processAttribute = (attribute) => {
* @type {object} if (typeof attribute == "string" || typeof attribute == "number")
* @property {function(this:any):void} [get] return attribute
* @property {function(this:any,any):void} [set] else if (attribute && "join" in attribute)
* @property {function(this:any,function():void):void} [hook] return attribute.join(" ")
*/ else
return JSON.stringify(attribute)
}
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
const getCustom = args => args.reduce(
(current, argument) => Array.isArray(argument)
? defined(getCustom(argument), current)
: (argument && typeof argument == "object")
? defined(argument.is, current)
: current
,undefined
)
export const isReactive = object => object
&& typeof object == "object"
&& !(object instanceof HTMLElement)
&& object.subscribe
const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number")
return document.createTextNode(arg)
else if (arg instanceof HTMLElement)
return arg
else if (isReactive(arg))
return reactiveChild(arg)
}
const reactiveChild = reactive => {
let ref
const abort = reactive.subscribe(value => {
if (ref && !ref.deref()) return abort()
const child = toChild(value) ?? document.createComment("Placeholder for reactive content")
untilDeathDoThemPart(child, reactive)
if (ref) ref.deref().replaceWith(child)
ref = new WeakRef(child)
})
return ref.deref()
}
/**
* @type {Object<string,SpecialAttributeDescriptor>}
*/
const specialAttributes = { const specialAttributes = {
value: { value: {
get() { return this.value }, get() { return this.value },
@ -69,117 +95,9 @@ const specialAttributes = {
} }
} }
const processAttribute = attribute => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
return attribute.join(" ")
else
return JSON.stringify(attribute)
}
/** Returns a fallback if value is defined */
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
/** Recursively finds the last 'is' attribute in a list nested array of objects
* @param {Array} args
*/
const getCustom = args => args.reduce(
(current, argument) => Array.isArray(argument)
? defined(getCustom(argument), current)
: (argument && typeof argument == "object")
? defined(argument.is, current)
: current
,undefined
)
/**
* @typedef Observable
* @type {EventTarget|object}
* @property {any} value
*/
/** Returns whether an object is an observable according to skooma's contract
* @param {any} object
* @return {object is Observable}
*/
export const isObservable = object => object && object.observable
/** Turns an argument into something that can be inserted as a child into a DOM node
* @param {any} value
* @return {Element|Text}
*/
const toElement = value => {
if (typeof value == "string" || typeof value == "number")
return document.createTextNode(value.toString())
else if (value instanceof Element)
return value
else if (isObservable(value))
return reactiveElement(value)
}
class ReplaceEvent extends Event {
/** @param {Element|Text} next */
constructor(next) {
super("replace", {bubbles: true, cancelable: true})
this.next = next
}
}
class ReplacedEvent extends Event {
/** @param {Element|Text} next */
constructor(next) {
super("replaced")
this.next = next
}
}
/**
* @param {Observable} observable
* @return {Element|Text}
*/
export const reactiveElement = observable => {
const element = toElement(observable.value)
untilDeathDoThemPart(element, observable)
const ref = new WeakRef(element)
observable.addEventListener("change", () => {
const next = reactiveElement(observable)
const element = ref.deref()
if (element.dispatchEvent(new ReplaceEvent(next)))
element.replaceWith(next)
element.dispatchEvent(new ReplacedEvent(next))
}, {once: true})
return element
}
/** A reference to a reactive element that follows it around through changes */
export class Ref {
#current
/** @param {Element|Text} target A reactive element to follow */
constructor(target) {
this.#current = target
this.follow(target)
}
follow(target) {
target.addEventListener("replaced", ({next}) => {
this.#current = next
this.follow(next)
})
}
deref() { return this.#current }
}
/** Set an attribute on an element
* @param {Element} element
* @param {string} attribute
* @param {any} value
* @param {AbortSignal} [cleanupSignal]
*/
const setAttribute = (element, attribute, value, cleanupSignal) => { const setAttribute = (element, attribute, value, cleanupSignal) => {
const special = specialAttributes[attribute] const special = specialAttributes[attribute]
if (isObservable(value)) if (isReactive(value))
setReactiveAttribute(element, attribute, value) setReactiveAttribute(element, attribute, value)
else if (typeof value === "function") else if (typeof value === "function")
element.addEventListener(attribute, value, {signal: cleanupSignal}) element.addEventListener(attribute, value, {signal: cleanupSignal})
@ -194,40 +112,32 @@ const setAttribute = (element, attribute, value, cleanupSignal) => {
} }
} }
/** Set up a binding between an attribute and an observable // (Two-way) binding between an attribute and a state container
* @param {Element} element const setReactiveAttribute = (element, attribute, reactive) => {
* @param {string} attribute untilDeathDoThemPart(element, reactive)
* @param {Observable} observable
*/
const setReactiveAttribute = (element, attribute, observable) => {
const multiAbort = new MultiAbortController() const multiAbort = new MultiAbortController()
let old
observable.addEventListener("change", () => { reactive.subscribe(value => {
old = value
multiAbort.abort() multiAbort.abort()
setAttribute(element, attribute, observable.value, multiAbort.signal) setAttribute(element, attribute, value, multiAbort.signal)
}) })
setAttribute(element, attribute, observable.value, multiAbort.signal)
const special = specialAttributes[attribute] const special = specialAttributes[attribute]
if (special.hook) { if (special?.hook && reactive.set) {
untilDeathDoThemPart(element, observable)
special.hook.call(element, () => { special.hook.call(element, () => {
const current = special.get.call(element, attribute) const value = special.get.call(element, attribute)
if (current != observable.value) observable.value = current if (value != old) reactive.set(value)
}) })
} }
} }
/** Processes a list of arguments for an HTML Node
* @param {Element} element
* @param {Array} args
*/
const processArgs = (element, ...args) => { const processArgs = (element, ...args) => {
if (element.content) element = element.content
for (const arg of args) if (arg !== empty) { for (const arg of args) if (arg !== empty) {
if (Array.isArray(arg)) { if (arg instanceof Array) {
processArgs(element, ...arg) processArgs(element, ...arg)
} else { } else {
const child = toElement(arg) const child = toChild(arg)
if (child) if (child)
element.append(child) element.append(child)
else if (arg === undefined || arg == null) else if (arg === undefined || arg == null)
@ -243,11 +153,6 @@ const processArgs = (element, ...args) => {
} }
} }
/** Creates a new node
* @param {String} name
* @param {Array} args
* @param {Object} options
*/
const node = (name, args, options) => { const node = (name, args, options) => {
let element let element
const custom = getCustom(args) const custom = getCustom(args)
@ -263,7 +168,6 @@ const node = (name, args, options) => {
} }
const nameSpacedProxy = (options={}) => new Proxy(Window, { const nameSpacedProxy = (options={}) => new Proxy(Window, {
/** @param {string} prop */
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) }, get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
has: (_target, _prop) => true, has: (_target, _prop) => true,
}) })
@ -274,44 +178,31 @@ export default html
// Other utility exports // Other utility exports
/** Wraps an event handler in a function that calls preventDefault on the event // Wraps an event handler in a function that calls preventDefault on the event
* @param {function(event) : event} fn
* @return {function(event)}
*/
export const handle = fn => event => { event.preventDefault(); return fn(event) } export const handle = fn => event => { event.preventDefault(); return fn(event) }
/** Wraps a list of elements in a document fragment // Wraps a list of elements in a document fragment
* @param {Array<Element|String>} elements
*/
export const fragment = (...elements) => { export const fragment = (...elements) => {
const fragment = new DocumentFragment() const fragment = new DocumentFragment()
for (const element of elements) for (const element of elements)
fragment.append(toElement(element)) fragment.append(element)
return fragment return fragment
} }
/** Turns a template literal into document fragment. // Turns a template literal into document fragment.
* Strings are returned as text nodes. // Strings are returned as text nodes.
* Elements are inserted in between. // Elements are inserted in between.
* @param {Array<String>} literals
* @param {Array<any>} items
* @return {DocumentFragment}
*/
const textFromTemplate = (literals, items) => { const textFromTemplate = (literals, items) => {
const fragment = new DocumentFragment() const fragment = new DocumentFragment()
for (const key in items) { for (const key in items) {
fragment.append(document.createTextNode(literals[key])) fragment.append(document.createTextNode(literals[key]))
fragment.append(toElement(items[key])) fragment.append(items[key])
} }
fragment.append(document.createTextNode(literals[literals.length-1])) fragment.append(document.createTextNode(literals.at(-1)))
return fragment return fragment
} }
/**
* @param {String|Array<String>} data
* @param {Array<String|Element>} items
*/
export const text = (data="", ...items) => export const text = (data="", ...items) =>
Array.isArray(data) typeof data == "object" && "at" in data
? textFromTemplate(data, items) ? textFromTemplate(data, items)
: document.createTextNode(data) : document.createTextNode(data)

453
state.js Normal file
View file

@ -0,0 +1,453 @@
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 "<name>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()
}
get() { return this.value }
set(value) { this.value = value }
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
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),
})
}
forward(property="value", methods) {
return new ForwardState(this, property, methods)
}
set(prop, value) {
if (arguments.length === 1) return this.set("value", prop)
this.#target[prop] = value
}
get(prop="value") {
return this.#target[prop]
}
set value(value) { this.set(value) }
get value() { return this.get() }
}
export class ForwardState extends SimpleState {
#backend
#property
#methods
constructor(backend, property, methods = {}) {
super()
this.#methods = methods
this.#backend = backend
this.#property = property
const ref = new WeakRef(this)
const abortController = new AbortController()
abortRegistry.register(this, abortController)
backend.addEventListener("change", event => {
const state = ref.deref()
if (state) {
let relevantChanges = event.changes
.filter(({property: name}) => name === property)
const get = methods.get
if (methods.get) {
relevantChanges = relevantChanges.map(
({from, to}) => ({property: "value", from: get(from), to: get(to)})
)
} else {
relevantChanges = relevantChanges.map(
({from, to}) => ({property: "value", from, to})
)
}
if (relevantChanges.length > 0)
state.dispatchEvent(new ChangeEvent(...relevantChanges))
} else {
abortController.abort()
}
}, {signal: abortController.signal})
}
get value() {
const methods = this.#methods
if (methods.get) {
return methods.get(this.#backend.values[this.#property])
} else {
return this.#backend.values[this.#property]
}
}
set value(value) {
const methods = this.#methods
if (methods.set) {
this.#backend.values[this.#property] = methods.set(value)
} else {
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<this.#storage.length; i++) {
const key = this.#storage.key(i)
const value = this.#storage[key]
if (value !== JSON.stringify(init[key]))
this.emit(key, value)
}
// Listen for changes from other windows
const handler = event => {
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 = (name, generator, methods) => {
if (typeof name === "function") {
methods = generator
generator = 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.call(this, this.state))
}
}
if (methods) {
Object.defineProperties(Element.prototype, Object.getOwnPropertyDescriptors(methods))
}
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.synchronous) {
this.update()
} else {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
this.update()
})
}
this.#microtaskQueued = true
}
}
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, {}, ...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.synchronous) {
this.dispatchEvent(new ChangeEvent(["value", current]))
} else {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new ChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = 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