Compare commits

..

No commits in common. "ce416d994b485708cea8594d7fcd0666c7af5dd9" and "f94fad92eb6b02187b775b2e5592baa716f7a595" have entirely different histories.

3 changed files with 190 additions and 63 deletions

View file

@ -21,16 +21,16 @@ const target = Symbol("Proxy Target")
*/
/** Event fired for every change before the internal state has been updated that can be canceled. */
export class ChangeEvent extends Event {
export class SynchronousChangeEvent extends Event {
/** @param {Change} change */
constructor(change) {
super('change', {cancelable: true})
super('synchronous', {cancelable: true})
this.change = Object.freeze(change)
}
}
/** Event fired for one or more changed values after the internal state has been updated. */
export class ChangedEvent extends Event {
export class MultiChangeEvent extends Event {
/** @type {any} */
#final
/** @type {any} */
@ -71,6 +71,14 @@ export class ChangedEvent extends Event {
}
}
export class ValueChangeEvent extends MultiChangeEvent {
get value() {
return this.final.value
}
}
/* Observable Classes */
export class Observable extends EventTarget {
#synchronous
/** @type Change[]> */
@ -95,11 +103,6 @@ export class Observable extends EventTarget {
})
}
/** @param {Change[]} changes */
emit(...changes) {
this.dispatchEvent(new ChangedEvent(...changes))
}
/**
* @param {string} prop
*/
@ -133,7 +136,7 @@ export class Observable extends EventTarget {
*/
enqueue(property, from, to, mutation=false) {
const change = {property, from, to, mutation}
if (!this.dispatchEvent(new ChangeEvent(change))) return false
if (!this.dispatchEvent(new SynchronousChangeEvent(change))) return false
if (!this.synchronous) {
if (!this.#queue) {
this.#queue = []
@ -149,13 +152,17 @@ export class Observable extends EventTarget {
return true
}
/** @param {any[]} _args */
emit(..._args) {
throw new TypeError(`${this.constructor.name} did not define an 'emit' method`)
}
get signal() { return this.#abortController.signal }
get synchronous() { return this.#synchronous }
get changesQueued() { return Boolean(this.#queue) }
}
export class ObservableObject extends Observable {
/**
* @param {Object} target
* @param {Object} options
@ -205,10 +212,15 @@ export class ObservableObject extends Observable {
return proxy
}
/** @param {Change[]} changes */
emit(...changes) {
this.dispatchEvent(new MultiChangeEvent(...changes))
}
/** @type Map<Observable, Map<String, Function>> */
#nested = new Map()
/** Adopts an observable to be notified of its changes
/** Adopts an obsercable to be notified of its changes
* @param {string} prop
* @param {Observable} observable
*/
@ -260,9 +272,20 @@ export class ObservableValue extends Observable {
}
}
/**
* @param {string} prop
* @param {function(any):void} callback
*/
subscribe(prop, callback) {
// @ts-ignore
if (typeof(prop) == "function") [prop, callback] = ["value", prop]
this.constructor.prototype.subscribe.call(this, prop, callback)
}
/** @param {Change[]} changes */
emit(...changes) {
this.dispatchEvent(new ChangedEvent(...changes))
this.dispatchEvent(new ValueChangeEvent(...changes))
}
}
@ -302,7 +325,7 @@ class ProxiedObservableValue extends ObservableValue {
const attributeObserver = new MutationObserver(mutations => {
for (const {type, target, attributeName: name} of mutations) {
if (type == "attributes" && target instanceof HTMLElement) {
if (type == "attributes") {
const next = target.getAttribute(name)
const camelName = kebabToCamel(name)
if (String(target.state.values[camelName]) !== next)
@ -317,21 +340,17 @@ export const component = (name, generator, methods) => {
generator = name
name = camelToKebab(generator.name)
}
component[kebabToCamel(name)] = class extends HTMLElement{
/** @type {ObservableObject} */
state
const Element = class extends HTMLElement{
constructor() {
super()
const target = Object.fromEntries([...this.attributes].map(attribute => [kebabToCamel(attribute.name), attribute.value]))
this.state = new ObservableObject(target)
this.state.addEventListener("changed", event => {
if (event instanceof ChangedEvent)
for (const {property, to: value} of event.changes) {
const kebabName = camelToKebab(property)
if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
}
this.state.addEventListener("change", event => {
for (const {property, to: value} of event.changes) {
const kebabName = camelToKebab(property)
if (this.getAttribute(kebabName) !== String(value))
this.setAttribute(kebabName, value)
}
})
attributeObserver.observe(this, {attributes: true})
const content = generator.call(this, this.state)
@ -345,26 +364,21 @@ export const component = (name, generator, methods) => {
return Element;
}
class Composition extends ObservableValue {
class ObservableComposition extends ObservableValue {
#func
#states
/**
* @param {(...values: any[]) => any} func
* @param {Object} options
* @param {Observable[]} states
*/
constructor(func, options, ...obesrvables) {
constructor(func, options, ...states) {
super(options)
this.#func = func
this.#states = obesrvables
this.#states = states
const abortController = new AbortController()
abortRegistry.register(this, abortController)
const ref = new WeakRef(this)
obesrvables.forEach(state => {
states.forEach(state => {
state.addEventListener("change", () => {
ref.deref()?.scheduleUpdate()
}, {signal: abortController.signal})
@ -373,7 +387,7 @@ class Composition extends ObservableValue {
this.update()
}
#microtaskQueued = false
#microtaskQueued
scheduleUpdate() {
if (this.synchronous) {
this.update()
@ -396,12 +410,106 @@ class Composition extends ObservableValue {
}
}
/**
* @param {Function} func
*/
export const compose = func =>
export const compose = func => (...states) => new ObservableComposition(func, {}, ...states)
class MutationEvent extends Event {
constructor() {
super("mutation", {bubbles: true})
}
}
const mutationObserver = new MutationObserver(mutations => {
for (const mutation of mutations) {
mutation.target.dispatchEvent(new MutationEvent())
}
})
export class ObservableElement extends Observable {
#getValue
#equal
#value
#changedValue = false
/**
* @param {Observable[]} observables
* @param {HTMLElement} target
*/
(...observables) =>
new Composition(func, {}, ...observables)
constructor(target, {get=undefined, equal=undefined, ...options}={}) {
// @ts-ignore
super(options)
Object.defineProperty(this, "target", {value: target, configurable: false, writable: false})
this.#getValue = get ?? (target => target.value)
this.#equal = equal ?? ((a, b) => a===b)
this.#value = this.#getValue(target)
const controller = new AbortController()
target.addEventListener("mutation", 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.#value }
update() {
const current = this.#getValue(this.target)
if (this.#equal(this.#value, current)) return
this.#value = current
if (this.synchronous) {
this.dispatchEvent(new ValueChangeEvent(["value", current]))
} else {
if (!this.#changedValue) {
queueMicrotask(() => {
this.#changedValue = false
this.dispatchEvent(new ValueChangeEvent(["value", this.#changedValue]))
})
this.#changedValue = current
}
}
}
}
export class MapStorage extends Storage {
#map = new Map()
/**
* @param {number} index
* @return {string}
*/
key(index) {
return [...this.#map.keys()][index]
}
/**
* @param {string} keyName
* @return {any}
*/
getItem(keyName) {
if (this.#map.has(keyName))
return this.#map.get(keyName)
else
return null
}
/**
* @param {string} keyName
* @param {any} keyValue
*/
setItem(keyName, keyValue) {
this.#map.set(keyName, String(keyValue))
}
/** @param {string} keyName */
removeItem(keyName) {
this.#map.delete(keyName)
}
clear() {
this.#map.clear()
}
}

View file

@ -1,10 +1,44 @@
# Nyooom
```js
import {html} from "nyooom/render"
import {ObservableValue} from "nyooom/observable"
import {html} from "nyooom/render.js"
const text = new ObservableValue("Nyooom is cool")
document.body.append(
html.p(
"This is a paragraph with some text ",
html.b("and some bold text "),
html.img({
alt: "And an image",
href: "http://picsum.photos/200/200"
})
)
)
```
## Goals
1. `nyooom/render` should stay small enough to use it as just a helper library
to generate some dom nodes in any sort of web environment.
1. `nyooom/observable` should likewise function as a standalone reactive state
management library to be used with or without a framework
1. A developer who doesn't use nyooom should be able to read any code using it
and piece together what it does based on structure and function names
1. Nyooom should be easy to gradually introduce into an application that uses
a different framework or no framework at all
1. Nyooom should make it easy to gradually replace it with a different solution
should it prove unfit for a project it is being used in
1. The library should be hackable so that developers can tweak it for different
environments like SSR or frameworks
## Warning
**This branch is in the process of being aggressively refactored and improved.
This readme file may not reflect the latest state of the interface.**
## Overview
```js
const text = new State({value: "Nyooom is cool"})
setTimeout(() => {text.value = "Nyooom is awesome!"}, 1e5)
document.body.append(html.div(
@ -14,21 +48,6 @@ document.body.append(html.div(
))
```
## Goals
> Arrakis teaches the attitude of the knife - chopping off what's incomplete and
> saying: "Now, it's complete because it's ended here."
>
> — Frank Herbert, Dune
Nyooom aims to offer as much convenienve as possible within the following
constraints:
1. Small, independent modules that can also work on their own
1. Code should be easy to figure out by someone who doesn't actiely use nyooom
1. Easy to gradually introduce and remove rather than forcing big re-writes
1. Flexible, hackable and easy to audit
## Importmaps
The included file `importmaps.html` can be used as a starting point for

View file

@ -220,9 +220,9 @@ export class DomRenderer extends Renderer {
element.dispatchEvent(new AfterReplaceEvent(next))
ref = new WeakRef(next)
}
observable.addEventListener("changed", handleChange, {once: true})
observable.addEventListener("change", handleChange, {once: true})
}
observable.addEventListener("changed", handleChange, {once: true})
observable.addEventListener("change", handleChange, {once: true})
return element
}
@ -261,7 +261,7 @@ export class DomRenderer extends Renderer {
static setReactiveAttribute(element, attribute, observable) {
const multiAbort = new MultiAbortController()
observable.addEventListener("changed", () => {
observable.addEventListener("change", () => {
multiAbort.abort()
if (element.dispatchEvent(new AttributeEvent(attribute, element.getAttribute(attribute), observable.value)))
this.setAttribute(element, attribute, observable.value, multiAbort.signal)