Compare commits

..

26 Commits

Author SHA1 Message Date
Talia 6d4e398336 Add Ref class to follow reactive elements 2024-02-29 15:29:36 +01:00
Talia d53e6c7fd5 Add replace and replaced events to reactive elements 2024-02-29 15:29:20 +01:00
Talia 3ff99bee9b Add jsdoc class annotations to skooma module 2024-02-12 13:38:57 +01:00
Talia 8876dcfa68
Add meta-tag domProxy 2024-02-06 22:56:08 +01:00
Talia 0a80d860df
Rename domLens to domProxy / domArray 2024-02-06 22:02:29 +01:00
Talia 39012902e0
Full refactor of State (now Observable)
* Renamed to Observable
* Updated skooma.js to match the API
2024-02-06 22:00:48 +01:00
Talia 0a14283892
Remvoe stored state class
* The idea was good but the specifics were dumb
* Re-implement later with a better interface
2024-02-06 17:43:23 +01:00
Talia 5115de451f
Correct spelling of domLens 2024-02-06 17:43:23 +01:00
Talia 9b738bd589
Remove vestigial ChildObserver class from DOM lens 2024-02-06 17:43:23 +01:00
Talia 71d7c0ff4f
Rename "reactive" object to observable in code 2024-02-06 17:43:23 +01:00
Talia 784eb78f0a Refactor 2024-01-24 16:43:50 +01:00
Talia 6248593570 Fix and refactor skooma.js 2024-01-24 16:13:01 +01:00
Talia 5488f2a49a Fix bug in composed state 2024-01-24 16:12:22 +01:00
Talia 30f52a05e5 Refactor state module 2024-01-24 16:12:15 +01:00
Talia 1c03da8815 Make domLense error on non-element targets 2024-01-24 15:06:12 +01:00
Talia 44b6343de6 Remove event name cleanup in skooma.js 2024-01-24 14:51:45 +01:00
Talia 2445617e8b Switch to svelte store contract 2024-01-24 14:50:51 +01:00
Talia 688cbae9ba Refactor skooma.js 2024-01-24 13:14:06 +01:00
Talia 74e364e714 Add methods param to component generator 2024-01-24 10:21:59 +01:00
Talia 2f95afbcb7 Fix error handling arrays in skooma.js 2024-01-24 10:21:59 +01:00
Talia 24ea67bf81 Add get/set filters to forward state 2024-01-24 10:21:59 +01:00
Talia dc29b10b1a [WIP] Tweak skooma state contract 2024-01-22 11:22:11 +01:00
Talia dda6673f15 [WIP] Add child generators and refactor 2024-01-22 10:35:42 +01:00
Talia f6e7c00944 [WIP] Refactor get/set with `arguments` 2024-01-22 10:32:35 +01:00
Talia 2b8ba6e7d6
Change domLense method semantics 2024-01-20 15:06:23 +01:00
Talia 7a789d407e [WIP] Major refactor and API change for version 2.0 2024-01-17 15:16:46 +01:00
5 changed files with 629 additions and 542 deletions

View File

@ -1,19 +1,5 @@
class ChildObserver extends MutationObserver { export const domArray = (methods, extra) => {
constructor() { if (extra) return domArray(extra)(methods)
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) {
@ -69,9 +55,20 @@ export const lense = (methods, extra) => {
} }
return element => { return element => {
if (!(element instanceof Element)) throw(new Error("Creating domLense on non-element")) if (!(element instanceof Element)) throw(new Error("Creating domArray on non-element"))
return new Proxy(element, traps) return new Proxy(element, traps)
} }
} }
export default lense export const meta = (element=document.head) => new Proxy(element, {
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
}
})

6
jsconfig.json Normal file
View File

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

428
observable.js Normal file
View File

@ -0,0 +1,428 @@
/** @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,63 +12,37 @@ 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) => { /**
for (const [key, value] of Object.entries(styles)) * @param {CSSStyleDeclaration} style The style property of a node
* @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")
rule.removeProperty(snakeToCSS(key)) style.removeProperty(snakeToCSS(key))
else else
rule.setProperty(snakeToCSS(key), value.toString()) style.setProperty(snakeToCSS(key), value.toString())
} }
const processAttribute = (attribute) => { /** @typedef SpecialAttributeDescriptor
if (typeof attribute == "string" || typeof attribute == "number") * @type {object}
return attribute * @property {function(this:any):void} [get]
else if (attribute && "join" in attribute) * @property {function(this:any,any):void} [set]
return attribute.join(" ") * @property {function(this:any,function():void):void} [hook]
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 },
@ -95,9 +69,117 @@ 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 (isReactive(value)) if (isObservable(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})
@ -112,32 +194,40 @@ const setAttribute = (element, attribute, value, cleanupSignal) => {
} }
} }
// (Two-way) binding between an attribute and a state container /** Set up a binding between an attribute and an observable
const setReactiveAttribute = (element, attribute, reactive) => { * @param {Element} element
untilDeathDoThemPart(element, reactive) * @param {string} attribute
* @param {Observable} observable
*/
const setReactiveAttribute = (element, attribute, observable) => {
const multiAbort = new MultiAbortController() const multiAbort = new MultiAbortController()
let old
reactive.subscribe(value => { observable.addEventListener("change", () => {
old = value
multiAbort.abort() multiAbort.abort()
setAttribute(element, attribute, value, multiAbort.signal) setAttribute(element, attribute, observable.value, multiAbort.signal)
}) })
setAttribute(element, attribute, observable.value, multiAbort.signal)
const special = specialAttributes[attribute] const special = specialAttributes[attribute]
if (special?.hook && reactive.set) { if (special.hook) {
untilDeathDoThemPart(element, observable)
special.hook.call(element, () => { special.hook.call(element, () => {
const value = special.get.call(element, attribute) const current = special.get.call(element, attribute)
if (value != old) reactive.set(value) if (current != observable.value) observable.value = current
}) })
} }
} }
/** 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 (arg instanceof Array) { if (Array.isArray(arg)) {
processArgs(element, ...arg) processArgs(element, ...arg)
} else { } else {
const child = toChild(arg) const child = toElement(arg)
if (child) if (child)
element.append(child) element.append(child)
else if (arg === undefined || arg == null) else if (arg === undefined || arg == null)
@ -153,6 +243,11 @@ 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)
@ -168,6 +263,7 @@ 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,
}) })
@ -178,31 +274,44 @@ 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(element) fragment.append(toElement(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(items[key]) fragment.append(toElement(items[key]))
} }
fragment.append(document.createTextNode(literals.at(-1))) fragment.append(document.createTextNode(literals[literals.length-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) =>
typeof data == "object" && "at" in data Array.isArray(data)
? textFromTemplate(data, items) ? textFromTemplate(data, items)
: document.createTextNode(data) : document.createTextNode(data)

453
state.js
View File

@ -1,453 +0,0 @@
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