simulacra
Version:
Data-binding function for the DOM.
255 lines (200 loc) • 8 kB
JavaScript
var processNodes = require('./process_nodes')
var bindKeys = require('./bind_keys')
var keyMap = require('./key_map')
var helpers = require('./helpers')
var rehydrate = require('./rehydrate')
var featureCheck = require('./feature_check')
var helper
var isArray = Array.isArray
var hasDefinitionKey = keyMap.hasDefinition
var replaceAttributeKey = keyMap.replaceAttribute
var isBoundToParentKey = keyMap.isBoundToParent
var isProcessedKey = keyMap.isProcessed
var markerKey = keyMap.marker
// Element tag names which should have value replaced.
var replaceValue = [ 'INPUT', 'PROGRESS' ]
// Input types which use the "checked" attribute.
var replaceChecked = [ 'checkbox', 'radio' ]
// A list of features to check for upon instantiation.
var features = [
// ECMAScript features.
[ Object, 'defineProperty' ],
// DOM features. Missing `contains` since apparently it is not on
// the Node.prototype in Internet Explorer.
[ 'document', 'createTreeWalker' ],
[ 'Node', 'prototype', 'cloneNode' ],
[ 'Node', 'prototype', 'normalize' ],
[ 'Node', 'prototype', 'insertBefore' ],
[ 'Node', 'prototype', 'isEqualNode' ],
[ 'Node', 'prototype', 'removeChild' ]
]
// Symbol for retaining an element instead of removing it.
Object.defineProperty(simulacra, 'retainElement', {
enumerable: true, value: keyMap.retainElement
})
// Option to use comment nodes as markers.
Object.defineProperty(simulacra, 'useCommentNode', {
get: function () { return processNodes.useCommentNode },
set: function (value) { processNodes.useCommentNode = value },
enumerable: true
})
// Assign helpers on the main export.
for (helper in helpers) simulacra[helper] = helpers[helper]
module.exports = simulacra
/**
* Bind an object to the DOM.
*
* @param {Object} obj
* @param {Object} def
* @param {Node} [matchNode]
* @return {Node}
*/
function simulacra (obj, def, matchNode) {
var document = this ? this.document : window.document
var Node = this ? this.Node : window.Node
var node, query
// Before continuing, check if required features are present.
featureCheck(this || window, features)
if (obj === null || typeof obj !== 'object' || isArray(obj))
throw new TypeError('First argument must be a singular object.')
if (!isArray(def))
throw new TypeError('Second argument must be an array.')
if (typeof def[0] === 'string') {
query = def[0]
def[0] = document.querySelector(query)
if (!def[0]) throw new Error(
'Top-level Node "' + query + '" could not be found in the document.')
}
else if (!(def[0] instanceof Node)) throw new TypeError(
'The first position of the top-level must be either a Node or a CSS ' +
'selector string.')
if (!def[isProcessedKey]) {
// Auto-detect template tag.
if ('content' in def[0]) def[0] = def[0].content
def[0] = def[0].cloneNode(true)
cleanNode(this, def[0])
ensureNodes(def[0], def[1])
setProperties(def)
}
node = processNodes(this, def[0], def[1])
bindKeys(this, obj, def[1], node, { root: obj })
if (matchNode) {
rehydrate(this, obj, def[1], node, matchNode)
return matchNode
}
return node
}
/**
* Internal function to mutate string selectors into Nodes and validate that
* they are allowed.
*
* @param {Element} parentNode
* @param {Object} def
*/
function ensureNodes (parentNode, def) {
var adjacentNodes = []
var i, j, key, query, branch, boundNode, matchedNodes
var adjacentNode, adjacentKey
if (typeof def !== 'object') throw new TypeError(
'The second position must be an object.')
for (key in def) {
branch = def[key]
// Change function or definition object bound to parent.
if (typeof branch === 'function' || (typeof branch === 'object' &&
branch !== null && !Array.isArray(branch)))
def[key] = branch = [ parentNode, branch ]
// Cast CSS selector string to array.
else if (typeof branch === 'string') def[key] = branch = [ branch ]
else if (!Array.isArray(branch))
throw new TypeError('The binding on key "' + key + '" is invalid.')
// Dereference CSS selector string to actual DOM element.
if (typeof branch[0] === 'string') {
query = branch[0]
// Match all nodes for the selector, pick the first and remove the rest.
matchedNodes = parentNode.querySelectorAll(query)
if (!matchedNodes.length) throw new Error(
'An element for selector "' + query + '" was not found.')
for (i = 1, j = matchedNodes.length; i < j; i++)
matchedNodes[i].parentNode.removeChild(matchedNodes[i])
branch[0] = matchedNodes[0]
}
else if (!branch[0])
throw new TypeError(
'The first position on key "' + key +
'" must be a CSS selector string.')
// Auto-detect template tag.
if ('content' in branch[0]) branch[0] = branch[0].content
boundNode = branch[0]
if (typeof branch[1] === 'object' && branch[1] !== null) {
Object.defineProperty(branch, hasDefinitionKey, { value: true })
if (branch[2] && typeof branch[2] !== 'function')
throw new TypeError('The third position on key "' + key +
'" must be a function.')
}
else if (branch[1] && typeof branch[1] !== 'function')
throw new TypeError('The second position on key "' + key +
'" must be an object or a function.')
// Special case for binding to parent node.
if (parentNode === boundNode) {
Object.defineProperty(branch, isBoundToParentKey, { value: true })
if (branch[hasDefinitionKey]) ensureNodes(boundNode, branch[1])
else if (typeof branch[1] === 'function')
setReplaceAttribute(branch, boundNode)
else console.warn( // eslint-disable-line
'A change function was not defined on the key "' + key + '".')
setProperties(branch)
continue
}
adjacentNodes.push([ key, boundNode ])
if (!parentNode.contains(boundNode))
throw new Error('The bound DOM element must be either ' +
'contained in or equal to the element in its parent binding.')
if (branch[hasDefinitionKey]) {
ensureNodes(boundNode, branch[1])
setProperties(branch)
continue
}
setReplaceAttribute(branch, boundNode)
setProperties(branch)
}
// Need to loop again to invalidate containment in adjacent nodes, after the
// adjacent nodes are found.
for (key in def) {
boundNode = def[key][0]
for (i = 0, j = adjacentNodes.length; i < j; i++) {
adjacentKey = adjacentNodes[i][0]
adjacentNode = adjacentNodes[i][1]
if (adjacentNode.contains(boundNode) && adjacentKey !== key)
throw new Error(
'The element for key "' + key + '" is contained in the ' +
'element for the adjacent key "' + adjacentKey + '".')
}
}
setProperties(def)
}
// Internal function to strip empty text nodes.
function cleanNode (scope, node) {
// A constant for showing text nodes.
var showText = 0x00000004
var document = scope ? scope.document : window.document
var treeWalker = document.createTreeWalker(
node, showText, processNodes.acceptNode, false)
var textNode
while (treeWalker.nextNode()) {
textNode = treeWalker.currentNode
textNode.textContent = textNode.textContent.trim()
}
node.normalize()
}
function setReplaceAttribute (branch, boundNode) {
Object.defineProperty(branch, replaceAttributeKey, {
value: ~replaceValue.indexOf(boundNode.nodeName) ?
~replaceChecked.indexOf(boundNode.type) ?
'checked' : 'value' : 'textContent'
})
}
function setProperties (obj) {
Object.defineProperty(obj, isProcessedKey, { value: true })
Object.defineProperty(obj, markerKey, { value: null, writable: true })
}