Compare commits

..

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

5 changed files with 99 additions and 403 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", "name": "skooma",
"version": "1.7.0", "version": "1.3.0",
"author": "darkwiiplayer", "author": "darkwiiplayer",
"license": "Unlicense", "license": "Unlicense",
"main": "skooma.js", "main": "skooma.js",

115
readme.md
View file

@ -1,69 +1,61 @@
# Skooma # Skooma
A functional-friendly helper library for procedural DOM generation and
templating.
```js ```js
import {html} from "skooma.js" import {html} from "skooma.js"
``` ```
A functional-friendly helper library for procedural DOM generation and
templating.
## Overview ## Overview
```js ```js
const text = new State({value: "Skooma is cool"})
setTimeout(() => {text.value = "Skooma is awesome!"}, 1e5)
document.body.append(html.div( document.body.append(html.div(
html.h1("Hello, World!"), html.h1("Hello, World!"),
html.p(text, {class: "amazing"}), html.p("Skooma is cool", {class: "amazing"}),
html.button("Show Proof", {click: event => { alert("It's true!") }}) html.button("Show Proof", click: event => { alert("It's true!") })
)) ))
``` ```
## Interface / Examples ## Interface / Examples
### Basic DOM generation ### HTML generation
Accessing the `html` proxy with any string key returns a new node generator
function:
```js ```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 Generators can be called with many arguments. Arrays get iterated recursively as
if they were part of a flat argument list. 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 ! // 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 ## handle
```js ```js

View file

@ -10,15 +10,6 @@ or
html.ul([1, 2, 3, 4, 5].map(x => html.li(x)), {class: "numbers"}) 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") 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 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 ,undefined
) )
const isElement = object => HTMLElement.prototype.isPrototypeOf(object)
const isReactive = object => object const isReactive = object => object
&& (typeof object == "object") && (typeof object == "object")
&& ("addEventListener" in object) && ("addEventListener" in object)
@ -60,7 +49,7 @@ const isReactive = object => 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 ("nodeName" in arg) {
return arg return arg
} else if (isReactive(arg)) { } else if (isReactive(arg)) {
return reactiveChild(arg) return reactiveChild(arg)
@ -74,7 +63,6 @@ const reactiveChild = reactive => {
if (value) if (value)
value.replaceWith(reactiveChild(reactive)) value.replaceWith(reactiveChild(reactive))
}, {once: true}) }, {once: true})
untilDeathDoThemPart(ref.deref(), reactive)
return ref.deref() return ref.deref()
} }
@ -123,8 +111,6 @@ const setAttribute = (element, attribute, value, cleanupSignal) => {
} }
const setReactiveAttribute = (element, attribute, reactive, abortController) => { const setReactiveAttribute = (element, attribute, reactive, abortController) => {
untilDeathDoThemPart(element, reactive)
if (abortController) abortController.abort() if (abortController) abortController.abort()
abortController = new AbortController() 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 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 const svg = nameSpacedProxy({xmlns: "http://www.w3.org/2000/svg"})
export default html
// Other utility exports // 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
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 // Wraps a list of elements in a document fragment
export const fragment = (...elements) => { export const fragment = (...elements) => {

281
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 { export class ChangeEvent extends Event {
#final #final
constructor(...changes) { constructor(...changes) {
@ -17,8 +12,6 @@ export class ChangeEvent extends Event {
} }
} }
export class SimpleState extends EventTarget {}
export class MapStorage extends Storage { export class MapStorage extends Storage {
#map = new Map() #map = new Map()
key(index) { key(index) {
@ -41,36 +34,19 @@ export class MapStorage extends Storage {
} }
} }
export class State extends SimpleState { export class State extends EventTarget {
#target #target
#options #options
#queue #queue
#forwardCache
#abortController
#nested = new Map()
#weakRef = new WeakRef(this)
static isState(object) { return SimpleState.prototype.isPrototypeOf(object) }
constructor(target={}, options={}) { constructor(target={}, options={}) {
super() super()
this.#abortController = new AbortController
abortRegistry.register(this, this.#abortController)
this.#options = options this.#options = options
this.#target = target this.#target = target
this.values = new Proxy(target, { this.proxy = new Proxy(target, {
set: (_target, prop, value) => { set: (_target, prop, value) => {
const old = this.get(prop) this.emit(prop, value)
if (old !== value) { this.set(prop, 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 return true
}, },
get: (_target, prop) => this.get(prop), 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. // When you only need one value, you can skip the proxy.
set value(value) { this.values.value = value } set value(value) { this.proxy.value = value }
get value() { return this.values.value } get value() { return this.proxy.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 // Anounces that a prop has changed
emit(prop, value) { emit(prop, value) {
@ -132,84 +85,46 @@ export class State extends SimpleState {
} }
} }
forward(property="value", fallback) { forward(property="value") {
if (!this.#forwardCache) this.#forwardCache = new Map() return new ForwardState(this, property)
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) { set(prop, value) {
if (args.length === 1) return this.set("value", ...args)
const [prop, value] = args
this.#target[prop] = value this.#target[prop] = value
} }
get(...args) { get(prop) {
if (args.length === 0) return this.get("value")
const prop = args[0]
return this.#target[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]) => { export class ForwardState extends EventTarget {
cache.remove(name)
})
export class ForwardState extends SimpleState {
#backend #backend
#property #property
#fallback
constructor(backend, property, fallback) { constructor(backend, property) {
super() super()
this.#backend = backend this.#backend = backend
this.#property = property this.#property = property
this.#fallback = fallback
const ref = new WeakRef(this) const ref = new WeakRef(this)
const abortController = new AbortController() const abortController = new 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 const relevantChanges = event.changes.filter(([name]) => name === property)
.filter(([name]) => name === property)
.map(([_, value]) => ["value", value])
if (relevantChanges.length > 0) if (relevantChanges.length > 0)
state.dispatchEvent(new ChangeEvent(...relevantChanges)) state.dispatchEvent(new ChangeEvent(relevantChanges))
} else { } else {
abortController.abort() abortController.abort()
} }
}, {signal: abortController.signal}) }, {signal: abortController.signal})
} }
get value() { return this.#backend.values[this.#property] ?? this.#fallback } get value() { return this.#backend.proxy[this.#property] }
set value(value) { this.#backend.values[this.#property] = value } set value(value) { this.#backend.proxy[this.#property] = value }
} }
class StorageChangeEvent extends Event { export class StorageChangeEvent extends Event {
constructor(storage, key, value, targetState) { constructor(storage, key, value, targetState) {
super("storagechange") super("storagechange")
this.storageArea = storage this.storageArea = storage
@ -221,13 +136,16 @@ class StorageChangeEvent extends Event {
export class StoredState extends State { export class StoredState extends State {
#storage #storage
#valueKey
constructor(init, options={}) { constructor(init, options={}) {
super({}, options) super({}, options)
this.#storage = options.storage ?? localStorage ?? new MapStorage() this.#storage = options.storage ?? localStorage ?? new MapStorage()
this.#valueKey = options.key ?? 'value'
// Initialise storage from defaults // 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) if (this.#storage[prop] === undefined)
this.set(prop, value) this.set(prop, value)
} }
@ -243,7 +161,9 @@ export class StoredState extends State {
// Listen for changes from other windows // Listen for changes from other windows
const handler = event => { const handler = event => {
if (event.targetState !== this && event.storageArea == this.#storage) { 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) addEventListener("storage", handler)
@ -251,162 +171,15 @@ export class StoredState extends State {
} }
set(prop, value) { set(prop, value) {
if (prop == "value") prop = this.#valueKey
const json = JSON.stringify(value) const json = JSON.stringify(value)
dispatchEvent(new StorageChangeEvent(this.#storage, prop, json, this)) dispatchEvent(new StorageChangeEvent(this.#storage, prop, json, this))
this.#storage[prop] = json this.#storage[prop] = json
} }
get(prop) { get(prop) {
const value = this.#storage[prop] if (prop == "value") prop = this.#valueKey
return value && JSON.parse(value) return JSON.parse(this.#storage[prop])
}
}
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]))
}
} }
} }