/** * A JavaScript module used for two way data binding * * @author Lars Hisken * * IMPROVEMENTS: Re-apply bindings on DOM changes * Properly ship this module * Write unit tests using eg mocha and jsom * * @param {String} root The root component selector * @param {Object} options The module's options * @return {Object} */ 'use strict'; function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; return arr2; } else { return Array.from(arr); } } function vmModule() { var root = arguments.length <= 0 || arguments[0] === undefined ? '#app' : arguments[0]; var _ref = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; var _ref$bindingAttr = _ref.bindingAttr; var bindingAttr = _ref$bindingAttr === undefined ? 'bind' : _ref$bindingAttr; var _ref$inputElements = _ref.inputElements; var inputElements = _ref$inputElements === undefined ? ['INPUT', 'TEXTAREA'] : _ref$inputElements; /** * Select all bound elements inside the root * TODO: The elementList should have some refresh mechanism when the DOM changes * @type {NodeList} */ var elementList = document.querySelector(root).querySelectorAll('*[' + bindingAttr + ']'); /** * Collect all elements by their binding * TODO: The view object should have some refresh mechanism when the DOM changes * @type {Object} */ var view = Array.prototype.reduce.call(elementList, viewReducer, {}); /** * Create the scope object and trigger apply() on property assignment * TODO: Add a polyfill for wider browser support (eg https://github.com/GoogleChrome/proxy-polyfill) * @type {Proxy} */ var scope = new Proxy({}, { set: function set(target, name, value) { target[name] = value; apply(view, name, value); } }); /** * Add event listeners to all input elements * TODO: There should be some method to remove these */ Array.prototype.filter.call(elementList, propertyInList('tagName', inputElements)).forEach(function (element) { // TODO: Different elements require different listeners // Add support for more elements and test thorougly if (element.getAttribute('type') === 'checkbox') { element.addEventListener('change', checkboxHandler); } else { element.addEventListener('input', textHandler); } }); /** * Apply a property value to the view * @param {Proxy} scope * @param {String} property * @param {String} value * @return {void} */ function apply(view, property, value) { view[property].forEach(function (element) { // TODO: Different elements require different attributes to be applied // Add support for more elements and test thorougly if (checkboxElementNeedsBinding(element, value)) { element.checked = value; } else if (textElementNeedsBinding(element, value)) { element.value = value; } else if (elementNeedsBinding(element, value)) { element.textContent = value; } }); } /** * Check whether a checkbox input element needs to be updated * @param {Element} element * @param {any} value * @return {boolean} */ function checkboxElementNeedsBinding(element, value) { return inputElements.indexOf(element.tagName) !== -1 && element.getAttribute('type') === 'checkbox' && element.checked !== value; } /** * Check whether a text input element needs to be updated * @param {Element} element * @param {any} value * @return {boolean} */ function textElementNeedsBinding(element, value) { return inputElements.indexOf(element.tagName) !== -1 && element.getAttribute('type') === 'text' || element.tagName === 'TEXTAREA' && element.getAttribute('value') !== value; } /** * Check whether an element needs to be updated * @param {Element} element * @param {any} value * @return {boolean} */ function elementNeedsBinding(element, value) { return element.textContent !== value; } /** * A handler used to assign input values to the scope * @param {InputEvent} event * @return {void} */ function textHandler(event) { scope[event.target.getAttribute(bindingAttr)] = event.target.value; } /** * A handler used to assign checkbox values to the scope * @param {ChangeEvent} event * @return {void} */ function checkboxHandler(event) { scope[event.target.getAttribute(bindingAttr)] = event.target.checked; } /** * A reducer used to collect elements by their binding * @param {Object} view The view's accumulator * @param {Element} element * @return {Object} */ function viewReducer(view, element) { var bindingValue = element.getAttribute(bindingAttr); view[bindingValue] = [element].concat(_toConsumableArray(view[bindingValue])); return view; } /** * Check whether an item's property is in a list * @param {string} property * @param {Array} list * @return {Function} */ function propertyInList(property, list) { return function (item) { return list.indexOf(item[property]) !== -1; }; } return { view: view, scope: scope, apply: apply }; } // Example usage (function () { var vm = vmModule(); // Add some data to the scope vm.scope.title = 'Use a text input'; vm.scope.paragraph = 'Use a textarea as input'; vm.scope.checked = true; var button = document.getElementById('#action'); button.addEventListener('click', function (event) { // Change the scope by assigning values directly vm.scope.title = 'Well, hello.'; vm.scope.paragraph = 'Some long description...'; // Or use apply vm.apply(vm.view, 'checked', false); // New values can also be assigned vm.scope.error = 'An error occurred.'; }); })();