Compare commits

...

4 commits

2 changed files with 256 additions and 8 deletions

247
documentation/render.md Normal file
View file

@ -0,0 +1,247 @@
# Rendering DOM nodes using `render.js`
```js
import {html} from "nyooom/render.js"
```
A functional-friendly helper library for procedural DOM generation and templating, with support for reactive state objects.
## Summary
```js
html.button(
"Click Me!",
{
class: "primary",
click({target}) {
console.log("User clicked", target)
}
},
button => { console.log("Created", button) }
)
```
* elements as factory 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 `any -> 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
Nyooom 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 nyooom'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, nyooom 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 })
```
## Helpers
### Empty Values
Nyooom will produce a warning when it encounters `undefined` as an argument to a
node factory, and skip it.
When completely ommitting the argument would make code more complicated, the
`nothing` export can be used instead, and will be completely ignored by nyooom:
```js
import {html, nothing} from "nyooom/render"
const value = undefined
html.div(value ? html.p(value) : nothing) // <div></div>
```
### Event Handlers
Event handlers often need to call `preventDefault()` and `stopPropagation()` on
the event. To save a bit of typing, the wrapper functions `noDefault` and
`noPropagation` take a function `event => void` and wrap it in a function that
first calls the corresponding method on the event:
```js
html.form(
html.button("Click me!", click: noDefault(() => console.log("Clicked!")))
)
```
### DOM-Fragments
The `fragment` helper function collects a list of DOM nodes from its arguments
into a new document fragment, so they can more easily be processed as a group
and inserted elsewhere.
```js
import {fragment} from "nyooom/render"
const f = fragment(html.p("paragraph 1"), html.p("paragraph 1"))
document.body.append(f.cloneNode(true))
```
### Text
The `text` helper function operates in two modes: When a string is provided, it
outputs a `TextNode`. This acts the same as `document.createTextNode`, except it
won't error for `undefined` and instead simply return an empty text node.
When used as a template literal, it will return a document template containing a
list of text nodes. HTML-Elements can be interpolated into these fragments and
will be inserted into the template.
```js
import {text} from "nyooom/render"
const t = text`Hello, ${html.b("World")}!`
```
## Extending
The Nyooom renderer is built in a modular way that allows for easy extending:
The most generic functionality like the `proxy()` method is implemented in the
`Renderer` class.
This is extended by the `DomRenderer` class which defines how
elements are created inside the browser or a compatible DOM implementation.
The DomHtmlRenderer defines a `camelCase` to `snake-case` conversion for node
names and a list of magic node names to be handled differently.
The `DomSvgRenderer` creates DOM nodes in the SVG namespace.

View file

@ -19,7 +19,7 @@ class MultiAbortController {
}
/** A symbol representing nothing to be appended to an element */
export const empty = Symbol("Explicit empty argument for Nyooom")
export const nothing = Symbol("Explicit non-argument for Nyooom")
/** Converts a snake-case string to a CSS property name
* @param {string} key
@ -41,7 +41,7 @@ const snakeToCSS = key => key.replace(/^[A-Z]/, a => "-" + a).replace(/[A-Z]/g,
export class BeforeReplaceEvent extends Event {
/** @param {Element|Text} next */
constructor(next) {
super("skooma:beforereplace", { cancelable: true })
super("nyooom:beforereplace", { cancelable: true })
this.next = next
}
}
@ -50,7 +50,7 @@ export class BeforeReplaceEvent extends Event {
export class AfterReplaceEvent extends Event {
/** @param {Element|Text} next */
constructor(next) {
super("skooma:afterreplace")
super("nyooom:afterreplace")
this.next = next
}
}
@ -59,7 +59,7 @@ export class AfterReplaceEvent extends Event {
export class ReplacedEvent extends Event {
/** @param {Element|Text} old */
constructor(old) {
super("skooma:replaced", { bubbles: true })
super("nyooom:replaced", { bubbles: true })
this.old = old
}
}
@ -72,7 +72,7 @@ export class AttributeEvent extends Event {
* @param {any} to
*/
constructor(attribute, from, to) {
super("skooma:attribute", { cancelable: true })
super("nyooom:attribute", { cancelable: true })
this.attribute = attribute
this.from = from
this.to = to
@ -137,7 +137,7 @@ export class DomRenderer extends Renderer {
* @param {Array} args
*/
static apply(element, ...args) {
for (const arg of args) if (arg !== empty) {
for (const arg of args) if (arg !== nothing) {
if (Array.isArray(arg)) {
this.apply(element, ...arg)
} else {
@ -145,7 +145,7 @@ export class DomRenderer extends Renderer {
if (child)
element.append(child)
else if (typeof arg == "function")
this.apply(element, arg(element) || empty)
this.apply(element, arg(element) || nothing)
else if (arg instanceof DocumentFragment)
element.append(arg)
else if (arg && typeof(arg)=="object")
@ -289,7 +289,7 @@ export class DomRenderer extends Renderer {
style.setProperty(snakeToCSS(key), value.toString())
}
/** Returns whether an object is an observable according to skooma's contract
/** Returns whether an object is an observable according to nyooom's contract
* @param {any} object
* @return {object is Observable}
*/
@ -345,6 +345,7 @@ export class DomRenderer extends Renderer {
fragment.append(this.toElement(items[key]))
}
fragment.append(document.createTextNode(literals[literals.length - 1]))
fragment.normalize()
return fragment
}
}