Compare commits

...

No commits in common. "main" and "documentation" have entirely different histories.

10 changed files with 269 additions and 884 deletions

2
.editorconfig Normal file
View file

@ -0,0 +1,2 @@
[*]
indent_style = tab

0
dom-proxy.md Normal file
View file

View file

@ -1,85 +0,0 @@
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)
const traps = {
get(target, prop) {
if (prop === "length") {
return target.children.length
} else if (prop === Symbol.iterator) {
return function*() {
for (const child of target.children) {
yield methods.get(child)
}
}
} else if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) return methods.get(child)
return child
} else {
return Array.prototype[prop]
}
},
set(target, prop, value) {
if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) {
methods.set(child, value)
return true
} else {
for (let i = target.children.length; i < Number(prop); i++) {
target.appendChild(methods.new(undefined))
}
const element = methods.new(value)
target.appendChild(element)
if (methods.get(element) !== value)
methods.set(element, value)
return true
}
} else if (prop == "length") {
if (value == target.children.length)
return true
else
return false
}
},
deleteProperty(target, prop) {
if (prop.match?.call(prop, /^[0-9]+$/)) {
const child = target.children[prop]
if (child) child.remove()
return true
}
},
has(target, prop) {
return (prop === Symbol.iterator) || (prop in target.children) || (prop in Array.prototype)
}
}
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
}
}
export default lense

View file

@ -1,22 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

0
observable.md Normal file
View file

View file

@ -1,21 +0,0 @@
{
"name": "skooma",
"version": "1.7.0",
"author": "darkwiiplayer",
"license": "Unlicense",
"main": "skooma.js",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/darkwiiplayer/skooma-js"
},
"homepage": "https://darkwiiplayer.github.io/skooma-js",
"keywords": [
"skooma",
"components",
"functional",
"html",
"ui",
"utility"
]
}

193
readme.md
View file

@ -1,148 +1,93 @@
# Skooma
## Skooma.js
A functional-friendly helper library for procedural DOM generation and
templating.
### HTML Proxy
The proxy object that does the actual HTML generation.
```js
import {html} from "skooma.js"
```
## Overview
```js
const text = new State({value: "Skooma is cool"})
setTimeout(() => {text.value = "Skooma is awesome!"}, 1e5)
document.body.append(html.div(
html.h1("Hello, World!"),
html.p(text, {class: "amazing"}),
html.button("Show Proof", {click: event => { alert("It's true!") }})
html.span("Hello, World!")
))
```
## Interface / Examples
### Handle helper
### Basic DOM generation
Accessing the `html` proxy with any string key returns a new node generator
function:
Wraps a funcion of the signature `event, ... -> value` so that
`event.preventDefault` gets called before running the function.
```js
html.div("Hello, World!")
```
Attributes can be set by passing objects to the generator:
```js
html.div("Big Text", {style: "font-size: 1.4em"})
```
Complex structures can easily achieved by nesting generator functions:
```js
html.div(
html.p(
html.b("Bold Text")
)
button.addEventListener("click",
handle(() => console.log("click"))
)
```
For convenience, arrays assigned as attributes will be joined with spaces:
### Fragment helper
Wraps a list of elements in a new document fragment.
```js
html.a({class: ["button", "important"]})
```
Assigning a function as an attribute will instead attach it as an event
listener:
```js
html.button("Click me!", {click: event => {
alert("You clicked the button.")
}})
```
<!-- TODO: Document special keys -->
Generators can be called with many arguments. Arrays get iterated recursively as
if they were part of a flat argument list.
### Generating Text Nodes
```js
text("Hello, World")
// Wraps document.createTextNode
text()
// Defaults to empty string instead of erroring
text(null)
// Non-string arguments still error
text`Hello, World!`
// returns a new document fragment containing the text node "Hello, World!"
text`Hello, ${user}!`
// returns a document fragment containing 3 nodes:
// "Hello, ", the interpolated value of `user` and "!"
text`Hello, ${html.b(user)}!`
// Text node for Hello, the <b> tag with the user's name, and a text node for !
```
## handle
```js
import {handle} from 'skooma.js'
```
Since it is common for event handlers to call `preventDefault()`, skooma
provides a helper function called `handle` with the following definition:
```js
fn => event => { event.preventDefault(); return fn(event) }
```
## A few more examples:
Create a Button that deletes itself:
```js
document.body.append(
html.button("Delete Me", {click: event => event.target.remove()})
const spans = fragment(
html.span("First span"),
html.span("Second span")
)
document.body.append(spans.cloneNode())
```
Turn a two-dimensional array into an HTML table:
### Text helper
When called as a normal function, returns a new text node with the given
content. Unlike `document.createTextNode`, it does not fail for non-string
values.
```js
const table = rows =>
html.table(html.tbody(rows.map(
row => html.tr(row.map(
cell => html.rd(cell, {dataset: {
content: cell.toLowerCase(),
}})
))
)))
const node = text("Hello, World!")
```
A list that you can add items to
When used as a tagged template, returns a document fragment containing text
nodes and interpolated values. DOM nodes can be interpolated into the document
fragment.
```js
let list, input = ""
document.body.append(html.div([
list=html.ul(),
html.input({type: 'text', input: e => input = e.target.value}),
html.button({click: event => list.append(html.li(input))}, "Add"),
]))
const description = text`Interpolate ${html.b("bold")} text`
```
A list that you can also delete items from
```js
const listItem = content => html.li(
html.span(content), " ", html.a("[remove]", {
click: event => event.target.closest("li").remove(),
style: { cursor: 'pointer', color: 'red' },
})
)
let list, input = ""
document.body.append(html.div([
list=html.ul(),
html.input({type: 'text', input: e => input = e.target.value}),
html.button({click: event => list.append(listItem(input))}, "Add"),
]))
```
For consistency, even tagged templates with no interpolated variables will
always return a document fragment.
## State.js
### AbortRegistry
`FinalizationRegistry` that takes an `AbortController` and aborts it whenever
the registered value gets collected.
### ChangeEvent
The event class emitted when a change is detected on a skooma state.
Provides the `final` getter.
### MapStorage
A utility class that simulates the `Storage` API but is backed by a map. Can be
used as fallback in environments where persistent storages aren't available.
### SimpleState
Base state class that all other states inherit from, used primarily for class
checking, as the `State` class introduces behaviours that may be undesireable
when inheriting.
### State
The main state class that does all the magic.
### ForwardState
Proxy to a named property on another State to be used with APIs that only accept
single-value states.
### StoredState
State class that is backed by a Storage instead of an internal proxy.
## domLense.js

198
render.md Normal file
View file

@ -0,0 +1,198 @@
# Rendering DOM nodes using `render.js`
```js
import {html} from "skooma/render.js"
```
A functional-friendly helper library for procedural DOM generation and templating, with support for reactive state objects.
## Summary
```js
html.button(
"Click Me!",
{
class: "primary",
click({target}) {
console.log("User clicked", target)
}
},
button => { console.log("Created", button) }
)
```
* elements as factory functions `content -> element`
* content as arguments or in arrays
* attributes in object arguments
* style, dataset, shadow root, etc. as magic attributes
* events as function attributes
* initialisers as `any -> void` functions
## Interface / Examples
### Basic DOM generation
Accessing the `html` proxy with any string key returns a new node generator function.
When called this function will generate a DOM node (HTML Tag).
The name of the function becomes the tag name of the node.
```js
html.div()
```
Content and attributes can be set via the function arguments:
Strings and DOM nodes are inserted as children, while other objects (except for some special cases) have their key-value pairs turned into attribute-value pairs on the
```js
html.div("Big Text", {style: "font-size: 1.4em"})
```
Arrays are iterated and their values treated as arguments.
This works both for inserting children and setting attributes.
```js
const content = [" ps: hi", {class: "title"}]
html.h1({id: "main-heading"}, "Heading", content)
```
Function arguments are treated differently depending on their length:]
Functions with **no** named parameters are called, and their return value is then evaluated just like an argument to the generator.
All other functions are (immediately) called with the newly created node as their first and only argument.
These can be used to initialise the new node in a point-free style.
```js
const hello = () => html.bold("Hello, World!")
const init = node => console.log("Initialising", node)
html.div(hello, init)
```
Nested tags can be generated with nested function calls.
When properly formatted, this means simpler templates will have the same structure as if written in HTML (sans those pesky closing tags).
```js
html.div(
html.p(
html.b("Bold Text")
)
)
```
### Attribute Processing
For convenience, arrays assigned as attributes will be joined with spaces:
```js
html.a({class: ["button", "important"]})
```
Assigning a function as an attribute will instead attach it as an event listener:
```js
html.button("Click me!", {click: event => {
alert("You clicked the button.")
}})
```
The special `style` property can be set to an object and its key/value pairs will be inserted as CSS properties on the element's `style` object.
```js
const style = { color: "salmon" }
html.span("Salmon", { style })
```
The special property `shadowRoot` will attach a shadow-DOM to the element if none is present and append its content to the shadow root.
Arrays are iterated over and their elements appended individually.
```js
html.div({
shadowRoot = ["Hello, ", html.b("World"), "!"]
})
```
The `dataset` property will add its key/value pairs to the new node's `dataset`,
as a more convenient alternative to setting individual `data-` attributes.
```js
const dataset = { foo: "bar" }
const div = html.div({dataset})
console.log(dataset.foo === div.dataset.foo)
console.log(div.getAttribute("data-foo") === "bar")
```
### Reactivity
Skooma supports reactivity through a simple protocol:
Observable objects identify themselves with the `observable` attribute,
which must return a truthy value.
Observables are expected to expose a `value` attribute that is both readable and writeable,
and to emit a "change" event whenever its vale has changed.
Observables can be passed to skooma's node functions as both
attribute values (values in an object) or
child elements (direct arguments or in an array).
#### Reactive Children
Passing an observable as a child element will attempt to insert its current
value into the new node as if it was passed in directly, but will also hook into
the observable to replace the value when the state changes.
```js
const state = new Observable.value(0)
const button = html.button(state, {
click(event) { state.value++ }
})
```
**Note** that to keep the replacement logic simple, it is not currently possible
to insert use document fragments, as these could insert several top-level
children into a component that would then all have to be replaced. When an
observable contains or changes to a document fragment, skooma will raise an
error.
When replacing a reactive child, the following events are dispatched:
- `skooma:beforereplace` (cancelable) on the element being replaced while it is
still in the DOM.
- `skooma:afterreplace` on the old element after it was removed from the DOM and
replaced with a new one.
- `skooma:replaced` (bubbles) on the new event after it has replaced the old one
and inserted into the DOM.
Before replacing an element, a `"replace"` event is emitted from the old
element. This event bubbles and is cancelable, and can thus be used both to
completely prevent the replacement according to custom logic, to alter or
initialise the new element before it is inserted, or even to modify the old
object instead of replacing it.
#### Reactive Attributes
Passing an observable as an object value will, likewise, treat its value as the
attribute value, and update it whenever the state's value changes.
```js
const state = new Observable.value(0)
const input_1 = html.input({ type: "number", value: state })
const input_2 = html.input({ type: "number", value: state })
```
When a reactive attribute is updated to a new value, a `skooma:attribute` event
is dispatched on the element. Canceling the event will prevent the attribute
from being changed.
The element has the properties `attribute`, `to` and `from`, representing the
attribute name new and old values.
## Custom Renderers
Skooma uses a class hierarchy to handle the complexity of generating DOM nodes.
The `Renderer` class provides the more generic utility functions to build custom
renderers for targets other than the DOM.
This allows building custom renderers that can work on the back-end by
outputting elements in a virtual dom or even plain HTML.

219
skooma.js
View file

@ -1,219 +0,0 @@
/*
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())
}
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())
}
const parseAttribute = (attribute) => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
return attribute.join(" ")
else
return JSON.stringify(attribute)
}
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
const getCustom = args => args.reduce(
(current, argument) => Array.isArray(argument)
? defined(getCustom(argument), current)
: (argument && typeof argument == "object")
? defined(argument.is, current)
: current
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object
&& (typeof object == "object")
&& ("addEventListener" in object)
&& ("value" in object)
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)
}
}
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)
return ref.deref()
}
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)
}
}
}
const setAttribute = (element, attribute, value, cleanupSignal) => {
const special = specialAttributes[attribute]
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)
}
else if (value === true)
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, parseAttribute(value))
}
}
const setReactiveAttribute = (element, attribute, reactive, abortController) => {
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 special = specialAttributes[attribute]
if (special?.hook) {
special.hook(element, () => {
const value = special.get(element, attribute)
if (value != reactive.value) reactive.value = value
})
}
}
const parseArgs = (element, before, ...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])
}
}
const node = (name, args, options) => {
let element
const custom = getCustom(args)
const opts = custom && {is: String(custom)}
if ("nameFilter" in options) name = options.nameFilter(name)
if (options.xmlns)
element = document.createElementNS(options.xmlns, name, opts)
else
element = document.createElement(name, opts)
parseArgs(element, null, args)
return element
}
const nameSpacedProxy = (options={}) => new Proxy(Window, {
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
has: (_target, _prop) => true,
})
export const html = nameSpacedProxy({nameFilter: name => name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()})
export const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
export default html
// Other utility exports
// Wraps an event handler in a function that calls preventDefault on the event
export const handle = fn => event => { event.preventDefault(); return fn(event) }
// Wraps a list of elements in a document fragment
export const fragment = (...elements) => {
const fragment = new DocumentFragment()
for (const element of elements)
fragment.append(element)
return fragment
}
// Turns a template literal into document fragment.
// Strings are returned as text nodes.
// Elements are inserted in between.
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(document.createTextNode(literals.at(-1)))
return fragment
}
export const text = (data="", ...items) =>
typeof data == "object" && "at" in 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