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

154
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 // Keep a referee alive until a referrer is collected
const weakReferences = new WeakMap() const weakReferences = new WeakMap()
const untilDeathDoThemPart = (referrer, reference) => { const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) { if (!weakReferences.has(referrer)) weakReferences.set(referrer, new Set())
weakReferences.set(referrer, new Set())
}
weakReferences.get(referrer).add(reference) 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") 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) => { const insertStyles = (rule, styles) => {
for (const [key, value] of Object.entries(styles)) for (const [key, value] of Object.entries(styles))
if (typeof value == "undefined") if (typeof value == "undefined")
rule.removeProperty(keyToPropName(key)) rule.removeProperty(snakeToCSS(key))
else 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") if (typeof attribute == "string" || typeof attribute == "number")
return attribute return attribute
else if (attribute && "join" in attribute) else if (attribute && "join" in attribute)
@ -50,55 +43,54 @@ const getCustom = args => args.reduce(
,undefined ,undefined
) )
const isElement = object => HTMLElement.prototype.isPrototypeOf(object) export const isReactive = object => object
&& typeof object == "object"
const isReactive = object => object && !(object instanceof HTMLElement)
&& (object instanceof EventTarget) && object.subscribe
&& ("subscribe" in object)
const toChild = arg => { const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number") { if (typeof arg == "string" || typeof arg == "number")
return document.createTextNode(arg) return document.createTextNode(arg)
} else if (isElement(arg)) { else if (arg instanceof HTMLElement)
return arg return arg
} else if (isReactive(arg)) { else if (isReactive(arg))
return reactiveChild(arg) return reactiveChild(arg)
}
} }
const reactiveChild = reactive => { const reactiveChild = reactive => {
const ref = new WeakRef(toChild(reactive.value)) let ref
reactive.addEventListener("change", () => { const abort = reactive.subscribe(value => {
const value = ref.deref() if (ref && !ref.deref()) return abort()
if (value) const child = toChild(value) ?? document.createComment("Placeholder for reactive content")
value.replaceWith(reactiveChild(reactive)) untilDeathDoThemPart(child, reactive)
}, {once: true}) if (ref) ref.deref().replaceWith(child)
untilDeathDoThemPart(ref.deref(), reactive) ref = new WeakRef(child)
})
return ref.deref() return ref.deref()
} }
const specialAttributes = { const specialAttributes = {
value: { value: {
get: element => element.value, get() { return this.value },
set: (element, value) => { set(value) {
element.setAttribute("value", value) this.setAttribute("value", value)
element.value = value this.value = value
}, },
hook: (element, callback) => { element.addEventListener("input", callback) } hook(callback) { this.addEventListener("input", callback) }
}, },
style: { style: {
set: (element, value) => { insertStyles(element.style, value) } set(value) { insertStyles(this.style, value) }
}, },
dataset: { dataset: {
set: (element, value) => { set(value) {
for (const [attribute2, value2] of Object.entries(value)) { for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = parseAttribute(value2) this.dataset[attribute2] = processAttribute(value2)
} }
} }
}, },
shadowRoot: { shadowRoot: {
set: (element, value) => { set(value) {
parseArgs((element.shadowRoot || element.attachShadow({mode: "open"})), null, value) processArgs((this.shadowRoot || this.attachShadow({mode: "open"})), value)
} }
} }
} }
@ -108,58 +100,56 @@ const setAttribute = (element, attribute, value, cleanupSignal) => {
if (isReactive(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.replace(/^on[A-Z]/, x => x.charAt(x.length-1).toLowerCase()), value, {signal: cleanupSignal}) element.addEventListener(attribute, value, {signal: cleanupSignal})
else if (special) { else if (special?.set)
special.set(element, value) special.set.call(element, value)
}
else if (value === true) else if (value === true)
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')} {if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
else if (value === false) else if (value === false)
element.removeAttribute(attribute) element.removeAttribute(attribute)
else { 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) untilDeathDoThemPart(element, reactive)
const multiAbort = new MultiAbortController()
if (abortController) abortController.abort() let old
abortController = new AbortController() reactive.subscribe(value => {
old = value
const ref = new WeakRef(element) multiAbort.abort()
setAttribute(element, attribute, reactive.value, abortController.signal) setAttribute(element, attribute, value, multiAbort.signal)
})
reactive.addEventListener("change", () => {
const element = ref.deref()
if (element)
setReactiveAttribute(element, attribute, reactive, abortController)
}, {once: true})
const special = specialAttributes[attribute] const special = specialAttributes[attribute]
if (special?.hook) { if (special?.hook && reactive.set) {
special.hook(element, () => { special.hook.call(element, () => {
const value = special.get(element, attribute) const value = special.get.call(element, attribute)
if (value != reactive.value) reactive.value = value if (value != old) reactive.set(value)
}) })
} }
} }
const parseArgs = (element, before, ...args) => { const processArgs = (element, ...args) => {
if (element.content) element = element.content if (element.content) element = element.content
for (const arg of args) if (arg !== empty) { for (const arg of args) if (arg !== empty) {
const child = toChild(arg) if (arg instanceof Array) {
if (child) processArgs(element, ...arg)
element.insertBefore(child, before) } else {
else if (arg === undefined || arg == null) const child = toChild(arg)
console.warn(`An argument of type ${typeof arg} has been ignored`, element) if (child)
else if (typeof arg == "function") element.append(child)
arg(element) else if (arg === undefined || arg == null)
else if ("length" in arg) console.warn(`An argument of type ${typeof arg} has been ignored`, element)
parseArgs(element, before, ...arg) else if (typeof arg == "function" && arg.length == 0)
else processArgs(element, arg())
for (const key in arg) else if (typeof arg == "function")
setAttribute(element, key, arg[key]) arg(element)
else
for (const key in arg)
setAttribute(element, key, arg[key])
}
} }
} }
@ -173,7 +163,7 @@ const node = (name, args, options) => {
element = document.createElementNS(options.xmlns, name, opts) element = document.createElementNS(options.xmlns, name, opts)
else else
element = document.createElement(name, opts) element = document.createElement(name, opts)
parseArgs(element, null, args) processArgs(element, args)
return element return element
} }

106
state.js
View file

@ -74,6 +74,9 @@ export class SimpleState extends EventTarget {
return () => controller.abort() return () => controller.abort()
} }
get() { return this.value }
set(value) { this.value = value }
emit(property, from, to, options={}) { emit(property, from, to, options={}) {
const change = {property, from, to, ...options} const change = {property, from, to, ...options}
if (!this.synchronous) { if (!this.synchronous) {
@ -121,7 +124,6 @@ export class SimpleState extends EventTarget {
export class State extends SimpleState { export class State extends SimpleState {
#target #target
#shallow #shallow
#forwardCache
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) } static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
@ -147,58 +149,52 @@ export class State extends SimpleState {
}) })
} }
set value(value) { this.values.value = value } forward(property="value", methods) {
get value() { return this.values.value } return new ForwardState(this, property, methods)
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
}
} }
set(...args) { set(prop, value) {
if (args.length === 1) return this.set("value", ...args) if (arguments.length === 1) return this.set("value", prop)
const [prop, value] = args
this.#target[prop] = value this.#target[prop] = value
} }
get(...args) { get(prop="value") {
if (args.length === 0) return this.get("value")
const prop = args[0]
return this.#target[prop] return this.#target[prop]
} }
}
const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => { set value(value) { this.set(value) }
cache.remove(name) get value() { return this.get() }
}) }
export class ForwardState extends SimpleState { export class ForwardState extends SimpleState {
#backend #backend
#property #property
#methods
constructor(backend, property) { constructor(backend, property, methods = {}) {
super() super()
this.#methods = methods
this.#backend = backend this.#backend = backend
this.#property = property this.#property = property
const ref = new WeakRef(this) const ref = new WeakRef(this)
const abortController = new AbortController() const abortController = new AbortController()
abortRegistry.register(this, abortController)
backend.addEventListener("change", event => { backend.addEventListener("change", event => {
const state = ref.deref() const state = ref.deref()
if (state) { if (state) {
const relevantChanges = event.changes let relevantChanges = event.changes
.filter(({property: name}) => name === property) .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) if (relevantChanges.length > 0)
state.dispatchEvent(new ChangeEvent(...relevantChanges)) state.dispatchEvent(new ChangeEvent(...relevantChanges))
} else { } else {
@ -207,8 +203,23 @@ export class ForwardState extends SimpleState {
}, {signal: abortController.signal}) }, {signal: abortController.signal})
} }
get value() { return this.#backend.values[this.#property]} get value() {
set value(value) { this.#backend.values[this.#property] = 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 { class StorageChangeEvent extends Event {
@ -275,8 +286,12 @@ const attributeObserver = new MutationObserver(mutations => {
} }
}) })
export const component = (generator, name) => { export const component = (name, generator, methods) => {
name = name ?? camelToKebab(generator.name) if (typeof name === "function") {
methods = generator
generator = name
name = camelToKebab(generator.name)
}
const Element = class extends HTMLElement{ const Element = class extends HTMLElement{
constructor() { constructor() {
super() super()
@ -289,9 +304,12 @@ export const component = (generator, name) => {
} }
}) })
attributeObserver.observe(this, {attributes: true}) 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) customElements.define(name, Element)
return Element; return Element;
} }
@ -322,7 +340,9 @@ class ComposedState extends SimpleState {
#microtaskQueued #microtaskQueued
scheduleUpdate() { scheduleUpdate() {
if (this.defer) { if (this.synchronous) {
this.update()
} else {
if (!this.#microtaskQueued) { if (!this.#microtaskQueued) {
queueMicrotask(() => { queueMicrotask(() => {
this.#microtaskQueued = false this.#microtaskQueued = false
@ -330,8 +350,6 @@ class ComposedState extends SimpleState {
}) })
} }
this.#microtaskQueued = true 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 value = this.#func(...this.#states.map(state => state.value))
const change = {property: "value", from: this.value, to: value} const change = {property: "value", from: this.value, to: value}
this.value = 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" const eventName = "mutation"
@ -396,7 +414,9 @@ export class DOMState extends SimpleState {
this.#old = current this.#old = current
if (this.defer) { if (this.synchronous) {
this.dispatchEvent(new ChangeEvent(["value", current]))
} else {
if (!this.#changedValue) { if (!this.#changedValue) {
queueMicrotask(() => { queueMicrotask(() => {
this.#changedValue = false this.#changedValue = false
@ -404,8 +424,6 @@ export class DOMState extends SimpleState {
}) })
this.#changedValue = current this.#changedValue = current
} }
} else {
this.dispatchEvent(new ChangeEvent(["value", current]))
} }
} }
} }