Compare commits

..

17 commits
v1.6.1 ... main

Author SHA1 Message Date
c5c4e973a5 Remove valueKey from StoredState 2024-01-17 14:09:50 +01:00
47994975f9 Replace state "deep" option with "shallow" option 2024-01-17 11:52:41 +01:00
035eeb8fc0 Add subscribe method to state class
This makes the State class fully compatible with svelte stores
2024-01-17 11:39:16 +01:00
69a2aa1ca3 Allow calling get and set without property name 2024-01-17 11:31:38 +01:00
5726b8c2b9 Rename proxy to values in State class 2024-01-17 11:07:57 +01:00
5a29b0e662 Make handle return a value just in case it is desired 2024-01-15 11:50:27 +01:00
0e6eee28fd Make DOMState API more generic
The old interface was too specific to the way domLense works, by
assuming the value to be an array that gets mutated elsewhere (or a
proxy that behaves like one)
2024-01-15 11:48:52 +01:00
eb75dc531a Remove exports from state.js 2024-01-15 11:03:11 +01:00
3b3e6467c8 Refactor exports
Export rules:
* All default exports as named exports
* Default export wherever it makes sense
2024-01-11 15:44:11 +01:00
7be7cf0210
Change DOMState value to last static value 2024-01-10 22:47:30 +01:00
61be174b6e
Add domState class 2024-01-10 20:42:47 +01:00
f199f64932 Add kebab<->camel case conversion to component state 2024-01-10 15:23:10 +01:00
451718cb74 Add event method/setting to domLense 2024-01-10 15:01:46 +01:00
fb000ba7a3 Replace state from component constructor entirely 2024-01-10 13:31:42 +01:00
b51732636d Add component as argument to state component constructor 2024-01-10 13:28:56 +01:00
f6b82e22ef Enable lense growing with empty values 2024-01-08 14:27:14 +01:00
be79784b2d Add domLense helper 2024-01-08 13:37:36 +01:00
4 changed files with 201 additions and 29 deletions

85
domLense.js Normal file
View file

@ -0,0 +1,85 @@
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.6.1", "version": "1.7.0",
"author": "darkwiiplayer", "author": "darkwiiplayer",
"license": "Unlicense", "license": "Unlicense",
"main": "skooma.js", "main": "skooma.js",

View file

@ -10,6 +10,7 @@ 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 weakReferences = new WeakMap()
const untilDeathDoThemPart = (referrer, reference) => { const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) { if (!weakReferences.has(referrer)) {
@ -184,11 +185,12 @@ 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 => { fn(event); event.preventDefault() } 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
export const fragment = (...elements) => { export const fragment = (...elements) => {

139
state.js
View file

@ -1,4 +1,7 @@
export const abortRegistry = new FinalizationRegistry(controller => controller.abort()) 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
@ -14,7 +17,7 @@ export class ChangeEvent extends Event {
} }
} }
class SimpleState extends EventTarget {} export class SimpleState extends EventTarget {}
export class MapStorage extends Storage { export class MapStorage extends Storage {
#map = new Map() #map = new Map()
@ -57,12 +60,12 @@ export class State extends SimpleState {
this.#options = options this.#options = options
this.#target = target this.#target = target
this.proxy = new Proxy(target, { this.values = new Proxy(target, {
set: (_target, prop, value) => { set: (_target, prop, value) => {
const old = this.get(prop) const old = this.get(prop)
if (old !== value) { if (old !== value) {
this.emit(prop, value) this.emit(prop, value)
if (this.#options.deep !== false) { if (this.#options.shallow) {
if (State.isState(old)) this.disown(prop, old) if (State.isState(old)) this.disown(prop, old)
if (State.isState(value)) this.adopt(prop, value) if (State.isState(value)) this.adopt(prop, value)
} }
@ -87,8 +90,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.proxy.value = value } set value(value) { this.values.value = value }
get value() { return this.proxy.value } get value() { return this.values.value }
adopt(prop, state) { adopt(prop, state) {
let handlers = this.#nested.get(state) let handlers = this.#nested.get(state)
@ -143,13 +146,33 @@ export class State extends SimpleState {
} }
} }
set(prop, value) { set(...args) {
if (args.length === 1) return this.set("value", ...args)
const [prop, value] = args
this.#target[prop] = value this.#target[prop] = value
} }
get(prop) { get(...args) {
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]) => { const forwardFinalizationRegistry = new FinalizationRegistry(([cache, name]) => {
@ -182,11 +205,11 @@ export class ForwardState extends SimpleState {
}, {signal: abortController.signal}) }, {signal: abortController.signal})
} }
get value() { return this.#backend.proxy[this.#property] ?? this.#fallback } get value() { return this.#backend.values[this.#property] ?? this.#fallback }
set value(value) { this.#backend.proxy[this.#property] = value } set value(value) { this.#backend.values[this.#property] = value }
} }
export class StorageChangeEvent extends Event { class StorageChangeEvent extends Event {
constructor(storage, key, value, targetState) { constructor(storage, key, value, targetState) {
super("storagechange") super("storagechange")
this.storageArea = storage this.storageArea = storage
@ -198,16 +221,13 @@ export 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 (let [prop, value] of Object.entries(init)) { for (const [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)
} }
@ -223,9 +243,7 @@ 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) {
let prop = event.key this.emit(event.key, JSON.parse(event.newValue))
if (prop === this.#valueKey) prop = 'value'
this.emit(prop, JSON.parse(event.newValue))
} }
} }
addEventListener("storage", handler) addEventListener("storage", handler)
@ -233,14 +251,12 @@ 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) {
if (prop == "value") prop = this.#valueKey
const value = this.#storage[prop] const value = this.#storage[prop]
return value && JSON.parse(value) return value && JSON.parse(value)
} }
@ -250,26 +266,28 @@ const attributeObserver = new MutationObserver(mutations => {
for (const {type, target, attributeName: name} of mutations) { for (const {type, target, attributeName: name} of mutations) {
if (type == "attributes") { if (type == "attributes") {
const next = target.getAttribute(name) const next = target.getAttribute(name)
if (String(target.state.proxy[name]) !== next) const camelName = kebabToCamel(name)
target.state.proxy[name] = next if (String(target.state.values[camelName]) !== next)
target.state.values[camelName] = next
} }
} }
}) })
export const component = (generator, name) => { export const component = (generator, name) => {
name = name ?? generator.name.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`) name = name ?? camelToKebab(generator.name)
const Element = class extends HTMLElement{ const Element = class extends HTMLElement{
constructor() { constructor() {
super() super()
this.state = new State(Object.fromEntries([...this.attributes].map(attribute => [attribute.name, attribute.value]))) this.state = new State(Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value])))
this.state.addEventListener("change", event => { this.state.addEventListener("change", event => {
for (const [name, value] of event.changes) { for (const [name, value] of event.changes) {
if (this.getAttribute(name) !== String(value)) const kebabName = camelToKebab(name)
this.setAttribute(name, value) if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
} }
}) })
attributeObserver.observe(this, {attributes: true}) attributeObserver.observe(this, {attributes: true})
this.replaceChildren(generator(this.state)) this.replaceChildren(generator(this))
} }
} }
customElements.define(name, Element) customElements.define(name, Element)
@ -325,4 +343,71 @@ class ComposedState extends SimpleState {
export const compose = func => (...states) => new ComposedState(func, {defer: true}, ...states) 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 export default State