Compare commits

...

26 commits

Author SHA1 Message Date
6d4e398336 Add Ref class to follow reactive elements 2024-02-29 15:29:36 +01:00
d53e6c7fd5 Add replace and replaced events to reactive elements 2024-02-29 15:29:20 +01:00
3ff99bee9b Add jsdoc class annotations to skooma module 2024-02-12 13:38:57 +01:00
8876dcfa68
Add meta-tag domProxy 2024-02-06 22:56:08 +01:00
0a80d860df
Rename domLens to domProxy / domArray 2024-02-06 22:02:29 +01:00
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
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
5115de451f
Correct spelling of domLens 2024-02-06 17:43:23 +01:00
9b738bd589
Remove vestigial ChildObserver class from DOM lens 2024-02-06 17:43:23 +01:00
71d7c0ff4f
Rename "reactive" object to observable in code 2024-02-06 17:43:23 +01:00
784eb78f0a Refactor 2024-01-24 16:43:50 +01:00
6248593570 Fix and refactor skooma.js 2024-01-24 16:13:01 +01:00
5488f2a49a Fix bug in composed state 2024-01-24 16:12:22 +01:00
30f52a05e5 Refactor state module 2024-01-24 16:12:15 +01:00
1c03da8815 Make domLense error on non-element targets 2024-01-24 15:06:12 +01:00
44b6343de6 Remove event name cleanup in skooma.js 2024-01-24 14:51:45 +01:00
2445617e8b Switch to svelte store contract 2024-01-24 14:50:51 +01:00
688cbae9ba Refactor skooma.js 2024-01-24 13:14:06 +01:00
74e364e714 Add methods param to component generator 2024-01-24 10:21:59 +01:00
2f95afbcb7 Fix error handling arrays in skooma.js 2024-01-24 10:21:59 +01:00
24ea67bf81 Add get/set filters to forward state 2024-01-24 10:21:59 +01:00
dc29b10b1a [WIP] Tweak skooma state contract 2024-01-22 11:22:11 +01:00
dda6673f15 [WIP] Add child generators and refactor 2024-01-22 10:35:42 +01:00
f6e7c00944 [WIP] Refactor get/set with arguments 2024-01-22 10:32:35 +01:00
2b8ba6e7d6
Change domLense method semantics 2024-01-20 15:06:23 +01:00
7a789d407e [WIP] Major refactor and API change for version 2.0 2024-01-17 15:16:46 +01:00
5 changed files with 669 additions and 561 deletions

View file

@ -1,21 +1,5 @@
class ChildObserver extends MutationObserver {
constructor() {
super(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new CustomEvent("change", {detail: mutation}))
}
})
}
observe(element) {
MutationObserver.prototype.observe.call(this, element, { childList: true })
}
}
const childObserver = new ChildObserver()
export const lense = (methods, extra) => {
if (extra) return lense(extra)(methods)
export const domArray = (methods, extra) => {
if (extra) return domArray(extra)(methods)
const traps = {
get(target, prop) {
@ -24,12 +8,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 +23,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 +31,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,15 +55,20 @@ 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 domArray on non-element"))
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()
}
}

330
skooma.js
View file

@ -1,37 +1,75 @@
/*
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)
}
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 insertStyles = (rule, styles) => {
for (const [key, value] of Object.entries(styles))
if (typeof value == "undefined")
rule.removeProperty(keyToPropName(key))
else
rule.setProperty(keyToPropName(key), value.toString())
// 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() }
}
const parseAttribute = (attribute) => {
/** A symbol representing nothing to be appended to an element */
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())
/**
* @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")
style.removeProperty(snakeToCSS(key))
else
style.setProperty(snakeToCSS(key), value.toString())
}
/** @typedef SpecialAttributeDescriptor
* @type {object}
* @property {function(this:any):void} [get]
* @property {function(this:any,any):void} [set]
* @property {function(this:any,function():void):void} [hook]
*/
/**
* @type {Object<string,SpecialAttributeDescriptor>}
*/
const specialAttributes = {
value: {
get() { return this.value },
set(value) {
this.setAttribute("value", value)
this.value = value
},
hook(callback) { this.addEventListener("input", callback) }
},
style: {
set(value) { insertStyles(this.style, value) }
},
dataset: {
set(value) {
for (const [attribute2, value2] of Object.entries(value)) {
this.dataset[attribute2] = processAttribute(value2)
}
}
},
shadowRoot: {
set(value) {
processArgs((this.shadowRoot || this.attachShadow({mode: "open"})), value)
}
}
}
const processAttribute = attribute => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
@ -40,7 +78,12 @@ const parseAttribute = (attribute) => {
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)
@ -50,120 +93,161 @@ const getCustom = args => args.reduce(
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
/**
* @typedef Observable
* @type {EventTarget|object}
* @property {any} value
*/
const isReactive = object => object
&& (typeof object == "object")
&& ("addEventListener" in object)
&& ("value" in object)
/** 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
const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number") {
return document.createTextNode(arg)
} else if (isElement(arg)) {
return arg
} else if (isReactive(arg)) {
return reactiveChild(arg)
/** 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
}
}
const reactiveChild = reactive => {
const ref = new WeakRef(toChild(reactive.value))
reactive.addEventListener("change", () => {
const value = ref.deref()
if (value)
value.replaceWith(reactiveChild(reactive))
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})
untilDeathDoThemPart(ref.deref(), reactive)
return ref.deref()
return element
}
const specialAttributes = {
value: {
get: element => element.value,
set: (element, value) => {
element.setAttribute("value", value)
element.value = value
},
hook: (element, callback) => { element.addEventListener("input", callback) }
},
style: {
set: (element, value) => { insertStyles(element.style, value) }
},
dataset: {
set: (element, value) => {
for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = parseAttribute(value2)
}
}
},
shadowRoot: {
set: (element, value) => {
parseArgs((element.shadowRoot || element.attachShadow({mode: "open"})), null, value)
}
/** 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 special = specialAttributes[attribute]
if (isReactive(value))
if (isObservable(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) => {
untilDeathDoThemPart(element, reactive)
/** Set up a binding between an attribute and an observable
* @param {Element} element
* @param {string} attribute
* @param {Observable} observable
*/
const setReactiveAttribute = (element, attribute, observable) => {
const multiAbort = new MultiAbortController()
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})
observable.addEventListener("change", () => {
multiAbort.abort()
setAttribute(element, attribute, observable.value, multiAbort.signal)
})
setAttribute(element, attribute, observable.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) {
untilDeathDoThemPart(element, observable)
special.hook.call(element, () => {
const current = special.get.call(element, attribute)
if (current != observable.value) observable.value = current
})
}
}
const parseArgs = (element, before, ...args) => {
if (element.content) element = element.content
/** Processes a list of arguments for an HTML Node
* @param {Element} element
* @param {Array} args
*/
const processArgs = (element, ...args) => {
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 (Array.isArray(arg)) {
processArgs(element, ...arg)
} else {
const child = toElement(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])
}
}
}
/** Creates a new node
* @param {String} name
* @param {Array} args
* @param {Object} options
*/
const node = (name, args, options) => {
let element
const custom = getCustom(args)
@ -174,11 +258,12 @@ 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
}
const nameSpacedProxy = (options={}) => new Proxy(Window, {
/** @param {string} prop */
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
has: (_target, _prop) => true,
})
@ -189,31 +274,44 @@ export default html
// 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) }
// 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) => {
const fragment = new DocumentFragment()
for (const element of elements)
fragment.append(element)
fragment.append(toElement(element))
return fragment
}
// Turns a template literal into document fragment.
// Strings are returned as text nodes.
// Elements are inserted in between.
/** Turns a template literal into document fragment.
* Strings are returned as text nodes.
* Elements are inserted in between.
* @param {Array<String>} literals
* @param {Array<any>} items
* @return {DocumentFragment}
*/
const textFromTemplate = (literals, items) => {
const fragment = new DocumentFragment()
for (const key in items) {
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
}
/**
* @param {String|Array<String>} data
* @param {Array<String|Element>} items
*/
export const text = (data="", ...items) =>
typeof data == "object" && "at" in data
Array.isArray(data)
? textFromTemplate(data, items)
: document.createTextNode(data)

413
state.js
View file

@ -1,413 +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
constructor(...changes) {
super('change')
this.changes = changes
}
get final() {
if (!this.#final) {
this.#final = new Map(this.changes)
}
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
#queue
#forwardCache
#abortController
#nested = new Map()
#weakRef = new WeakRef(this)
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
constructor(target={}, options={}) {
super()
this.#abortController = new AbortController
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) {
this.addEventListener("change", ({final}) => {
final.forEach((value, prop) => {
if (`${prop}Changed` in this) this[`${prop}Changed`](value)
})
})
}
}
// 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)
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()
}
// Backwards compatibility
get proxy() { return this.values }
}
const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => {
cache.remove(name)
})
export class ForwardState extends SimpleState {
#backend
#property
#fallback
constructor(backend, property, fallback) {
super()
this.#backend = backend
this.#property = property
this.#fallback = fallback
const ref = new WeakRef(this)
const abortController = new AbortController()
backend.addEventListener("change", event => {
const state = ref.deref()
if (state) {
const relevantChanges = event.changes
.filter(([name]) => name === property)
.map(([_, value]) => ["value", value])
if (relevantChanges.length > 0)
state.dispatchEvent(new ChangeEvent(...relevantChanges))
} else {
abortController.abort()
}
}, {signal: abortController.signal})
}
get value() { return this.#backend.values[this.#property] ?? this.#fallback }
set value(value) { 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 = (generator, name) => {
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)
if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
}
})
attributeObserver.observe(this, {attributes: true})
this.replaceChildren(generator(this))
}
}
customElements.define(name, Element)
return Element;
}
class ComposedState extends SimpleState {
#func
#states
#options
constructor(func, options, ...states) {
super()
this.#func = func
this.#states = states
this.#options = options
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.#options.defer) {
if (!this.#microtaskQueued) {
queueMicrotask(() => {
this.#microtaskQueued = false
this.update()
})
}
this.#microtaskQueued = true
} else {
this.update()
}
}
update() {
this.value = this.#func(...this.#states.map(state => state.value))
this.dispatchEvent(new ChangeEvent([["value", this.value]]))
}
}
export const compose = func => (...states) => new ComposedState(func, {defer: true}, ...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
#defer
#getValue
#equal
#old
#changedValue = false
constructor(target, options) {
super()
this.#defer = options.defer ?? false
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.#defer) {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new ChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = current
}
} else {
this.dispatchEvent(new ChangeEvent(["value", current]))
}
}
}
export default State