hyperhtml
Version:
A Fast & Light Virtual DOM Alternative
378 lines (356 loc) • 11.8 kB
JavaScript
import CustomEvent from '@ungap/custom-event';
import WeakSet from '@ungap/essential-weakset';
import isArray from '@ungap/is-array';
import createContent from '@ungap/create-content';
import disconnected from 'disconnected';
import domdiff from 'domdiff';
import domtagger from 'domtagger';
import hyperStyle from 'hyperhtml-style';
import Wire from 'hyperhtml-wire';
import {
CONNECTED, DISCONNECTED,
DOCUMENT_FRAGMENT_NODE,
OWNER_SVG_ELEMENT
} from '../shared/constants.js';
import Component from '../classes/Component.js';
import Intent from './Intent.js';
const componentType = Component.prototype.nodeType;
const wireType = Wire.prototype.nodeType;
const observe = disconnected({Event: CustomEvent, WeakSet});
export {Tagger, observe};
// returns an intent to explicitly inject content as html
const asHTML = html => ({html});
// returns nodes from wires and components
const asNode = (item, i) => {
switch (item.nodeType) {
case wireType:
// in the Wire case, the content can be
// removed, post-pended, inserted, or pre-pended and
// all these cases are handled by domdiff already
/* istanbul ignore next */
return (1 / i) < 0 ?
(i ? item.remove(true) : item.lastChild) :
(i ? item.valueOf(true) : item.firstChild);
case componentType:
return asNode(item.render(), i);
default:
return item;
}
}
// returns true if domdiff can handle the value
const canDiff = value => 'ELEMENT_NODE' in value;
// borrowed from uhandlers
// https://github.com/WebReflection/uhandlers
const booleanSetter = (node, key, oldValue) => newValue => {
if (oldValue !== !!newValue) {
if ((oldValue = !!newValue))
node.setAttribute(key, '');
else
node.removeAttribute(key);
}
};
const hyperSetter = (node, name, svg) => svg ?
value => {
try {
node[name] = value;
}
catch (nope) {
node.setAttribute(name, value);
}
} :
value => {
node[name] = value;
};
// when a Promise is used as interpolation value
// its result must be parsed once resolved.
// This callback is in charge of understanding what to do
// with a returned value once the promise is resolved.
const invokeAtDistance = (value, callback) => {
callback(value.placeholder);
if ('text' in value) {
Promise.resolve(value.text).then(String).then(callback);
} else if ('any' in value) {
Promise.resolve(value.any).then(callback);
} else if ('html' in value) {
Promise.resolve(value.html).then(asHTML).then(callback);
} else {
Promise.resolve(Intent.invoke(value, callback)).then(callback);
}
};
// quick and dirty way to check for Promise/ish values
const isPromise_ish = value => value != null && 'then' in value;
// list of attributes that should not be directly assigned
const readOnly = /^(?:form|list)$/i;
// reused every slice time
const slice = [].slice;
// simplifies text node creation
const text = (node, text) => node.ownerDocument.createTextNode(text);
function Tagger(type) {
this.type = type;
return domtagger(this);
}
Tagger.prototype = {
// there are four kind of attributes, and related behavior:
// * events, with a name starting with `on`, to add/remove event listeners
// * special, with a name present in their inherited prototype, accessed directly
// * regular, accessed through get/setAttribute standard DOM methods
// * style, the only regular attribute that also accepts an object as value
// so that you can style=${{width: 120}}. In this case, the behavior has been
// fully inspired by Preact library and its simplicity.
attribute(node, name, original) {
const isSVG = OWNER_SVG_ELEMENT in node;
let oldValue;
// if the attribute is the style one
// handle it differently from others
if (name === 'style')
return hyperStyle(node, original, isSVG);
// direct accessors for <input .value=${...}> and friends
else if (name.slice(0, 1) === '.')
return hyperSetter(node, name.slice(1), isSVG);
// boolean accessors for <input .value=${...}> and friends
else if (name.slice(0, 1) === '?')
return booleanSetter(node, name.slice(1));
// the name is an event one,
// add/remove event listeners accordingly
else if (/^on/.test(name)) {
let type = name.slice(2);
if (type === CONNECTED || type === DISCONNECTED) {
observe(node);
}
else if (name.toLowerCase()
in node) {
type = type.toLowerCase();
}
return newValue => {
if (oldValue !== newValue) {
if (oldValue)
node.removeEventListener(type, oldValue, false);
oldValue = newValue;
if (newValue)
node.addEventListener(type, newValue, false);
}
};
}
// the attribute is special ('value' in input)
// and it's not SVG *or* the name is exactly data,
// in this case assign the value directly
else if (
name === 'data' ||
(!isSVG && name in node && !readOnly.test(name))
) {
return newValue => {
if (oldValue !== newValue) {
oldValue = newValue;
if (node[name] !== newValue && newValue == null) {
// cleanup on null to avoid silly IE/Edge bug
node[name] = '';
node.removeAttribute(name);
}
else
node[name] = newValue;
}
};
}
else if (name in Intent.attributes) {
oldValue;
return any => {
const newValue = Intent.attributes[name](node, any);
if (oldValue !== newValue) {
oldValue = newValue;
if (newValue == null)
node.removeAttribute(name);
else
node.setAttribute(name, newValue);
}
};
}
// in every other case, use the attribute node as it is
// update only the value, set it as node only when/if needed
else {
let owner = false;
const attribute = original.cloneNode(true);
return newValue => {
if (oldValue !== newValue) {
oldValue = newValue;
if (attribute.value !== newValue) {
if (newValue == null) {
if (owner) {
owner = false;
node.removeAttributeNode(attribute);
}
attribute.value = newValue;
} else {
attribute.value = newValue;
if (!owner) {
owner = true;
node.setAttributeNode(attribute);
}
}
}
}
};
}
},
// in a hyper(node)`<div>${content}</div>` case
// everything could happen:
// * it's a JS primitive, stored as text
// * it's null or undefined, the node should be cleaned
// * it's a component, update the content by rendering it
// * it's a promise, update the content once resolved
// * it's an explicit intent, perform the desired operation
// * it's an Array, resolve all values if Promises and/or
// update the node with the resulting list of content
any(node, childNodes) {
const diffOptions = {node: asNode, before: node};
const nodeType = OWNER_SVG_ELEMENT in node ? /* istanbul ignore next */ 'svg' : 'html';
let fastPath = false;
let oldValue;
const anyContent = value => {
switch (typeof value) {
case 'string':
case 'number':
case 'boolean':
if (fastPath) {
if (oldValue !== value) {
oldValue = value;
childNodes[0].textContent = value;
}
} else {
fastPath = true;
oldValue = value;
childNodes = domdiff(
node.parentNode,
childNodes,
[text(node, value)],
diffOptions
);
}
break;
case 'function':
anyContent(value(node));
break;
case 'object':
case 'undefined':
if (value == null) {
fastPath = false;
childNodes = domdiff(
node.parentNode,
childNodes,
[],
diffOptions
);
break;
}
default:
fastPath = false;
oldValue = value;
if (isArray(value)) {
if (value.length === 0) {
if (childNodes.length) {
childNodes = domdiff(
node.parentNode,
childNodes,
[],
diffOptions
);
}
} else {
switch (typeof value[0]) {
case 'string':
case 'number':
case 'boolean':
anyContent({html: value});
break;
case 'object':
if (isArray(value[0])) {
value = value.concat.apply([], value);
}
if (isPromise_ish(value[0])) {
Promise.all(value).then(anyContent);
break;
}
default:
childNodes = domdiff(
node.parentNode,
childNodes,
value,
diffOptions
);
break;
}
}
} else if (canDiff(value)) {
childNodes = domdiff(
node.parentNode,
childNodes,
value.nodeType === DOCUMENT_FRAGMENT_NODE ?
slice.call(value.childNodes) :
[value],
diffOptions
);
} else if (isPromise_ish(value)) {
value.then(anyContent);
} else if ('placeholder' in value) {
invokeAtDistance(value, anyContent);
} else if ('text' in value) {
anyContent(String(value.text));
} else if ('any' in value) {
anyContent(value.any);
} else if ('html' in value) {
childNodes = domdiff(
node.parentNode,
childNodes,
slice.call(
createContent(
[].concat(value.html).join(''),
nodeType
).childNodes
),
diffOptions
);
} else if ('length' in value) {
anyContent(slice.call(value));
} else {
anyContent(Intent.invoke(value, anyContent));
}
break;
}
};
return anyContent;
},
// style or textareas don't accept HTML as content
// it's pointless to transform or analyze anything
// different from text there but it's worth checking
// for possible defined intents.
text(node) {
let oldValue;
const textContent = value => {
if (oldValue !== value) {
oldValue = value;
const type = typeof value;
if (type === 'object' && value) {
if (isPromise_ish(value)) {
value.then(textContent);
} else if ('placeholder' in value) {
invokeAtDistance(value, textContent);
} else if ('text' in value) {
textContent(String(value.text));
} else if ('any' in value) {
textContent(value.any);
} else if ('html' in value) {
textContent([].concat(value.html).join(''));
} else if ('length' in value) {
textContent(slice.call(value).join(''));
} else {
textContent(Intent.invoke(value, textContent));
}
} else if (type === 'function') {
textContent(value(node));
} else {
node.textContent = value == null ? '' : value;
}
}
};
return textContent;
}
};