Compare commits

...

13 commits

6 changed files with 734 additions and 261 deletions

0
doc/dom-proxy.md Normal file
View file

0
doc/observable.md Normal file
View file

93
doc/readme.md Normal file
View file

@ -0,0 +1,93 @@
## Skooma.js
### HTML Proxy
The proxy object that does the actual HTML generation.
```js
document.body.append(html.div(
html.span("Hello, World!")
))
```
### Handle helper
Wraps a funcion of the signature `event, ... -> value` so that
`event.preventDefault` gets called before running the function.
```js
button.addEventListener("click",
handle(() => console.log("click"))
)
```
### Fragment helper
Wraps a list of elements in a new document fragment.
```js
const spans = fragment(
html.span("First span"),
html.span("Second span")
)
document.body.append(spans.cloneNode())
```
### Text helper
When called as a normal function, returns a new text node with the given
content. Unlike `document.createTextNode`, it does not fail for non-string
values.
```js
const node = text("Hello, World!")
```
When used as a tagged template, returns a document fragment containing text
nodes and interpolated values. DOM nodes can be interpolated into the document
fragment.
```js
const description = text`Interpolate ${html.b("bold")} text`
```
For consistency, even tagged templates with no interpolated variables will
always return a document fragment.
## State.js
### AbortRegistry
`FinalizationRegistry` that takes an `AbortController` and aborts it whenever
the registered value gets collected.
### ChangeEvent
The event class emitted when a change is detected on a skooma state.
Provides the `final` getter.
### MapStorage
A utility class that simulates the `Storage` API but is backed by a map. Can be
used as fallback in environments where persistent storages aren't available.
### SimpleState
Base state class that all other states inherit from, used primarily for class
checking, as the `State` class introduces behaviours that may be undesireable
when inheriting.
### State
The main state class that does all the magic.
### ForwardState
Proxy to a named property on another State to be used with APIs that only accept
single-value states.
### StoredState
State class that is backed by a Storage instead of an internal proxy.
## domLense.js

174
doc/render.md Normal file
View file

@ -0,0 +1,174 @@
# Rendering DOM nodes using `render.js`
```js
import {html} from "skooma/render.js"
```
A functional-friendly helper library for procedural DOM generation and templating, with support for reactive state objects.
## Summary
```js
html.button(
"Clicki Me!",
{
class: "primary",
click({target}) {
console.log("User clicked", target)
}
},
button => { console.log("Created", button) }
)
```
* elements as functions `content -> element`
* content as arguments or in arrays
* attributes in object arguments
* style, dataset, shadow root, etc. as magic attributes
* events as function attributes
* initialisers as `void -> void` functions
## Interface / Examples
### Basic DOM generation
Accessing the `html` proxy with any string key returns a new node generator function.
When called this function will generate a DOM node (HTML Tag).
The name of the function becomes the tag name of the node.
```js
html.div()
```
Content and attributes can be set via the function arguments:
Strings and DOM nodes are inserted as children, while other objects (except for some special cases) have their key-value pairs turned into attribute-value pairs on the
```js
html.div("Big Text", {style: "font-size: 1.4em"})
```
Arrays are iterated and their values treated as arguments.
This works both for inserting children and setting attributes.
```js
const content = [" ps: hi", {class: "title"}]
html.h1({id: "main-heading"}, "Heading", content)
```
Function arguments are treated differently depending on their length:]
Functions with **no** named parameters are called, and their return value is then evaluated just like an argument to the generator.
All other functions are (immediately) called with the newly created node as their first and only argument.
These can be used to initialise the new node in a point-free style.
```js
const hello = () => html.bold("Hello, World!")
const init = node => console.log("Initialising", node)
html.div(hello, init)
```
Nested tags can be generated with nested function calls.
When properly formatted, this means simpler templates will have the same structure as if written in HTML (sans those pesky closing tags).
```js
html.div(
html.p(
html.b("Bold Text")
)
)
```
### Attribute Processing
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.")
}})
```
The special `style` property can be set to an object and its key/value pairs will be inserted as CSS properties on the element's `style` object.
```js
const style = { color: "salmon" }
html.span("Salmon", { style })
```
The special property `shadowRoot` will attach a shadow-DOM to the element if none is present and append its content to the shadow root.
Arrays are iterated over and their elements appended individually.
```js
html.div({
shadowRoot = ["Hello, ", html.b("World"), "!"]
})
```
The `dataset` property will add its key/value pairs to the new node's `dataset`,
as a more convenient alternative to setting individual `data-` attributes.
```js
const dataset = { foo: "bar" }
const div = html.div({dataset})
console.log(dataset.foo === div.dataset.foo)
console.log(div.getAttribute("data-foo") === "bar")
```
### Reactivity
Skooma supports reactivity through a simple protocol:
Observable objects identify themselves with the `observable` attribute,
which must return a truthy value.
Observables are expected to expose a `value` attribute that is both readable and writeable,
and to emit a "change" event whenever its vale has changed.
Observables can be passed to skooma's node functions as both
attribute values (values in an object) or
child elements (direct arguments or in an array).
#### Reactive Children
Passing an observable as a child element will attempt to insert its current
value into the new node as if it was passed in directly, but will also hook into
the observable to replace the value when the state changes.
```js
const state = new Observable.value(0)
const button = html.button(state, {
click(event) { state.value++ }
})
```
Note that to keep the replacement logic simple, it is not currently possible to
insert use document fragments, as these could insert several top-level children
into a component that would then all have to be replaced. When an observable
contains or changes to a document fragment, skooma will raise an error.
Before replacing an element, a `"replace"` event is emitted from the old
element. This event bubbles and is cancelable, and can thus be used both to
completely prevent the replacement according to custom logic, to alter or
initialise the new element before it is inserted, or even to modify the old
object instead of replacing it.
#### Reactive Attributes
Passing an observable as an object value will, likewise, treat its value as the
attribute value, and update it whenever the state's value changes.
```js
const state = new Observable.value(0)
const input_1 = html.input({ type: "number", value: state })
const input_2 = html.input({ type: "number", value: state })
```
TODO: events as for reactive children

View file

@ -1,12 +1,40 @@
# Skooma # Skooma
A functional-friendly helper library for procedural DOM generation and
templating.
```js ```js
import {html} from "skooma.js" import {html} from "skooma/render.js"
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. `skooma/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. `skooma/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 skooma should be able to read any code using it
and piece together what it does based on structure and function names
1. Skooma should be easy to gradually introduce into an application that uses
a different framework or no framework at all
1. Skooma 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 ## Overview
```js ```js
@ -22,7 +50,7 @@ document.body.append(html.div(
## Interface / Examples ## Interface / Examples
### Basic DOM generation ### Basic DOM generatio
Accessing the `html` proxy with any string key returns a new node generator Accessing the `html` proxy with any string key returns a new node generator
function: function:
@ -89,7 +117,7 @@ text`Hello, ${html.b(user)}!`
## handle ## handle
```js ```js
import {handle} from 'skooma.js' import {handle} from 'skooma/state.js'
``` ```
Since it is common for event handlers to call `preventDefault()`, skooma Since it is common for event handlers to call `preventDefault()`, skooma

626
render.js
View file

@ -1,13 +1,19 @@
// Keep a referee alive until a referrer is collected // Keep a referee alive until a referrer is collected
const weakReferences = new WeakMap() const weakReferences = new WeakMap()
/** Keeps the referenced value alive until the referrer is collected
* @param {Object} referrer
* @param {Object} reference
*/
const untilDeathDoThemPart = (referrer, reference) => { const untilDeathDoThemPart = (referrer, reference) => {
if (!weakReferences.has(referrer)) weakReferences.set(referrer, new Set()) if (!weakReferences.has(referrer)) weakReferences.set(referrer, new Set())
weakReferences.get(referrer).add(reference) weakReferences.get(referrer).add(reference)
} }
// Like AbortController, but resets after each abort /** Like AbortController, but resets after each abort */
class MultiAbortController { class MultiAbortController {
#controller = new AbortController() #controller = new AbortController()
/** @return {AbortSignal} */
get signal() { return this.#controller.signal } get signal() { return this.#controller.signal }
abort() { this.#controller.abort(); this.#controller = new AbortController() } abort() { this.#controller.abort(); this.#controller = new AbortController() }
} }
@ -21,242 +27,50 @@ export const empty = Symbol("Explicit empty argument for Skooma")
*/ */
const snakeToCSS = key => key.replace(/^[A-Z]/, a => "-" + a).replace(/[A-Z]/g, a => '-' + a.toLowerCase()) const snakeToCSS = key => key.replace(/^[A-Z]/, a => "-" + a).replace(/[A-Z]/g, a => '-' + a.toLowerCase())
/**
* @param {CSSStyleDeclaration} style The style property of a node
* @param {object} rules A map of snake case property names to css values
*/
const insertStyles = (style, rules) => {
for (const [key, value] of Object.entries(rules))
if (typeof value == "undefined")
style.removeProperty(snakeToCSS(key))
else
style.setProperty(snakeToCSS(key), value.toString())
}
/** @typedef SpecialAttributeDescriptor /** @typedef SpecialAttributeDescriptor
* @type {object} * @type {object}
* @property {function(this:any):void} [get] * @property {function(Node):void} [get]
* @property {function(this:any,any):void} [set] * @property {function(Node,any):void} [set]
* @property {function(this:any,function():void):void} [hook] * @property {function(Node,function(any):void):void} [subscribe]
* @property {function(Node):boolean} [filter]
*/ */
/** /**
* @type {Object<string,SpecialAttributeDescriptor>} * Returns a fallback if value is fallback
*/
const specialAttributes = {
value: {
get() { return this.value },
set(value) {
this.setAttribute("value", value)
this.value = value
},
hook(callback) { this.addEventListener("input", callback) }
},
style: {
set(value) { insertStyles(this.style, value) }
},
dataset: {
set(value) {
for (const [attribute2, value2] of Object.entries(value)) {
this.dataset[attribute2] = processAttribute(value2)
}
}
},
shadowRoot: {
set(value) {
processArgs((this.shadowRoot || this.attachShadow({mode: "open"})), value)
}
}
}
const processAttribute = attribute => {
if (typeof attribute == "string" || typeof attribute == "number")
return attribute
else if (attribute && "join" in attribute)
return attribute.join(" ")
else
return JSON.stringify(attribute)
}
/** Returns a fallback if value is defined */
const defined = (value, fallback) => typeof value != "undefined" ? value : fallback
/** Recursively finds the last 'is' attribute in a list nested array of objects
* @param {Array} args
*/
const getCustom = args => args.reduce(
(current, argument) => Array.isArray(argument)
? defined(getCustom(argument), current)
: (argument && typeof argument == "object")
? defined(argument.is, current)
: current
,undefined
)
/**
* @typedef Observable
* @type {EventTarget|object}
* @property {any} value
*/
/** Returns whether an object is an observable according to skooma's contract
* @param {any} object
* @return {object is Observable}
*/
export const isObservable = object => object && object.observable
/** Turns an argument into something that can be inserted as a child into a DOM node
* @param {any} value * @param {any} value
* @return {Element|Text} * @param {any} whenUndefined
*/ */
const toElement = value => { const fallback = (value, whenUndefined) => typeof value != "undefined" ? value : whenUndefined
if (typeof value == "string" || typeof value == "number")
return document.createTextNode(value.toString())
else if (value instanceof Element)
return value
else if (isObservable(value))
return reactiveElement(value)
}
class ReplaceEvent extends Event { /** @typedef {EventTarget & {value: any}} Observable */
/** Cancelable event triggered when a reactive element gets replaced with something else */
export class BeforeReplaceEvent extends Event {
/** @param {Element|Text} next */ /** @param {Element|Text} next */
constructor(next) { constructor(next) {
super("replace", {bubbles: true, cancelable: true}) super("beforereplace", { cancelable: true })
this.next = next this.next = next
} }
} }
class ReplacedEvent extends Event { /** Event triggered when a reactive element was replaced */
export class AfterReplaceEvent extends Event {
/** @param {Element|Text} next */ /** @param {Element|Text} next */
constructor(next) { constructor(next) {
super("replaced") super("afterreplace")
this.next = next this.next = next
} }
} }
/** @type {WeakMap<Text|Element,Text|Element>} */ /** Event triggered when a new element replaces an old one */
export const newer = new WeakMap() export class ReplacedEvent extends Event {
/** @param {Element|Text} old */
/** constructor(old) {
* @param {Observable} observable super("replaced", { bubbles: true })
* @return {Element|Text} this.old = old
*/
export const reactiveElement = observable => {
const element = toElement(observable.value)
untilDeathDoThemPart(element, observable)
const ref = new WeakRef(element)
observable.addEventListener("change", () => {
const next = reactiveElement(observable)
const element = ref.deref()
if (element.dispatchEvent(new ReplaceEvent(next)))
element.replaceWith(next)
newer.set(this, next)
element.dispatchEvent(new ReplacedEvent(next))
}, {once: true})
return element
}
/** Set an attribute on an element
* @param {Element} element
* @param {string} attribute
* @param {any} value
* @param {AbortSignal} [cleanupSignal]
*/
const setAttribute = (element, attribute, value, cleanupSignal) => {
const special = specialAttributes[attribute]
if (isObservable(value))
setReactiveAttribute(element, attribute, value)
else if (typeof value === "function")
element.addEventListener(attribute, value, {signal: cleanupSignal})
else if (special?.set)
special.set.call(element, value)
else if (value === true)
{if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '')}
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, processAttribute(value))
} }
} }
/** Set up a binding between an attribute and an observable
* @param {Element} element
* @param {string} attribute
* @param {Observable} observable
*/
const setReactiveAttribute = (element, attribute, observable) => {
const multiAbort = new MultiAbortController()
observable.addEventListener("change", () => {
multiAbort.abort()
setAttribute(element, attribute, observable.value, multiAbort.signal)
})
setAttribute(element, attribute, observable.value, multiAbort.signal)
const special = specialAttributes[attribute]
if (special.hook) {
untilDeathDoThemPart(element, observable)
special.hook.call(element, () => {
const current = special.get.call(element, attribute)
if (current != observable.value) observable.value = current
})
}
}
/** Processes a list of arguments for an HTML Node
* @param {Element} element
* @param {Array} args
*/
const processArgs = (element, ...args) => {
for (const arg of args) if (arg !== empty) {
if (Array.isArray(arg)) {
processArgs(element, ...arg)
} else {
const child = toElement(arg)
if (child)
element.append(child)
else if (arg === undefined || arg == null)
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
else if (typeof arg == "function" && arg.length == 0)
processArgs(element, arg())
else if (typeof arg == "function")
arg(element)
else
for (const key in arg)
setAttribute(element, key, arg[key])
}
}
}
/** Creates a new node
* @param {String} name
* @param {Array} args
* @param {Object} options
*/
const node = (name, args, options) => {
let element
const custom = getCustom(args)
const opts = custom && {is: String(custom)}
if ("nameFilter" in options) name = options.nameFilter(name)
if (options.xmlns)
element = document.createElementNS(options.xmlns, name, opts)
else
element = document.createElement(name, opts)
processArgs(element, args)
return element
}
const nameSpacedProxy = (options={}) => new Proxy(Window, {
/** @param {string} prop */
get: (_target, prop, _receiver) => { return (...args) => node(prop, args, options) },
has: (_target, _prop) => true,
})
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 // 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
@ -265,16 +79,285 @@ export default html
*/ */
export const handle = fn => event => { event.preventDefault(); return fn(event) } export const handle = fn => event => { event.preventDefault(); return fn(event) }
/** A reference to an element that follows it around through replacements */
export class Ref {
/** @type {WeakMap<Text|Element,Text|Element>} */
static #map = new WeakMap()
/** @type {Element|Text} */
#element
/** @param {Element|Text} element */
constructor(element) {
this.#element = element
}
/** @return {Element|Text} */
deref() {
const next = Ref.newer(this.#element)
if (next) {
this.#element = next
return this.deref()
} else {
return this.#element
}
}
/** @param {Element|Text} element */
static newer(element) {
return this.#map.get(element)
}
/**
* @param {Element|Text} previous
* @param {Element|Text} next
*/
static replace(previous, next) {
if (this.newer(previous))
throw "Element has already been replaced with newer one"
this.#map.set(previous, next)
}
}
/** Main class doing all the rendering */
export class Renderer {
static proxy() {
return new Proxy(new this(), {
/** @param {string} prop */
get: (renderer, prop) => /** @param {any[]} args */ (...args) => renderer.node(prop, args),
has: (renderer, prop) => renderer.nodeSupported(prop),
})
}
/** @param {string} name */
node(name, ...args) {
throw "Attempting to use an abstract Renderer"
}
/** @param {string|symbol} name */
nodeSupported(name) {
if (typeof(name) != "string") return false
return true
}
/** Turns an attribute value into a string */
/** @param {any} value */
static serialiseAttributeValue(value) {
if (typeof value == "string" || typeof value == "number")
return value
else if (value && "join" in value)
return value.join(" ")
else if (Object.getPrototypeOf(value) == Object.prototype)
return JSON.stringify(value)
else
return value.toString()
}
}
export class DomRenderer extends Renderer {
/** @type {Object<string,SpecialAttributeDescriptor>} */
static specialAttributes = Object.freeze({})
/** Processes a list of arguments for an HTML Node
* @param {Element|ShadowRoot} element
* @param {Array} args
*/
static apply(element, ...args) {
for (const arg of args) if (arg !== empty) {
if (Array.isArray(arg)) {
this.apply(element, ...arg)
} else {
const child = this.toElement(arg)
if (child)
element.append(child)
else if (typeof arg == "function")
this.apply(element, arg(element) || empty)
else if (arg instanceof DocumentFragment)
element.append(arg)
else if (arg && typeof(arg)=="object")
for (const key in arg)
if (element instanceof Element)
this.setAttribute(element, key, arg[key])
else
throw `Attempting to set attributes on a non-element (${element.constructor.name})`
else
console.warn(`An argument of type ${typeof arg} has been ignored`, element)
}
}
}
/** Creates a new node
* @param {String} name
* @param {Array} args
*/
node(name, args) {
const element = this.createElement(name)
this.constructor.apply(element, args)
return element
}
/**
* @protected
* @param {String} name
* @param {Object} options
* @return {Node}
*/
createElement(name, options={}) {
return document.createElement(name, options)
}
/** Turns an argument into something that can be inserted as a child into a DOM node
* @protected
* @param {any} value
* @return {Element|Text}
*/
static toElement(value) {
if (typeof value == "string" || typeof value == "number")
return document.createTextNode(value.toString())
else if (value instanceof Element)
return value
else if (this.isObservable(value))
return this.toReactiveElement(value)
}
/**
* @protected
* @param {Observable} observable
* @return {Element|Text}
*/
static toReactiveElement(observable) {
if (observable.value instanceof DocumentFragment) {
throw "Failed to create reactive element: Document fragments cannot be replaced dynamically"
}
const element = this.toElement(observable.value)
untilDeathDoThemPart(element, observable)
let ref = new WeakRef(element)
const handleChange = () => {
const element = ref.deref()
if (!element) return
const next = this.toElement(observable.value)
if (element?.dispatchEvent(new BeforeReplaceEvent(next))) {
element.replaceWith(next)
Ref.replace(element, next)
next.dispatchEvent(new ReplacedEvent(element))
element.dispatchEvent(new AfterReplaceEvent(next))
ref = new WeakRef(next)
}
observable.addEventListener("change", handleChange, {once: true})
}
observable.addEventListener("change", handleChange, {once: true})
return element
}
/** Set an attribute on an element
* @protected
* @param {Element} element
* @param {string} attribute
* @param {any} value
* @param {AbortSignal} [cleanupSignal]
*/
static setAttribute(element, attribute, value, cleanupSignal) {
const special = this.getSpecialAttribute(element, attribute)
if (this.isObservable(value))
this.setReactiveAttribute(element, attribute, value)
else if (typeof value === "function")
element.addEventListener(attribute, value, { signal: cleanupSignal })
else if (special?.set)
special.set(element, value)
else if (value === true)
{ if (!element.hasAttribute(attribute)) element.setAttribute(attribute, '') }
else if (value === false)
element.removeAttribute(attribute)
else {
element.setAttribute(attribute, this.serialiseAttributeValue(value))
}
}
/** Set up a binding between an attribute and an observable
* @protected
* @param {Element} element
* @param {string} attribute
* @param {Observable} observable
*/
static setReactiveAttribute(element, attribute, observable) {
const multiAbort = new MultiAbortController()
observable.addEventListener("change", () => {
multiAbort.abort()
this.setAttribute(element, attribute, observable.value, multiAbort.signal)
})
this.setAttribute(element, attribute, observable.value, multiAbort.signal)
const special = this.getSpecialAttribute(element, attribute)
if (special?.subscribe) {
untilDeathDoThemPart(element, observable)
special.subscribe(element, value => {
if (value != observable.value) observable.value = value
})
}
}
/**
* @param {CSSStyleDeclaration} style The style property of a node
* @param {object} rules A map of snake case property names to css values
*/
static insertStyles(style, rules) {
for (const [key, value] of Object.entries(rules))
if (typeof value == "undefined")
style.removeProperty(snakeToCSS(key))
else
style.setProperty(snakeToCSS(key), value.toString())
}
/** Returns whether an object is an observable according to skooma's contract
* @param {any} object
* @return {object is Observable}
*/
static isObservable(object) {
return object && object.observable
}
/** Wraps a list of elements in a document fragment /** Wraps a list of elements in a document fragment
* @param {Array<Element|String>} elements * @param {Array<Element|String>} elements
*/ */
export const fragment = (...elements) => { static documentFragment(...elements) {
const fragment = new DocumentFragment() const fragment = new DocumentFragment()
for (const element of elements) for (const element of elements)
fragment.append(toElement(element)) fragment.append(this.toElement(element))
return fragment return fragment
} }
/**
* @protected
* @param {Element} element
* @param {String} attribute
*/
static getSpecialAttribute(element, attribute) {
const special = this.specialAttributes[attribute]
if (special?.filter == undefined)
return special
if (special.filter(element))
return special
return undefined
}
/**
* @param {String|Array<String>} data
* @param {Array<String|Element>} items
*/
static createTextOrFragment(data = "", ...items) {
return Array.isArray(data)
? this.textFromTemplate(data, items)
: document.createTextNode(data)
}
/** Turns a template literal into document fragment. /** Turns a template literal into document fragment.
* Strings are returned as text nodes. * Strings are returned as text nodes.
* Elements are inserted in between. * Elements are inserted in between.
@ -282,21 +365,116 @@ export const fragment = (...elements) => {
* @param {Array<any>} items * @param {Array<any>} items
* @return {DocumentFragment} * @return {DocumentFragment}
*/ */
const textFromTemplate = (literals, items) => { static textFromTemplate(literals, items) {
const fragment = new DocumentFragment() const fragment = new DocumentFragment()
for (const key in items) { for (const key in items) {
fragment.append(document.createTextNode(literals[key])) fragment.append(document.createTextNode(literals[key]))
fragment.append(toElement(items[key])) fragment.append(this.toElement(items[key]))
} }
fragment.append(document.createTextNode(literals[literals.length - 1])) fragment.append(document.createTextNode(literals[literals.length - 1]))
return fragment return fragment
} }
}
/** Renderer for normal HTML nodes targetting a browser's DOM */
export class DomHtmlRenderer extends DomRenderer {
/** /**
* @param {String|Array<String>} data * @param {String} name
* @param {Array<String|Element>} items * @param {Object} options
* @return {Node}
*/ */
export const text = (data="", ...items) => createElement(name, options) {
Array.isArray(data) return document.createElement(name.replace(/([a-z])([A-Z])/g, "$1-$2"), options)
? textFromTemplate(data, items) }
: document.createTextNode(data)
/** Creates a new node and make it a custom element if necessary
* @param {String} name
* @param {Array} args
*/
node(name, args) {
const custom = this.getCustom(args)
const opts = custom && { is: String(custom) }
const element = this.createElement(name, opts)
this.constructor.apply(element, args)
return element
}
/** Recursively finds the last 'is' attribute in a list nested array of objects
* @param {Array} args
*/
getCustom(args) {
return args.reduce(
(current, argument) => Array.isArray(argument)
? fallback(this.getCustom(argument), current)
: (argument && typeof argument == "object")
? fallback(argument.is, current)
: current
, undefined
)
}
/** @type {Object<string,SpecialAttributeDescriptor>} */
static specialAttributes = {
value: {
/** @param {HTMLInputElement} element */
get(element) { return element.value },
/** @param {HTMLInputElement} element */
set(element, value) {
element.setAttribute("value", value)
element.value = value
},
/** @param {HTMLInputElement} element */
subscribe(element, callback) {
element.addEventListener("input", () => {
callback(this.get(element))
})
},
/** @param {HTMLElement} element */
filter(element) {
return element.nodeName.toLowerCase() == "input"
}
},
style: {
/** @param {HTMLElement} element */
set(element, value) { DomRenderer.insertStyles(element.style, value) }
},
dataset: {
/** @param {HTMLElement} element */
set(element, value) {
for (const [attribute2, value2] of Object.entries(value)) {
element.dataset[attribute2] = DomRenderer.serialiseAttributeValue(value2)
}
}
},
shadowRoot: {
/** @param {HTMLElement} element */
set(element, value) {
DomRenderer.apply(
(element.shadowRoot || element.attachShadow({ mode: "open" })),
value
)
}
}
}
}
/** Renderer for normal SVG nodes targetting a browser's DOM */
export class DomSvgRenderer extends DomRenderer {
/**
* @param {String} name
* @param {Object} options
* @return {Node}
*/
createElement(name, options) {
return document.createElementNS("http://www.w3.org/2000/svg", name, options)
}
}
export const html = DomHtmlRenderer.proxy()
export const svg = DomSvgRenderer.proxy()
export const fragment = DomRenderer.documentFragment.bind(DomRenderer)
export const text = DomRenderer.createTextOrFragment.bind(DomRenderer)
export default html