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 141 additions and 141 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)
}
}

136
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,55 +43,54 @@ const getCustom = args => args.reduce(
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object
&& (object instanceof EventTarget)
&& ("subscribe" 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)
}
}
}
@ -108,60 +100,58 @@ 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) {
if (arg instanceof Array) {
processArgs(element, ...arg)
} else {
const child = toChild(arg)
if (child)
element.insertBefore(child, before)
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 if ("length" in arg)
parseArgs(element, before, ...arg)
else
for (const key in arg)
setAttribute(element, key, arg[key])
}
}
}
const node = (name, args, options) => {
let element
@ -173,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
}

106
state.js
View file

@ -74,6 +74,9 @@ export class SimpleState extends EventTarget {
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) {
@ -121,7 +124,6 @@ export class SimpleState extends EventTarget {
export class State extends SimpleState {
#target
#shallow
#forwardCache
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
@ -147,58 +149,52 @@ export class State extends SimpleState {
})
}
set value(value) { this.values.value = value }
get value() { return this.values.value }
forward(property="value") {
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)
const ref = new WeakRef(forwarded)
this.#forwardCache.set(property, ref)
forwardFinalizationRegistry.register(forwarded, [this.#forwardCache, property])
return forwarded
}
forward(property="value", methods) {
return new ForwardState(this, property, methods)
}
set(...args) {
if (args.length === 1) return this.set("value", ...args)
const [prop, value] = args
set(prop, value) {
if (arguments.length === 1) return this.set("value", prop)
this.#target[prop] = value
}
get(...args) {
if (args.length === 0) return this.get("value")
const prop = args[0]
get(prop="value") {
return this.#target[prop]
}
}
const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => {
cache.remove(name)
})
set value(value) { this.set(value) }
get value() { return this.get() }
}
export class ForwardState extends SimpleState {
#backend
#property
#methods
constructor(backend, property) {
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) {
const relevantChanges = event.changes
let relevantChanges = event.changes
.filter(({property: name}) => name === property)
.map(({from, to}) => ({property: "value", from, to}))
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 {
@ -207,8 +203,23 @@ export class ForwardState extends SimpleState {
}, {signal: abortController.signal})
}
get value() { return this.#backend.values[this.#property]}
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 {
@ -275,8 +286,12 @@ 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()
@ -289,9 +304,12 @@ export const component = (generator, name) => {
}
})
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;
}
@ -322,7 +340,9 @@ class ComposedState extends SimpleState {
#microtaskQueued
scheduleUpdate() {
if (this.defer) {
if (this.synchronous) {
this.update()
} else {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
@ -330,8 +350,6 @@ class ComposedState extends SimpleState {
})
}
this.#microtaskQueued = true
} else {
this.update()
}
}
@ -339,11 +357,11 @@ class ComposedState extends SimpleState {
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]))
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"
@ -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,8 +424,6 @@ export class DOMState extends SimpleState {
})
this.#changedValue = current
}
} else {
this.dispatchEvent(new ChangeEvent(["value", current]))
}
}
}