Compare commits

..

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

5 changed files with 91 additions and 425 deletions

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,6 +1,6 @@
{
"name": "skooma",
"version": "1.7.0",
"version": "1.2.0",
"author": "darkwiiplayer",
"license": "Unlicense",
"main": "skooma.js",

115
readme.md
View file

@ -1,69 +1,61 @@
# Skooma
A functional-friendly helper library for procedural DOM generation and
templating.
```js
import {html} from "skooma.js"
```
A functional-friendly helper library for procedural DOM generation and
templating.
## 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.p("Skooma is cool", {class: "amazing"}),
html.button("Show Proof", click: event => { alert("It's true!") })
))
```
## Interface / Examples
### Basic DOM generation
Accessing the `html` proxy with any string key returns a new node generator
function:
### HTML generation
```js
html.div("Hello, World!")
html.div()
// Creates a <div></div> element
html.div("hello", "world")
// <div>helloworld</div>
html.div(html.span())
// <div><span></span></div>
html.div([html.span(), html.span()])
// <div> <span></span> <span></span> </div>
html.div({class: "foo"})
// <div class="foo"></div>
html.div({class: ["foo", "bar"]})
// <div class="foo bar"></div>
html.div({click: 1})
// <div click="1"></div>
html.div({click: event => console.log(event.target)})
// Creates a <div> with an event listener for "click" events
html.div(player: {username: "KhajiitSlayer3564"})
// Creates a <div> with the attribute "player" set to a JSON-encoded Object
html.div("Old content", self => self.innerText = "Hello, World!")
// Creates a <div> and passes it to a function for further processing
html.div({foo: true})
// <div foo></div>
html.div({foo: "bar"}, {foo: false})
// <div></div>
// Special keys:
html.div({dataset: {foo: 1, bar: 2}})
// <div data-foo="1" data-bar="2"></div>
html.div({shadowRoot: html.span("Shadow root content")})
// Attaches a shadow root with a span
```
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")
)
)
```
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.")
}})
```
<!-- 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.
@ -86,6 +78,37 @@ text`Hello, ${html.b(user)}!`
// Text node for Hello, the <b> tag with the user's name, and a text node for !
```
## bind
```js
import {bind} from 'skooma.js'
```
This function offers a generic mechanism for binding elements to dynamic state.
It takes a register function that satisfies the following criteria:
- It returns an initial state as an array
- It accepts a callback function
- On state change, it calls it with the new state as its arguments
And returns a second function, which takes a transformation (another functuion)
from input state to DOM node. This transformation will be used to create an
initial element from the initial state, which will be returned.
On every state change, the transform ation will be called on the new state to
generate a new DOM Node, which replace the current one.
```js
bind(register)(html.div)
// Returns a div element bound to register
// Assuming register is a higher order function
// and html.div is a transformation from input state to a <div> node
```
Since references to the bound element can become stale, a `current` property
is set on every element that returns the current element. This will keep working
even after several state changes.
## handle
```js

View file

@ -10,15 +10,6 @@ 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())
@ -50,8 +41,6 @@ const getCustom = args => args.reduce(
,undefined
)
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object
&& (typeof object == "object")
&& ("addEventListener" in object)
@ -60,7 +49,7 @@ const isReactive = object => object
const toChild = arg => {
if (typeof arg == "string" || typeof arg == "number") {
return document.createTextNode(arg)
} else if (isElement(arg)) {
} else if ("nodeName" in arg) {
return arg
} else if (isReactive(arg)) {
return reactiveChild(arg)
@ -74,7 +63,6 @@ const reactiveChild = reactive => {
if (value)
value.replaceWith(reactiveChild(reactive))
}, {once: true})
untilDeathDoThemPart(ref.deref(), reactive)
return ref.deref()
}
@ -123,8 +111,6 @@ const setAttribute = (element, attribute, value, cleanupSignal) => {
}
const setReactiveAttribute = (element, attribute, reactive, abortController) => {
untilDeathDoThemPart(element, reactive)
if (abortController) abortController.abort()
abortController = new AbortController()
@ -185,12 +171,11 @@ const nameSpacedProxy = (options={}) => new Proxy(Window, {
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) }
export const handle = fn => event => { fn(event); event.preventDefault() }
// Wraps a list of elements in a document fragment
export const fragment = (...elements) => {

295
state.js
View file

@ -1,8 +1,3 @@
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) {
@ -17,8 +12,6 @@ export class ChangeEvent extends Event {
}
}
export class SimpleState extends EventTarget {}
export class MapStorage extends Storage {
#map = new Map()
key(index) {
@ -41,36 +34,19 @@ export class MapStorage extends Storage {
}
}
export class State extends SimpleState {
export class State extends EventTarget {
#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, {
this.proxy = 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)
}
this.emit(prop, value)
this.set(prop, value)
return true
},
get: (_target, prop) => this.get(prop),
@ -90,31 +66,8 @@ export class State extends SimpleState {
}
// When you only need one value, you can skip the proxy.
set value(value) { this.values.value = value }
get value() { return this.values.value }
adopt(prop, state) {
let handlers = this.#nested.get(state)
if (!handlers) {
// Actual adoption
handlers = new Map()
this.#nested.set(state, handlers)
}
const ref = this.#weakRef
const handler = () => ref.deref()?.emit(prop, state)
handlers.set(prop, handler)
state.addEventListener("change", handler, {signal: this.#abortController.signal})
}
disown(prop, state) {
const handlers = this.#nested.get(state)
const handler = handlers.get(prop)
state.removeEventListener("change", handler)
handlers.delete(prop)
if (handlers.size == 0) {
this.#nested.delete(state)
}
}
set value(value) { this.proxy.value = value }
get value() { return this.proxy.value }
// Anounces that a prop has changed
emit(prop, value) {
@ -132,84 +85,16 @@ export class State extends SimpleState {
}
}
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
set(prop, value) {
this.#target[prop] = value
}
get(...args) {
if (args.length === 0) return this.get("value")
const prop = args[0]
get(prop) {
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 {
export class StorageChangeEvent extends Event {
constructor(storage, key, value, targetState) {
super("storagechange")
this.storageArea = storage
@ -221,13 +106,16 @@ class StorageChangeEvent extends Event {
export class StoredState extends State {
#storage
#valueKey
constructor(init, options={}) {
super({}, options)
this.#storage = options.storage ?? localStorage ?? new MapStorage()
this.#valueKey = options.key ?? 'value'
// Initialise storage from defaults
for (const [prop, value] of Object.entries(init)) {
for (let [prop, value] of Object.entries(init)) {
if (prop === 'value') prop = this.#valueKey
if (this.#storage[prop] === undefined)
this.set(prop, value)
}
@ -243,7 +131,9 @@ export class StoredState extends State {
// 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))
let prop = event.key
if (prop === this.#valueKey) prop = 'value'
this.emit(prop, JSON.parse(event.newValue))
}
}
addEventListener("storage", handler)
@ -251,162 +141,15 @@ export class StoredState extends State {
}
set(prop, value) {
if (prop == "value") prop = this.#valueKey
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]))
}
if (prop == "value") prop = this.#valueKey
return JSON.parse(this.#storage[prop])
}
}