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
3 changed files with 279 additions and 258 deletions

View file

@ -12,8 +12,6 @@ class ChildObserver extends MutationObserver {
}
}
const childObserver = new ChildObserver()
export const lense = (methods, extra) => {
if (extra) return lense(extra)(methods)
@ -24,12 +22,12 @@ export const lense = (methods, extra) => {
} else if (prop === Symbol.iterator) {
return function*() {
for (const child of target.children) {
yield methods.get(child)
yield methods.get.call(child)
}
}
} else if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) return methods.get(child)
if (child) return methods.get.call(child)
return child
} else {
return Array.prototype[prop]
@ -39,7 +37,7 @@ export const lense = (methods, extra) => {
if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) {
methods.set(child, value)
methods.set.call(child, value)
return true
} else {
for (let i = target.children.length; i < Number(prop); i++) {
@ -47,8 +45,8 @@ export const lense = (methods, extra) => {
}
const element = methods.new(value)
target.appendChild(element)
if (methods.get(element) !== value)
methods.set(element, value)
if (methods.get.call(element) !== value)
methods.set.call(element, value)
return true
}
} else if (prop == "length") {
@ -71,14 +69,8 @@ export const lense = (methods, extra) => {
}
return element => {
const proxy = new Proxy(element, traps)
if (methods.event) childObserver.observe(element)
if (typeof methods.event === "function") element.addEventListener("change", event => {
methods.event(proxy, element, event.detail)
})
return proxy
if (!(element instanceof Element)) throw(new Error("Creating domLense on non-element"))
return new Proxy(element, traps)
}
}

155
skooma.js
View file

@ -1,37 +1,30 @@
/*
A functional HTML generation library.
Example:
html.label(
html.span("Delete everything", {class: ["warning", "important"]}),
html.button("Click", {onClick: e => document.body.innerHTML=""}),
)
or
html.ul([1, 2, 3, 4, 5].map(x => html.li(x)), {class: "numbers"})
*/
// Keep a referee alive until a referrer is collected
const weakReferences = new WeakMap()
const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) {
weakReferences.set(referrer, new Set())
}
if (!weakReferences.has(referrer)) weakReferences.set(referrer, new Set())
weakReferences.get(referrer).add(reference)
}
// Like AbortController, but resets after each abort
class MultiAbortController {
#controller = new AbortController()
get signal() { return this.#controller.signal }
abort() { this.#controller.abort(); this.#controller = new AbortController() }
}
export const empty = Symbol("Explicit empty argument for Skooma")
const keyToPropName = 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))
if (typeof value == "undefined")
rule.removeProperty(keyToPropName(key))
rule.removeProperty(snakeToCSS(key))
else
rule.setProperty(keyToPropName(key), value.toString())
rule.setProperty(snakeToCSS(key), value.toString())
}
const parseAttribute = (attribute) => {
const processAttribute = (attribute) => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
@ -50,56 +43,54 @@ const getCustom = args => args.reduce(
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object
&& (typeof object == "object")
&& ("addEventListener" in object)
&& ("value" in object)
export const isReactive = object => object
&& typeof object == "object"
&& !(object instanceof HTMLElement)
&& object.subscribe
const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number") {
if (typeof arg == "string" || typeof arg == "number")
return document.createTextNode(arg)
} else if (isElement(arg)) {
else if (arg instanceof HTMLElement)
return arg
} else if (isReactive(arg)) {
else if (isReactive(arg))
return reactiveChild(arg)
}
}
const reactiveChild = reactive => {
const ref = new WeakRef(toChild(reactive.value))
reactive.addEventListener("change", () => {
const value = ref.deref()
if (value)
value.replaceWith(reactiveChild(reactive))
}, {once: true})
untilDeathDoThemPart(ref.deref(), 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()
}
const specialAttributes = {
value: {
get: element => element.value,
set: (element, value) => {
element.setAttribute("value", value)
element.value = value
get() { return this.value },
set(value) {
this.setAttribute("value", value)
this.value = value
},
hook: (element, callback) => { element.addEventListener("input", callback) }
hook(callback) { this.addEventListener("input", callback) }
},
style: {
set: (element, value) => { insertStyles(element.style, value) }
set(value) { insertStyles(this.style, value) }
},
dataset: {
set: (element, value) => {
set(value) {
for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = parseAttribute(value2)
this.dataset[attribute2] = processAttribute(value2)
}
}
},
shadowRoot: {
set: (element, value) => {
parseArgs((element.shadowRoot || element.attachShadow({mode: "open"})), null, value)
set(value) {
processArgs((this.shadowRoot || this.attachShadow({mode: "open"})), value)
}
}
}
@ -109,58 +100,56 @@ const setAttribute = (element, attribute, value, cleanupSignal) => {
if (isReactive(value))
setReactiveAttribute(element, attribute, value)
else if (typeof value === "function")
element.addEventListener(attribute.replace(/^on[A-Z]/, x => x.charAt(x.length-1).toLowerCase()), value, {signal: cleanupSignal})
else if (special) {
special.set(element, value)
}
element.addEventListener(attribute, value, {signal: cleanupSignal})
else if (special?.set)
special.set.call(element, value)
else if (value === true)
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, parseAttribute(value))
element.setAttribute(attribute, processAttribute(value))
}
}
const setReactiveAttribute = (element, attribute, reactive, abortController) => {
// (Two-way) binding between an attribute and a state container
const setReactiveAttribute = (element, attribute, reactive) => {
untilDeathDoThemPart(element, reactive)
if (abortController) abortController.abort()
abortController = new AbortController()
const ref = new WeakRef(element)
setAttribute(element, attribute, reactive.value, abortController.signal)
reactive.addEventListener("change", () => {
const element = ref.deref()
if (element)
setReactiveAttribute(element, attribute, reactive, abortController)
}, {once: true})
const multiAbort = new MultiAbortController()
let old
reactive.subscribe(value => {
old = value
multiAbort.abort()
setAttribute(element, attribute, value, multiAbort.signal)
})
const special = specialAttributes[attribute]
if (special?.hook) {
special.hook(element, () => {
const value = special.get(element, attribute)
if (value != reactive.value) reactive.value = value
if (special?.hook && reactive.set) {
special.hook.call(element, () => {
const value = special.get.call(element, attribute)
if (value != old) reactive.set(value)
})
}
}
const parseArgs = (element, before, ...args) => {
const processArgs = (element, ...args) => {
if (element.content) element = element.content
for (const arg of args) if (arg !== empty) {
const child = toChild(arg)
if (child)
element.insertBefore(child, before)
else if (arg === undefined || arg == null)
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
else if (typeof arg == "function")
arg(element)
else if ("length" in arg)
parseArgs(element, before, ...arg)
else
for (const key in arg)
setAttribute(element, key, arg[key])
if (arg instanceof Array) {
processArgs(element, ...arg)
} else {
const child = toChild(arg)
if (child)
element.append(child)
else if (arg === undefined || arg == null)
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
else if (typeof arg == "function" && arg.length == 0)
processArgs(element, arg())
else if (typeof arg == "function")
arg(element)
else
for (const key in arg)
setAttribute(element, key, arg[key])
}
}
}
@ -174,7 +163,7 @@ const node = (name, args, options) => {
element = document.createElementNS(options.xmlns, name, opts)
else
element = document.createElement(name, opts)
parseArgs(element, null, args)
processArgs(element, args)
return element
}

360
state.js
View file

@ -5,82 +5,56 @@ const kebabToCamel = string => string.replace(/([a-z])-([a-z])/g, (_, a, b) => a
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(this.changes)
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 {}
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 class State extends SimpleState {
#target
#options
export class SimpleState extends EventTarget {
#synchronous
#queue
#forwardCache
#abortController
#nested = new Map()
#weakRef = new WeakRef(this)
#abortController = new AbortController
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
constructor(target={}, options={}) {
constructor({synchronous, methods}={}) {
super()
this.#abortController = new AbortController
this.#synchronous = !!synchronous
abortRegistry.register(this, this.#abortController)
this.#options = options
this.#target = target
this.values = new Proxy(target, {
set: (_target, prop, value) => {
const old = this.get(prop)
if (old !== value) {
this.emit(prop, value)
if (this.#options.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),
})
this.addEventListener
// Try running a "<name>Changed" method for every changed property
// Can be disabled to maybe squeeze out some performance
if (options.methods ?? true) {
if (methods ?? true) {
this.addEventListener("change", ({final}) => {
final.forEach((value, prop) => {
if (`${prop}Changed` in this) this[`${prop}Changed`](value)
@ -89,77 +63,6 @@ export class State extends SimpleState {
}
}
// When you only need one value, you can skip the proxy.
set value(value) { this.values.value = value }
get value() { return this.values.value }
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)
handlers.set(prop, handler)
state.addEventListener("change", handler, {signal: this.#abortController.signal})
}
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)
}
}
// Anounces that a prop has changed
emit(prop, value) {
if (this.#options.defer ?? true) {
if (!this.#queue) {
this.#queue = []
queueMicrotask(() => {
this.dispatchEvent(new ChangeEvent(...this.#queue))
this.#queue = undefined
})
}
this.#queue.push([prop, value])
} else {
this.dispatchEvent(new ChangeEvent([prop, 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]
}
subscribe(prop, callback) {
if (!callback) return this.subscribe("value", prop)
@ -171,32 +74,127 @@ export class State extends SimpleState {
return () => controller.abort()
}
// Backwards compatibility
get proxy() { return this.values }
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 }
}
const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => {
cache.remove(name)
})
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
#fallback
#methods
constructor(backend, property, fallback) {
constructor(backend, property, methods = {}) {
super()
this.#methods = methods
this.#backend = backend
this.#property = property
this.#fallback = fallback
const ref = new WeakRef(this)
const abortController = new AbortController()
abortRegistry.register(this, abortController)
backend.addEventListener("change", event => {
const state = ref.deref()
if (state) {
const relevantChanges = event.changes
.filter(([name]) => name === property)
.map(([_, value]) => ["value", value])
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 {
@ -205,8 +203,23 @@ export class ForwardState extends SimpleState {
}, {signal: abortController.signal})
}
get value() { return this.#backend.values[this.#property] ?? this.#fallback }
set value(value) { this.#backend.values[this.#property] = value }
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 {
@ -273,23 +286,30 @@ const attributeObserver = new MutationObserver(mutations => {
}
})
export const component = (generator, name) => {
name = name ?? camelToKebab(generator.name)
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 [name, value] of event.changes) {
const kebabName = camelToKebab(name)
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))
this.replaceChildren(generator.call(this, this.state))
}
}
if (methods) {
Object.defineProperties(Element.prototype, Object.getOwnPropertyDescriptors(methods))
}
customElements.define(name, Element)
return Element;
}
@ -297,14 +317,12 @@ export const component = (generator, name) => {
class ComposedState extends SimpleState {
#func
#states
#options
constructor(func, options, ...states) {
super()
super(options)
this.#func = func
this.#states = states
this.#options = options
const abortController = new AbortController()
abortRegistry.register(this, abortController)
@ -322,7 +340,9 @@ class ComposedState extends SimpleState {
#microtaskQueued
scheduleUpdate() {
if (this.#options.defer) {
if (this.synchronous) {
this.update()
} else {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
@ -330,18 +350,18 @@ class ComposedState extends SimpleState {
})
}
this.#microtaskQueued = true
} else {
this.update()
}
}
update() {
this.value = this.#func(...this.#states.map(state => state.value))
this.dispatchEvent(new ChangeEvent([["value", this.value]]))
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)
export const compose = func => (...states) => new ComposedState(func, {}, ...states)
const eventName = "mutation"
@ -359,7 +379,6 @@ const mutationObserver = new MutationObserver(mutations => {
export class DOMState extends SimpleState {
#target
#defer
#getValue
#equal
@ -367,8 +386,7 @@ export class DOMState extends SimpleState {
#changedValue = false
constructor(target, options) {
super()
this.#defer = options.defer ?? false
super(options)
this.#target = target
this.#getValue = options.get ?? (target => target.value)
this.#equal = options.equal ?? ((a, b) => a===b)
@ -396,7 +414,9 @@ export class DOMState extends SimpleState {
this.#old = current
if (this.#defer) {
if (this.synchronous) {
this.dispatchEvent(new ChangeEvent(["value", current]))
} else {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
@ -404,10 +424,30 @@ export class DOMState extends SimpleState {
})
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