UNPKG

@danielkalen/simplybind

Version:

Magically simple, framework-less one-way/two-way data binding for frontend/backend in ~5kb.

718 lines (605 loc) 19.2 kB
var h = require('./vhtml'); var domComponent = require('./domComponent'); var simplePromise = require('./simplePromise'); var bindingMeta = require('./meta'); var coerceChildren = require('./coerceChildren'); var parseTag = require('virtual-dom/virtual-hyperscript/parse-tag.js'); function doThenFireAfterRender(attachment, fn) { try { exports.html.currentRender = {attachment: attachment}; exports.html.currentRender.finished = simplePromise(); exports.html.refresh = function (component) { if (isComponent(component)) { refreshComponent(component, attachment); } else { attachment.refresh(); } } fn(); } finally { exports.html.currentRender.finished.fulfill(); exports.html.currentRender.finished = undefined; delete exports.html.currentRender; exports.html.refresh = refreshOutOfRender; } } function refreshOutOfRender() { throw new Error('Please assign plastiq.html.refresh during a render cycle if you want to use it in event handlers. See https://github.com/featurist/plastiq#refresh-outside-render-cycle'); } function areAllComponents(components) { for (var i = 0; i < components.length; i++) { if(!isComponent(components[i])) { return false; } } return true; } function isComponent(component) { return component && typeof component.init === 'function' && typeof component.update === 'function' && typeof component.destroy === 'function'; } exports.merge = function (element, render, model, options) { var attachment = startAttachment(render, model, options, function(render, domComponentOptions) { var component = domComponent(domComponentOptions); exports.html.currentRender.eventHandlerWrapper = function() { return null; }; var vdom = render(); component.merge(vdom, element); return component; }); attachment.refresh(); return attachment; }; exports.append = function (element, render, model, options) { return startAttachment(render, model, options, function(render, domComponentOptions) { var component = domComponent(domComponentOptions); var vdom = render(); element.appendChild(component.create(vdom)); return component; }); }; exports.replace = function (element, render, model, options) { return startAttachment(render, model, options, function(render, domComponentOptions) { var component = domComponent(domComponentOptions); var vdom = render(); element.parentNode.replaceChild(component.create(vdom), element); return component; }); }; exports.appendVDom = function (vdom, render, model, options) { return startAttachment(render, model, options, function(render) { var component = { create: function(newVDom) { vdom.children = []; if (newVDom) { vdom.children.push(newVDom); } }, update: function(newVDom) { vdom.children = []; if (newVDom) { vdom.children.push(newVDom); } } }; component.create(render()); return component; }); }; var attachmentId = 1; function startAttachment(render, model, options, attachToDom) { if (typeof render == 'object' && typeof render.render == 'function') { return start(function () { return render.render(); }, model, attachToDom); } else { return start(function () { return render(model); }, options, attachToDom); } } function start(render, options, attachToDom) { var win = (options && options.window) || window; var requestRender = (options && options.requestRender) || win.requestAnimationFrame || win.setTimeout; var requested = false; function refresh() { if (!requested) { requestRender(function () { requested = false; if (attachment.attached) { doThenFireAfterRender(attachment, function () { var vdom = render(); component.update(vdom); }); } }); requested = true; } } var attachment = { refresh: refresh, requestRender: requestRender, id: attachmentId++, attached: true } var component; doThenFireAfterRender(attachment, function () { if (options) { var domComponentOptions = {document: options.document}; } component = attachToDom(render, domComponentOptions); }); return { detach: function () { attachment.attached = false; }, remove: function () { component.destroy({removeElement: true}); attachment.attached = false; }, refresh: refresh }; } exports.attach = function () { console.warn('plastiq.attach has been renamed to plastiq.append, plastiq.attach will be deprecated in a future version'); return exports.append.apply(this, arguments); } function refreshComponent(component, attachment) { if (!component.canRefresh) { throw new Error("this component cannot be refreshed, make sure that the component's view is returned from a function"); } if (!component.requested) { var requestRender = attachment.requestRender; requestRender(function () { doThenFireAfterRender(attachment, function () { component.requested = false; component.refresh(); }); }); component.requested = true; } } var norefresh = {}; function refreshify(fn, options) { if (!fn) { return fn; } if (!exports.html.currentRender) { if (typeof global === 'object') { return fn; } else { throw new Error('You cannot create virtual-dom event handlers outside a render function. See https://github.com/featurist/plastiq#outside-render-cycle'); } } var onlyRefreshAfterPromise = options && options.refresh == 'promise'; var componentToRefresh = options && options.component; if (options && (options.norefresh == true || options.refresh == false)) { return fn; } var attachment = exports.html.currentRender.attachment; var r = attachment.refresh; return function () { var result = fn.apply(this, arguments); function handleResult(result, promiseResult) { var allowRefresh = !onlyRefreshAfterPromise || promiseResult; if (allowRefresh && result && typeof(result) == 'function') { console.warn('animations are now deprecated, you should consider using plastiq.html.refresh'); result(r); } else if (result && typeof(result.then) == 'function') { if (allowRefresh) { r(); } result.then(function (result) { handleResult(result, onlyRefreshAfterPromise); }); } else if ( result && typeof result.init === 'function' && typeof result.update === 'function' && typeof result.destroy === 'function') { refreshComponent(result, attachment); } else if (result instanceof Array && areAllComponents(result)) { for (var i = 0; i < result.length; i++) { refreshComponent(result[i], attachment); } } else if (componentToRefresh) { refreshComponent(componentToRefresh, attachment); } else if (result === norefresh) { // don't refresh; } else if (allowRefresh) { r(); return result; } } return handleResult(result); }; } function refreshAfter(promise) { var refresh = exports.html.refresh; promise.then(refresh); } function bindTextInput(attributes, children, get, set) { var textEventNames = ['onkeyup', 'oninput', 'onpaste', 'textInput']; var bindingValue = get(); if (!(bindingValue instanceof Error)) { attributes.value = bindingValue != undefined? bindingValue: ''; } attachEventHandler(attributes, textEventNames, function (ev) { if (bindingValue != ev.target.value) { set(ev.target.value); } }); } function sequenceFunctions(handler1, handler2) { return function (ev) { handler1(ev); return handler2(ev); }; } function insertEventHandler(attributes, eventName, handler, after) { var previousHandler = attributes[eventName]; if (previousHandler) { if (after) { attributes[eventName] = sequenceFunctions(previousHandler, handler); } else { attributes[eventName] = sequenceFunctions(handler, previousHandler); } } else { attributes[eventName] = handler; } } function attachEventHandler(attributes, eventNames, handler) { if (eventNames instanceof Array) { for (var n = 0; n < eventNames.length; n++) { insertEventHandler(attributes, eventNames[n], handler); } } else { insertEventHandler(attributes, eventNames, handler); } } function ListenerHook(listener) { this.listener = exports.html.refreshify(listener); } ListenerHook.prototype.hook = function (element, propertyName, previous) { element.addEventListener(propertyName.substring(2), this.listener, false); }; ListenerHook.prototype.unhook = function (element, propertyName) { element.removeEventListener(propertyName.substring(2), this.listener); }; function customEvent(name) { if (typeof window.Event == 'function') { return new Event('_plastiqsyncchecked'); } else { var customEvent = document.createEvent('Event'); customEvent.initEvent('_plastiqsyncchecked', false, false); return customEvent; } } var inputTypeBindings = { text: bindTextInput, textarea: bindTextInput, checkbox: function (attributes, children, get, set) { attributes.checked = get(); attachEventHandler(attributes, 'onclick', function (ev) { attributes.checked = ev.target.checked; set(ev.target.checked); }); }, radio: function (attributes, children, get, set) { var value = attributes.value; attributes.checked = get() == attributes.value; attributes.on_plastiqsyncchecked = new ListenerHook(function (event) { attributes.checked = event.target.checked; }); attachEventHandler(attributes, 'onclick', function (event) { var name = event.target.name; if (name) { var inputs = document.getElementsByName(name); for (var i = 0, l = inputs.length; i < l; i++) { var radio = inputs[i]; radio.dispatchEvent(customEvent('_plastiqsyncchecked')); } } set(value); }); }, select: function (attributes, children, get, set) { var currentValue = get(); var options = children.filter(function (child) { return child.tagName.toLowerCase() == 'option'; }); var values = []; var selectedIndex; for(var n = 0; n < options.length; n++) { var option = options[n]; var value = option.properties.value; var text = option.children.map(function (x) { return x.text; }).join(''); values.push(value != undefined? value: text); var selected = value == currentValue || text == currentValue; if (selected) { selectedIndex = n; } option.properties.selected = selected; option.properties.value = n; } if (selectedIndex !== undefined) { attributes.selectedIndex = selectedIndex; } attachEventHandler(attributes, 'onchange', function (ev) { attributes.selectedIndex = ev.target.selectedIndex; set(values[ev.target.value]); }); }, file: function (attributes, children, get, set) { var multiple = attributes.multiple; attachEventHandler(attributes, 'onchange', function (ev) { if (multiple) { set(ev.target.files); } else { set(ev.target.files[0]); } }); } }; function bindModel(attributes, children, type) { var bind = inputTypeBindings[type] || bindTextInput; var bindingAttr = makeBinding(attributes.binding); bind(attributes, children, bindingAttr.get, bindingAttr.set); } function inputType(selector, attributes) { if (/^textarea\b/i.test(selector)) { return 'textarea'; } else if (/^select\b/i.test(selector)) { return 'select'; } else { return attributes.type || 'text'; } } var renames = { for: 'htmlFor', class: 'className', contenteditable: 'contentEditable', tabindex: 'tabIndex', colspan: 'colSpan' }; var dataAttributeRegex = /^data-/; function prepareAttributes(tag, attributes, childElements) { var keys = Object.keys(attributes); var dataset; var eventHandlerWrapper = exports.html.currentRender && exports.html.currentRender.eventHandlerWrapper; for (var k = 0; k < keys.length; k++) { var key = keys[k]; var attribute = attributes[key]; if (typeof(attribute) == 'function') { if (eventHandlerWrapper) { var fn = eventHandlerWrapper.call(undefined, key.replace(/^on/, ''), attribute); attributes[key] = typeof fn === 'function'? refreshify(fn): fn; } else { attributes[key] = refreshify(attribute); } } var rename = renames[key]; if (rename) { attributes[rename] = attribute; delete attributes[key]; continue; } if (dataAttributeRegex.test(key)) { if (!dataset) { dataset = attributes.dataset; if (!dataset) { dataset = attributes.dataset = {}; } } var datakey = key.replace(dataAttributeRegex, ''); dataset[datakey] = attribute; delete attributes[key]; continue; } } if (attributes.className) { attributes.className = generateClassName(attributes.className); } if (attributes.binding) { bindModel(attributes, childElements, inputType(tag, attributes)); delete attributes.binding; } } /** * this function is quite ugly and you may be very tempted * to refactor it into smaller functions, I certainly am. * however, it was written like this for performance * so think of that before refactoring! :) */ exports.html = function (hierarchySelector) { var hasHierarchy = hierarchySelector.indexOf(' ') >= 0; var selector, selectorElements; if (hasHierarchy) { selectorElements = hierarchySelector.match(/\S+/g); selector = selectorElements[selectorElements.length - 1]; } else { selector = hierarchySelector; } var attributes; var childElements; var vdom; var tag; if (arguments[1] && arguments[1].constructor == Object) { attributes = arguments[1]; childElements = coerceChildren(Array.prototype.slice.call(arguments, 2)); prepareAttributes(selector, attributes, childElements); tag = parseTag(selector, attributes); vdom = h(tag, attributes, childElements); } else { attributes = {}; childElements = coerceChildren(Array.prototype.slice.call(arguments, 1)); tag = parseTag(selector, attributes); vdom = h(tag, attributes, childElements); } if (hasHierarchy) { for(var n = selectorElements.length - 2; n >= 0; n--) { vdom = h(selectorElements[n], {}, [vdom]); } } return vdom; }; exports.jsx = function (tag, attributes) { var childElements = coerceChildren(Array.prototype.slice.call(arguments, 2)); if (attributes) { prepareAttributes(tag, attributes, childElements); } return h(tag, attributes || {}, childElements); }; exports.html.refreshify = refreshify; exports.html.refresh = refreshOutOfRender; exports.html.refreshAfter = refreshAfter; exports.html.norefresh = norefresh; function makeBinding(b, options) { var binding = b instanceof Array ? bindingObject.apply(undefined, b) : b; binding.set = refreshify(binding.set, options); return binding; } function makeConverter(converter) { if (typeof converter == 'function') { return { view: function (model) { return model; }, model: function (view) { return converter(view); } }; } else { return converter; } } function chainConverters(startIndex, converters) { function makeConverters() { if (!_converters) { _converters = new Array(converters.length - startIndex); for(var n = startIndex; n < converters.length; n++) { _converters[n - startIndex] = makeConverter(converters[n]); } } } if ((converters.length - startIndex) == 1) { return makeConverter(converters[startIndex]); } else { var _converters; return { view: function (model) { makeConverters(); var intermediateValue = model; for(var n = 0; n < _converters.length; n++) { intermediateValue = _converters[n].view(intermediateValue); } return intermediateValue; }, model: function (view) { makeConverters(); var intermediateValue = view; for(var n = _converters.length - 1; n >= 0; n--) { intermediateValue = _converters[n].model(intermediateValue); } return intermediateValue; } }; } } function bindingObject(model, property) { var _meta; function plastiqMeta() { return _meta || (_meta = bindingMeta(model, property)); } if (arguments.length > 2) { var converter = chainConverters(2, arguments); return { get: function() { var meta = plastiqMeta(); var modelValue = model[property]; var modelText; if (meta.error) { return meta.view; } else if (meta.view === undefined) { modelText = converter.view(modelValue); meta.view = modelText; return modelText; } else { var previousValue = converter.model(meta.view); modelText = converter.view(modelValue); var normalisedPreviousText = converter.view(previousValue); if (modelText === normalisedPreviousText) { return meta.view; } else { meta.view = modelText; return modelText; } } }, set: function(view) { var meta = plastiqMeta(); meta.view = view; try { model[property] = converter.model(view, model[property]); delete meta.error; } catch (e) { meta.error = e; } }, meta: function() { return plastiqMeta(); } }; } else { return { get: function () { return model[property]; }, set: function (value) { model[property] = value; }, meta: function() { return plastiqMeta(); } }; } } exports.binding = makeBinding; exports.html.binding = makeBinding; exports.html.meta = bindingMeta; function rawHtml() { var selector; var html; var options; if (arguments.length == 2) { selector = arguments[0]; html = arguments[1]; options = {innerHTML: html}; return exports.html(selector, options); } else { selector = arguments[0]; options = arguments[1]; html = arguments[2]; options.innerHTML = html; return exports.html(selector, options); } } exports.html.rawHtml = rawHtml; function generateConditionalClassNames(obj) { return Object.keys(obj).filter(function (key) { return obj[key]; }).join(' ') || undefined; } function generateClassName(obj) { if (typeof(obj) == 'object') { if (obj instanceof Array) { var names = obj.map(function(item) { return generateClassName(item); }); return names.join(' ') || undefined; } else { return generateConditionalClassNames(obj); } } else { return obj; } }