Compare commits

..

7 commits

2 changed files with 155 additions and 7 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()
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,5 +1,8 @@
export const abortRegistry = new FinalizationRegistry(controller => controller.abort()) export 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) {
@ -250,26 +253,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.proxy[camelName]) !== next)
target.state.proxy[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 +330,62 @@ 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 {
#old
#options
#changedValue = false
constructor(target, value, options) {
super()
this.#options = options
this.#old = [...value]
this.value = value
const controller = new AbortController()
mutationObserver.observe(target, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
target.addEventListener(eventName, event=>{this.update(event)}, {signal: controller.signal})
abortRegistry.register(this, controller)
}
update() {
const current = [...this.value]
if (current.length === this.#old.length) {
for (const idx in current) {
if (current[idx] !== this.#old[idx]) break
}
return
}
this.#old = current
if (this.#options?.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