Published unlisted
Edited
Aug 9, 2019
Importers
2 stars
Insert cell
Insert cell
Insert cell
Insert cell
function getBuilder(plugins = [], options = {}) {
const hooks = {before: new Map, after: new Map};
for(let [hook, map] of Object.entries(hooks)) {
for(let [name, plugin] of Object.entries(plugins)) {
if(plugin[hook]) map.set(name, plugin[hook]);
}
}

const build = (items, parent) => {
for(let item of items) {
if(item instanceof Node) item = {node: item};
hooks.before.forEach((fn, name) => {
item = fn(item, options);
if(!item) throw BuilderError(`Plugin "${name}" returned no item`, 'before');
})
if(!(item.node instanceof Element)) {
throw BuilderError('Item has no valid node', 'before', item);
}
if(item.children) build(item.children, item.node);
let collection = document.createDocumentFragment();
collection.appendChild(item.node);
hooks.after.forEach((fn, name) => {
collection = fn(collection, item, options);
if(!(collection instanceof Node)) {
throw BuilderError(`Plugin "${name}" returned no valid collection`, 'after', item);
}
})
parent.appendChild(collection);
}
}
return build;
}
Insert cell
md`
---
## Plugins

Item trees are transformed and decorated through plugins. Only two properties are handled directly by the builder:
- <code>item.node</code>
The initial Node or Element that will be decorated and appended to the parent element. If no plugin sets this property it will default to an empty <code>DocumentFragment</code>.
- <code>item.children</code>
An array of objects that describe the item's child elements (or nodes). Children are processed and appended between execution of the <code>before</code> and <code>after</code> hook.

A plugin can implement the following hooks (callbacks):
- <code>before(item, options) : item</code>
Runs before an item's children are processed. The callback must return an item object. A plugin may assign a DOM Node to <code>item.node</code>.
- <code>after(collection, item, options) : collection</code>
Runs after the item's children have been processed


Plugins may use or ignore any properties. Plugins that provide similarly named properties do so only by convention.

The following plugins are provided by default:

#### type

Specifiy common or custom element types, through a unified interface. An object with custom type factories can be passed to <code>getBuilder()</code> via <code>options.types</code>.

**Example:**
<code>{ type: 'range', ... }
</code>

#### id

Automatically sets a unique ID for elements that have a name attribute. Does not replace existing IDs.

#### data

Applies data attributes.
- **label**:
`
Insert cell
defaultPlugins = ({
type: {
before(item, options) {
if(item.type == null) return item;
const {node, type} = item;
if(item.node) throw Error(`Cannot create type "${type}", node is already set\nItem: ${dumpJSON(item)}`);
if(!options.types[type]) throw Error(`Type "${type}" is not defined\nItem: ${dumpJSON(item)}`);
return options.types[item.type](item);
}
},
id: {
after(collection, {node}) {
if(node instanceof Element && !node.hasAttribute('id') && node.hasAttribute('name')) {
node.setAttribute('id', DOM.uid(node.getAttribute('name')).id);
}
return collection;
}
},
// Sets data attributes on the element.
data: {
after(collection, item) {
if(item.data != null) {
requireElement(item, 'data');
Object.assign(item.node.dataset, item.data);
}
return collection;
}
},
// Adds and links a label element.
label: {
after(collection, item) {
if(item.label != null) {
const {label, node} = item;
const l = html`<label>${label instanceof Node ? label : DOM.text(label)}`;
if(node instanceof Element && node.hasAttribute('id')) {
l.setAttribute('for', node.getAttribute('id'));
}
node.parentNode.insertBefore(l, node);
}
return collection;
}
},
// Addds and links an output element.
output: {
after(collection, item) {
if(item.output == null) return collection;
requireElement(item, 'output');
const {output: fn, node} = item, o = DOM.element('output');
if(node.hasAttribute('id')) {
o.setAttribute('for', node.getAttribute('id'));
}
o.value = fn(node.value);
node.addEventListener('input', e => {
if(e.target === node) o.value = fn(node.value);
});
return fragment(html`${collection} ${o}`);
}
},
submit: {
after(collection, item) {
if(item.submit != null) {
requireElement(item, 'submit');
// TODO
}
return collection;
}
},
// TODO: Alternative solution?
rearrange: {
after(collection, item) {
if(item.rearrange != null) {
const {node, rearrange} = item;
const nodes = new Set;
rearrange.forEach(selector => {
if(selector instanceof Node) return nodes.set(selector);
for(let n of node.children) {
if(n.matches(selector) && !nodes.has(n)) return nodes.set(n);
}
// TODO
});
}
return collection;
}
},
wrap: {
before(item) {
if(item.wrapChildren != null && item.children) {
for(let c of item.children) c.wrap = item.wrapChildren;
}
return item;
},
after(collection, item) {
return item.wrap == null ? collection : html`<${item.wrap}>${collection}`;
}
},
theme: {
after(collection, item, {themes = {}}) {
if(item.theme != null) {
requireElement(item, 'theme');
const {theme, node} = item;
if(!themes[item.theme]) throw TypeError(`Theme "${item.theme}" is not defined`);
const id = node.dataset.themeScope = DOM.uid(`theme-${theme}`).id;
const scope = `[data-theme-scope="${id}"]`;
const css = themes[theme].replace(/:scope\b/g, scope);
collection.appendChild(html`<style>${css}`);
}
return collection;
}
}
})
Insert cell
Insert cell
defaultTypes = ({

details: ({summary, open, ...item}) => ({
...item,
node: html`<details ${open ? 'open' : ''}>${summary == null ? '' : html`<summary>${DOM.text(summary)}`}`,
}),
range: ({type, name, disabled, min, max, step, value, children, ...item}) => ({
...item,
node: DOM.element('input', filterObject({type, name, disabled, min, max, step, value})),
}),
color: ({type, name, disabled, value, children, ...item}) => ({
...item,
node: DOM.element('input', filterObject({type, name, disabled, value})),
}),
checkbox: ({type, name, disabled, value, checked, children, ...item}) => ({
...item,
node: DOM.element('input', filterObject({type, name, disabled, value, checked})),
}),
radio: ({type, name, disabled, value, checked, children, ...item}) => ({
...item,
node: DOM.element('input', filterObject({type, name, disabled, value, checked})),
}),
select: ({name, disabled, value, options = [], children, ...item}) => {
const s = DOM.element('select', filterObject({name, disabled}));
const opts = Array.isArray(options) ? options.map(v => [v,v]) : Object.entries(options);
for(let [key, label] of opts) {
const o = DOM.element('option', {value: key, selected: key == value || null});
if(label instanceof Node) o.appendChild(label);
else o.textContent = label;
s.appendChild(o);
}
return {...item, node: s};
},
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more