diff --git a/.gitmodules b/.gitmodules index bdb4604..75d0b96 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,7 @@ [submodule "third_party/ChangeSummary"] path = third_party/ChangeSummary url = https://github.com/rafaelw/ChangeSummary.git + branch = master [submodule "third_party/mocha"] path = third_party/mocha url = https://github.com/visionmedia/mocha.git diff --git a/README.md b/README.md index e32f2fc..1e44f04 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ MDV is designed to as two primitives which could eventually become standardized MDV is mainly concerned with being robust and efficient in interacting with application data and keeping the DOM in sync , but more advanced behaviors can be accomplished via one or both of the following: * [A Custom Syntax API](https://github.com/Polymer/mdv/blob/master/docs/syntax_api.md) +* [Expression Syntax](https://github.com/Polymer/mdv/blob/master/docs/expression_syntax.md) ### Advanced Topics diff --git a/benchmark/index.html b/benchmark/index.html new file mode 100644 index 0000000..d5080b5 --- /dev/null +++ b/benchmark/index.html @@ -0,0 +1,223 @@ + + + + MDV Benchmarks + + + + + + + + + +

MDV Benchmarks

+ +Width: + +Depth: + +Decoration: + +Instance Count: +
+Binding Density (%): +
+ +MDV: +Handlebars: + +
+ + + +
+
+
+ Times in ms +
+
+ +
+
+
    +
+
+
+
+

Binding Density

+ +
+
+
+ + + \ No newline at end of file diff --git a/benchmark/mdv_benchmark.js b/benchmark/mdv_benchmark.js new file mode 100644 index 0000000..a1cad23 --- /dev/null +++ b/benchmark/mdv_benchmark.js @@ -0,0 +1,229 @@ +// Copyright 2013 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +(function(global) { + 'use strict'; + + var createObject = ('__proto__' in {}) ? + function(obj) { return obj; } : + function(obj) { + var proto = obj.__proto__; + if (!proto) + return obj; + var newObject = Object.create(proto); + Object.getOwnPropertyNames(obj).forEach(function(name) { + Object.defineProperty(newObject, name, + Object.getOwnPropertyDescriptor(obj, name)); + }); + return newObject; + }; + + var attribNames = [ + 'foo', + 'bar', + 'baz', + 'bat', + 'boo', + 'cat', + 'dog', + 'fog', + 'hat', + 'pig' + ] + + var propNames = [ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j' + ]; + + function MDVBenchmark(testDiv, width, depth, decoration, + instanceCount) { + Benchmark.call(this); + this.testDiv = testDiv; + this.width = width; + this.depth = depth; + this.decoration = decoration; + + this.valueCounter = 1; + this.ping = this.objectArray(instanceCount); + this.pong = this.objectArray(instanceCount); + this.flip = true; + } + + MDVBenchmark.prototype = createObject({ + __proto__: Benchmark.prototype, + + dataObject: function() { + var obj = {}; + propNames.forEach(function(prop) { + obj[prop] = 'value' + (this.valueCounter++); + }, this); + return obj; + }, + + objectArray: function(count) { + var array = []; + + for (var i = 0; i < count; i++) + array.push(this.dataObject()); + + return array; + }, + + nextBindingText: function() { + if (this.bindingCounter++ > this.bindingCount) + return 'I am Text!'; + + if (this.propNameCounter >= propNames.length) + this.propNameCounter = 0; + + return '{{ ' + propNames[this.propNameCounter++] + ' }}'; + }, + + decorate: function(element) { + if (!this.decoration) + return; + + if (element.nodeType === Node.TEXT_NODE) { + element.textContent = this.nextBindingText(); + return; + } + + for (var i = 0; i < this.decoration; i++) { + element.setAttribute(attribNames[i], this.nextBindingText()); + } + }, + + buildFragment: function(parent, width, depth) { + if (!depth) + return; + + var text = parent.appendChild(document.createTextNode('I am text!')); + this.decorate(text); + + for (var i = 0; i < width; i++) { + var div = parent.appendChild(document.createElement('div')); + this.buildFragment(div, width, depth - 1); + this.decorate(div); + } + }, + + setupTest: function(density) { + // |decoration| attributes on each element in each depth + var bindingCount = this.decoration * + (Math.pow(this.width, this.depth) - 1) * this.width; + // if |decoration| >= 1, one binding for each text node at each depth. + if (this.decoration > 0) + bindingCount += Math.pow(this.width, this.depth) - 1; + + this.bindingCount = Math.round(bindingCount * density); + this.bindingCounter = 0; + this.propNameCounter = 0; + this.fragment = document.createDocumentFragment(); + this.buildFragment(this.fragment, this.width, this.depth, this.decoration, + density); + }, + + teardownTest: function(density) { + this.fragment = undefined; + }, + + setupMDVVariant: function() { + if (testDiv.childNodes.length > 1) + alert('Failed to cleanup last test'); + + testDiv.innerHTML = ''; + this.template = testDiv.appendChild(document.createElement('template')); + HTMLTemplateElement.decorate(this.template); + this.template.content.appendChild(this.fragment.cloneNode(true)); + this.template.setAttribute('repeat', ''); + }, + + runMDV: function() { + this.template.model = this.flip ? this.ping : this.pong; + this.flip = !this.flip; + }, + + teardownMDVVariant: function() { + this.template.model = undefined; + }, + + setupHandlebarsVariant: function() { + testDiv.innerHTML = ''; + var div = document.createElement('div'); + div.appendChild(this.fragment.cloneNode(true)); + this.handlebarsTemplate = '{{#each this}}' + div.innerHTML + '{{/each}}'; + this.compiledTemplate = Handlebars.compile(this.handlebarsTemplate); + }, + + runHandlebars: function() { + testDiv.innerHTML = ''; + testDiv.innerHTML = this.compiledTemplate(this.flip ? + this.ping : this.pong); + if (!testDiv.querySelectorAll('div').length) + console.error('Foo'); + this.flip = !this.flip; + }, + + teardownHandlebarsVariant: function() { + testDiv.innerHTML = ''; + }, + + setupVariant: function(testType) { + switch (testType) { + case 'MDV': + this.setupMDVVariant(); + break; + case 'Handlebars': + this.setupHandlebarsVariant(); + break; + } + }, + + run: function(testType) { + switch (testType) { + case 'MDV': + this.runMDV(); + break; + case 'Handlebars': + this.runHandlebars(); + break; + } + }, + + teardownVariant: function(testType) { + switch (testType) { + case 'MDV': + this.teardownMDVVariant(); + break; + case 'Handlebars': + this.teardownHandlebarsVariant(); + break; + } + }, + + destroy: function() {} + }); + + global.MDVBenchmark = MDVBenchmark; + +})(this); \ No newline at end of file diff --git a/build.json b/build.json new file mode 100644 index 0000000..1fc19df --- /dev/null +++ b/build.json @@ -0,0 +1,13 @@ +[ + "third_party/ChangeSummary/change_summary.js", + "src/compat.js", + "src/sidetable.js", + "src/model.js", + "src/script_value_binding.js", + "src/text_replacements_binding.js", + "src/element_attribute_bindings.js", + "src/element_bindings.js", + "src/input_bindings.js", + "src/template_element.js", + "src/delegates.js" +] diff --git a/conf/karma.conf.js b/conf/karma.conf.js index 2c8e2c2..6cbb7b4 100644 --- a/conf/karma.conf.js +++ b/conf/karma.conf.js @@ -1,89 +1,85 @@ -// Sample Karma configuration file, that contain pretty much all the available options -// It's used for running client tests on Travis (http://travis-ci.org/#!/karma-runner/karma) -// Most of the options can be overriden by cli arguments (see karma --help) -// -// For all available config options and default values, see: -// https://github.com/karma-runner/karma/blob/stable/lib/config.js#L54 - - -// base path, that will be used to resolve files and exclude -basePath = '../'; - -// list of files / patterns to load in the browser -files = [ - 'node_modules/chai/chai.js', - 'conf/mocha.conf.js', - 'tests/setup.js', - 'mdv.js', - 'tests/*.js', - {pattern: 'src/*.css', included: false}, - {pattern: 'src/*.js', included: false}, - {pattern: 'util/*.js', included: false}, - {pattern: 'third_party/**/*.js', included: false} -]; - -// list of files to exclude -exclude = []; - -frameworks = ['mocha']; - -// use dots reporter, as travis terminal does not support escaping sequences -// possible values: 'dots', 'progress', 'junit', 'teamcity' -// CLI --reporters progress -reporters = ['progress']; - -// web server port -// CLI --port 9876 -port = 9876; - -// cli runner port -// CLI --runner-port 9100 -runnerPort = 9100; - -// enable / disable colors in the output (reporters and logs) -// CLI --colors --no-colors -colors = true; - -// level of logging -// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG -// CLI --log-level debug -logLevel = LOG_INFO; - -// enable / disable watching file and executing tests whenever any file changes -// CLI --auto-watch --no-auto-watch -autoWatch = true; - -// Start these browsers, currently available: -// - Chrome -// - ChromeCanary -// - Firefox -// - Opera -// - Safari (only Mac) -// - PhantomJS -// - IE (only Windows) -// CLI --browsers Chrome,Firefox,Safari -browsers = ['ChromeCanary']; - -// If browser does not capture in given timeout [ms], kill it -// CLI --capture-timeout 5000 -captureTimeout = 50000; - -// Auto run tests on start (when browsers are captured) and exit -// CLI --single-run --no-single-run -singleRun = true; - -// report which specs are slower than 500ms -// CLI --report-slower-than 500 -reportSlowerThan = 500; - -// compile coffee scripts -preprocessors = { +module.exports = function(karma) { + karma.configure({ + // base path, that will be used to resolve files and exclude + basePath: '../', + + // list of files / patterns to load in the browser + files: [ + 'node_modules/chai/chai.js', + 'conf/mocha.conf.js', + 'tests/setup.js', + 'mdv.js', + 'tests/*.js', + {pattern: 'src/*.css', included: false}, + {pattern: 'src/*.js', included: false}, + {pattern: 'util/*.js', included: false}, + {pattern: 'third_party/**/*.js', included: false} + ], + + // list of files to exclude + exclude: [], + + frameworks: ['mocha'], + + // use dots reporter, as travis terminal does not support escaping sequences + // possible values: 'dots', 'progress', 'junit', 'teamcity' + // CLI --reporters progress + reporters: ['progress'], + + // web server port + // CLI --port 9876 + port: 9876, + + // cli runner port + // CLI --runner-port 9100 + runnerPort: 9100, + + // enable / disable colors in the output (reporters and logs) + // CLI --colors --no-colors + colors: true, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + // CLI --log-level debug + logLevel: karma.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + // CLI --auto-watch --no-auto-watch + autoWatch: true, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + // CLI --browsers Chrome,Firefox,Safari + browsers: ['ChromeCanary'], + + // If browser does not capture in given timeout [ms], kill it + // CLI --capture-timeout 5000 + captureTimeout: 50000, + + // Auto run tests on start (when browsers are captured) and exit + // CLI --single-run --no-single-run + singleRun: true, + + // report which specs are slower than 500ms + // CLI --report-slower-than 500 + reportSlowerThan: 500, + + // compile coffee scripts + preprocessors: { + }, + + plugins: [ + 'karma-mocha', + 'karma-chrome-launcher', + 'karma-firefox-launcher', + 'karma-script-launcher', + 'karma-crbot-reporter' + ] + }); }; - -plugins = [ - 'karma-mocha', - 'karma-chrome-launcher', - 'karma-firefox-launcher', - 'karma-script-launcher', - 'karma-crbot-reporter' -] diff --git a/examples/how_to/array_reduction.html b/examples/how_to/array_reduction.html index dfb1b20..9d3473f 100644 --- a/examples/how_to/array_reduction.html +++ b/examples/how_to/array_reduction.html @@ -1,43 +1,58 @@ - - -

Reduction

+ + + + + + + +

Reduction

-
- -
+
+ +
- + document.getElementById('reduction').model = model; + + // Needed to detect model changes if Object.observe + // is not available in the JS VM. + Platform.performMicrotaskCheckpoint(); + }); + + + \ No newline at end of file diff --git a/examples/how_to/bind_to_attributes.html b/examples/how_to/bind_to_attributes.html index b49e302..33d8b39 100644 --- a/examples/how_to/bind_to_attributes.html +++ b/examples/how_to/bind_to_attributes.html @@ -1,31 +1,41 @@ - + + + + + + +

Bind To Attributes

-

Bind To Attributes

+ - + - + + Platform.performMicrotaskCheckpoint(); + }); + }); + + + \ No newline at end of file diff --git a/examples/how_to/bind_to_input_elements.html b/examples/how_to/bind_to_input_elements.html index 4b3d8fd..bd10f14 100644 --- a/examples/how_to/bind_to_input_elements.html +++ b/examples/how_to/bind_to_input_elements.html @@ -1,42 +1,54 @@ - - -

Bind to Input Elements

- - - - + + + + + + +

Bind to Input Elements

+ + + + + + diff --git a/examples/how_to/bind_to_text.html b/examples/how_to/bind_to_text.html index 3e07c35..9f338e9 100644 --- a/examples/how_to/bind_to_text.html +++ b/examples/how_to/bind_to_text.html @@ -1,31 +1,42 @@ - + + + + + + +

Bind To Text

-

Bind To Text

+ - + - + + Platform.performMicrotaskCheckpoint(); + }); + }); + + + diff --git a/examples/how_to/conditional_attributes.html b/examples/how_to/conditional_attributes.html index e9ea04a..965e913 100644 --- a/examples/how_to/conditional_attributes.html +++ b/examples/how_to/conditional_attributes.html @@ -1,17 +1,29 @@ - + + + + + + +

Conditional Attributes

-

Conditional Attributes

+ - + + // Needed to detect model changes if Object.observe + // is not available in the JS VM. + Platform.performMicrotaskCheckpoint(); + }); + + + \ No newline at end of file diff --git a/examples/how_to/conditional_template.html b/examples/how_to/conditional_template.html index 328220e..3047c32 100644 --- a/examples/how_to/conditional_template.html +++ b/examples/how_to/conditional_template.html @@ -1,19 +1,31 @@ - + + + + + + +

Conditional Template

-

Conditional Template

+ - + + // Needed to detect model changes if Object.observe + // is not available in the JS VM. + Platform.performMicrotaskCheckpoint(); + }); + + + \ No newline at end of file diff --git a/examples/how_to/custom_syntax.html b/examples/how_to/custom_syntax.html index 4377838..9c283d5 100644 --- a/examples/how_to/custom_syntax.html +++ b/examples/how_to/custom_syntax.html @@ -1,33 +1,46 @@ - + + + + + + +

Custom Syntax

-

Custom Syntax

+ - + + // Needed to detect model changes if Object.observe + // is not available in the JS VM. + Platform.performMicrotaskCheckpoint(); + }); + + + \ No newline at end of file diff --git a/examples/how_to/nested_templates.html b/examples/how_to/nested_templates.html index 26345a4..a4adc31 100644 --- a/examples/how_to/nested_templates.html +++ b/examples/how_to/nested_templates.html @@ -1,34 +1,46 @@ - + + + + + + +

Nested Template

-

Nested Template

- -Managers: - - + + + diff --git a/examples/how_to/recursive_templates.html b/examples/how_to/recursive_templates.html index 9011b4d..6761cc3 100644 --- a/examples/how_to/recursive_templates.html +++ b/examples/how_to/recursive_templates.html @@ -12,114 +12,120 @@

Recursive Template

\ No newline at end of file diff --git a/examples/how_to/template_ref.html b/examples/how_to/template_ref.html index de844a5..1fb3e56 100644 --- a/examples/how_to/template_ref.html +++ b/examples/how_to/template_ref.html @@ -1,26 +1,38 @@ - + + + + + + +

Re-using templates

-

Re-using templates

+ - +

Usage one:

+ User: -

Usage one:

-User: +

Usage two:

+ More users: + -

Usage two:

-More users: - + + // Needed to detect model changes if Object.observe + // is not available in the JS VM. + Platform.performMicrotaskCheckpoint(); + }); + + + \ No newline at end of file diff --git a/examples/twitter.html b/examples/twitter.html deleted file mode 100644 index 211614e..0000000 --- a/examples/twitter.html +++ /dev/null @@ -1,205 +0,0 @@ - - - - -Twitter Demo - - - - - - - -

Twitter Demo

- - - -
-
-

Users

- - -
- -
-

Details

- -
- -
-
-
- - - - - diff --git a/gruntfile.js b/gruntfile.js index 8a1a0d2..3aefc9c 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -69,25 +69,13 @@ module.exports = function(grunt) { }, wrap: { modules: { - src: [ - 'third_party/ChangeSummary/change_summary.js', - 'src/compat.js', - 'src/sidetable.js', - 'src/model.js', - 'src/script_value_binding.js', - 'src/text_replacements_binding.js', - 'src/element_attribute_bindings.js', - 'src/element_bindings.js', - 'src/input_bindings.js', - 'src/template_element.js', - 'src/delegates.js' - ], + src: grunt.file.readJSON('build.json'), dest: 'src/mdv.combined.js' } } }); - grunt.loadNpmTasks('grunt-karma-0.9.1'); + grunt.loadNpmTasks('grunt-karma'); grunt.registerTask('default', 'wrap'); grunt.registerTask('test', ['karma:mdv']); diff --git a/package.json b/package.json index ff1a50c..6e4d62d 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,10 @@ "chai": "*", "mocha": ">=1.9", "grunt": "*", - "grunt-karma-0.9.1": "~0.4.3", + "grunt-karma": "~0.5.0", "karma-mocha": "*", "karma-script-launcher": "*", - "karma-crbot-reporter": "*" + "karma-crbot-reporter": "*", + "handlebars": "*" } } diff --git a/sample.html b/sample.html index ea1415a..c87c94f 100644 --- a/sample.html +++ b/sample.html @@ -1,23 +1,32 @@ - - - - -

Model-driven Views

- - - + + + + + + +

Model-driven Views

+ + + + \ No newline at end of file diff --git a/src/template_element.js b/src/template_element.js index 269c258..30ac9fc 100644 --- a/src/template_element.js +++ b/src/template_element.js @@ -130,37 +130,86 @@ return node.ownerDocument.contains(node); } - function bindNode(name, model, path) { - console.error('Unhandled binding to Node: ', this, name, model, path); - } + Node.prototype.bind = function(name, model, path) { + this.bindings = this.bindings || {}; + var binding = this.bindings[name]; + if (binding) + binding.close(); + + binding = this.createBinding(name, model, path); + this.bindings[name] = binding; + if (!binding) { + console.error('Unhandled binding to Node: ', this, name, model, path); + return; + } - function unbindNode(name) {} - function unbindAllNode() {} + return binding; + }; - Node.prototype.bind = bindNode; - Node.prototype.unbind = unbindNode; - Node.prototype.unbindAll = unbindAllNode; + // TODO(rafaelw): This isn't really the right design. If node.bind() is + // specified, there's no way to host objects to invoke a "virtual" + // createBinding on custom elements. + Node.prototype.createBinding = function() {}; - var textContentBindingTable = new SideTable(); + Node.prototype.unbind = function(name) { + if (!this.bindings) + return; + var binding = this.bindings[name]; + if (!binding) + return; + binding.close(); + delete this.bindings[name]; + }; - function Binding(model, path, changed) { + Node.prototype.unbindAll = function() { + if (!this.bindings) + return; + var names = Object.keys(this.bindings); + for (var i = 0; i < names.length; i++) { + var binding = this.bindings[names[i]]; + if (binding) + binding.close(); + } + + this.bindings = {}; + }; + + function NodeBinding(node, property, model, path) { + this.closed = false; + this.node = node; + this.property = property; this.model = model; this.path = path; - this.changed = changed; - this.observer = new PathObserver(this.model, this.path, this.changed); - this.changed(this.observer.value); + this.observer = new PathObserver(model, path, + this.boundValueChanged, this); + this.boundValueChanged(this.value); } - Binding.prototype = { - dispose: function() { - if (this.model && typeof this.model.dispose == 'function') - this.model.dispose(); + NodeBinding.prototype = { + boundValueChanged: function(value) { + this.node[this.property] = this.sanitizeBoundValue(value); + }, + sanitizeBoundValue: function(value) { + return value == undefined ? '' : String(value); + }, + + close: function() { + if (this.closed) + return; this.observer.close(); + this.observer = undefined; + this.node = undefined; + this.model = undefined; + this.closed = true; }, - set value(newValue) { - PathObserver.setValueAtPath(this.model, this.path, newValue); + get value() { + return this.observer.value; + }, + + set value(value) { + PathObserver.setValueAtPath(this.model, this.path, value); }, reset: function() { @@ -168,128 +217,42 @@ } }; - function boundSetTextContent(textNode) { - return function(value) { - textNode.data = value == undefined ? '' : String(value); - }; - } - - function bindText(name, model, path) { - if (name !== 'textContent') - return Node.prototype.bind.call(this, name, model, path); - - this.unbind('textContent'); - var binding = new Binding(model, path, boundSetTextContent(this)); - textContentBindingTable.set(this, binding); - } - - function unbindText(name) { - if (name != 'textContent') - return Node.prototype.unbind.call(this, name); - - var binding = textContentBindingTable.get(this); - if (!binding) - return; - - binding.dispose(); - textContentBindingTable.delete(this); - } + Text.prototype.createBinding = function(name, model, path) { + if (name === 'textContent') + return new NodeBinding(this, 'data', model, path); - function unbindAllText() { - this.unbind('textContent'); - Node.prototype.unbindAll.call(this); + return Node.prototype.createBinding.call(this, name, model, path); } - Text.prototype.bind = bindText; - Text.prototype.unbind = unbindText; - Text.prototype.unbindAll = unbindAllText; - - var attributeBindingsTable = new SideTable(); - - function boundSetAttribute(element, attributeName, conditional) { - if (conditional) { - return function(value) { - if (!value) - element.removeAttribute(attributeName); - else - element.setAttribute(attributeName, ''); - }; + function AttributeBinding(element, attributeName, model, path) { + this.conditional = attributeName[attributeName.length - 1] == '?'; + if (this.conditional) { + element.removeAttribute(attributeName); + attributeName = attributeName.slice(0, -1); } - return function(value) { - element.setAttribute(attributeName, - String(value === undefined ? '' : value)); - }; + NodeBinding.call(this, element, attributeName, model, path); } - function ElementAttributeBindings() { - this.bindingMap = Object.create(null); - } - - ElementAttributeBindings.prototype = { - add: function(element, attributeName, model, path) { - element.removeAttribute(attributeName); - var conditional = attributeName[attributeName.length - 1] == '?'; - if (conditional) - attributeName = attributeName.slice(0, -1); - - this.remove(attributeName); - - var binding = new Binding(model, path, - boundSetAttribute(element, attributeName, conditional)); - - this.bindingMap[attributeName] = binding; - }, + AttributeBinding.prototype = createObject({ + __proto__: NodeBinding.prototype, - remove: function(attributeName) { - var binding = this.bindingMap[attributeName]; - if (!binding) + boundValueChanged: function(value) { + if (this.conditional) { + if (value) + this.node.setAttribute(this.property, ''); + else + this.node.removeAttribute(this.property); return; + } - binding.dispose(); - delete this.bindingMap[attributeName]; - }, - - removeAll: function() { - Object.keys(this.bindingMap).forEach(function(attributeName) { - this.remove(attributeName); - }, this); - } - }; - - function bindElement(name, model, path) { - var bindings = attributeBindingsTable.get(this); - if (!bindings) { - bindings = new ElementAttributeBindings(); - attributeBindingsTable.set(this, bindings); + this.node.setAttribute(this.property, this.sanitizeBoundValue(value)); } + }); - // ElementAttributeBindings takes care of removing old binding as needed. - bindings.add(this, name, model, path); - } - - function unbindElement(name) { - var bindings = attributeBindingsTable.get(this); - if (bindings) - bindings.remove(name); - } - - function unbindAllElement(name) { - var bindings = attributeBindingsTable.get(this); - if (!bindings) - return; - attributeBindingsTable.delete(this); - bindings.removeAll(); - Node.prototype.unbindAll.call(this); - } - - - Element.prototype.bind = bindElement; - Element.prototype.unbind = unbindElement; - Element.prototype.unbindAll = unbindAllElement; - - var valueBindingTable = new SideTable(); - var checkedBindingTable = new SideTable(); + Element.prototype.createBinding = function(name, model, path) { + return new AttributeBinding(this, name, model, path); + }; var checkboxEventType; (function() { @@ -331,47 +294,34 @@ } } - function InputBinding(element, valueProperty, model, path) { - this.element = element; - this.valueProperty = valueProperty; - this.boundValueChanged = this.valueChanged.bind(this); - this.boundUpdateBinding = this.updateBinding.bind(this); - - this.binding = new Binding(model, path, this.boundValueChanged); - this.element.addEventListener(getEventForInputType(this.element), - this.boundUpdateBinding, true); + function InputBinding(node, property, model, path) { + NodeBinding.call(this, node, property, model, path); + this.eventType = getEventForInputType(this.node); + this.boundNodeValueToModel = this.nodeValueChanged.bind(this); + this.node.addEventListener(this.eventType, this.boundNodeValueToModel, + true); } - InputBinding.prototype = { - valueChanged: function(newValue) { - this.element[this.valueProperty] = this.produceElementValue(newValue); - }, - - updateBinding: function() { - this.binding.value = this.element[this.valueProperty]; - this.binding.reset(); - if (this.postUpdateBinding) - this.postUpdateBinding(); + InputBinding.prototype = createObject({ + __proto__: NodeBinding.prototype, + nodeValueChanged: function() { + this.value = this.node[this.property]; + this.reset(); + this.postUpdateBinding(); Platform.performMicrotaskCheckpoint(); }, - unbind: function() { - this.binding.dispose(); - this.element.removeEventListener(getEventForInputType(this.element), - this.boundUpdateBinding, true); - } - }; + postUpdateBinding: function() {}, - function ValueBinding(element, model, path) { - InputBinding.call(this, element, 'value', model, path); - } - - ValueBinding.prototype = createObject({ - __proto__: InputBinding.prototype, + close: function() { + if (this.closed) + return; - produceElementValue: function(value) { - return String(value == null ? '' : value); + this.node.removeEventListener(this.eventType, + this.boundNodeValueToModel, + true); + NodeBinding.prototype.close.call(this); } }); @@ -410,7 +360,7 @@ CheckedBinding.prototype = createObject({ __proto__: InputBinding.prototype, - produceElementValue: function(value) { + sanitizeBoundValue: function(value) { return Boolean(value); }, @@ -418,94 +368,44 @@ // Only the radio button that is getting checked gets an event. We // therefore find all the associated radio buttons and update their // CheckedBinding manually. - if (this.element.tagName === 'INPUT' && - this.element.type === 'radio') { - getAssociatedRadioButtons(this.element).forEach(function(r) { - var checkedBinding = checkedBindingTable.get(r); + if (this.node.tagName === 'INPUT' && + this.node.type === 'radio') { + getAssociatedRadioButtons(this.node).forEach(function(radio) { + var checkedBinding = radio.bindings.checked; if (checkedBinding) { // Set the value directly to avoid an infinite call stack. - checkedBinding.binding.value = false; + checkedBinding.value = false; } }); } } }); - function bindInput(name, model, path) { - switch(this.tagName + '.' + name.toLowerCase()) { - case 'INPUT.value': - case 'TEXTAREA.value': - this.unbind('value'); - this.removeAttribute('value'); - valueBindingTable.set(this, new ValueBinding(this, model, path)); - break; - case 'INPUT.checked': - this.unbind('checked'); - this.removeAttribute('checked'); - checkedBindingTable.set(this, new CheckedBinding(this, model, path)); - break; - case 'SELECT.selectedindex': - this.unbind('selectedindex'); - this.removeAttribute('selectedindex'); - valueBindingTable.set(this, - new SelectedIndexBinding(this, model, path)); - break; - default: - return Element.prototype.bind.call(this, name, model, path); - break; + HTMLInputElement.prototype.createBinding = function(name, model, path) { + if (name === 'value') { + // TODO(rafaelw): Maybe template should remove all binding instructions. + this.removeAttribute(name); + return new InputBinding(this, 'value', model, path) } - } - function unbindInput(name) { - switch(this.tagName + '.' + name.toLowerCase()) { - case 'INPUT.value': - case 'TEXTAREA.value': - var valueBinding = valueBindingTable.get(this); - if (valueBinding) { - valueBinding.unbind(); - valueBindingTable.delete(this); - } - break; - case 'INPUT.checked': - var checkedBinding = checkedBindingTable.get(this); - if (checkedBinding) { - checkedBinding.unbind(); - checkedBindingTable.delete(this) - } - break; - case 'SELECT.selectedindex': - var valueBinding = valueBindingTable.get(this); - if (valueBinding) { - valueBinding.unbind(); - valueBindingTable.delete(this); - } - break; - default: - return Element.prototype.unbind.call(this, name); - break; + if (name === 'checked') { + this.removeAttribute(name); + return new CheckedBinding(this, model, path); } + + return HTMLElement.prototype.createBinding.call(this, name, model, path); } - function unbindAllInput(name) { - switch (this.tagName) { - case 'INPUT': - this.unbind('checked'); - // fallthrough - case 'TEXTAREA': - this.unbind('value'); - break; - case 'SELECT': - this.unbind('selectedindex'); - break; + HTMLTextAreaElement.prototype.createBinding = function(name, model, path) { + if (name === 'value') { + // TODO(rafaelw): Maybe template should remove all binding instructions. + this.removeAttribute(name); + return new InputBinding(this, name, model, path) } - Element.prototype.unbindAll.call(this); + return HTMLElement.prototype.createBinding.call(this, name, model, path); } - HTMLInputElement.prototype.bind = bindInput; - HTMLInputElement.prototype.unbind = unbindInput; - HTMLInputElement.prototype.unbindAll = unbindAllInput; - function SelectedIndexBinding(element, model, path) { InputBinding.call(this, element, 'selectedIndex', model, path); } @@ -513,10 +413,10 @@ SelectedIndexBinding.prototype = createObject({ __proto__: InputBinding.prototype, - valueChanged: function(newValue) { - var newValue = this.produceElementValue(newValue); - if (newValue <= this.element.length) { - this.element[this.valueProperty] = newValue; + boundValueChanged: function(value) { + var newValue = Number(value); + if (newValue <= this.node.length) { + this.node[this.property] = newValue; return; } @@ -526,26 +426,24 @@ var maxRetries = 2; var self = this; function delaySetSelectedIndex() { - if (newValue > self.element.length && maxRetries--) + if (newValue > self.node.length && maxRetries--) ensureScheduled(delaySetSelectedIndex); else - self.element[self.valueProperty] = newValue; + self.node[self.property] = newValue; } ensureScheduled(delaySetSelectedIndex); - }, - - produceElementValue: function(value) { - return Number(value); } }); - HTMLSelectElement.prototype.bind = bindInput; - HTMLSelectElement.prototype.unbind = unbindInput; - HTMLSelectElement.prototype.unbindAll = unbindAllInput; + HTMLSelectElement.prototype.createBinding = function(name, model, path) { + if (name.toLowerCase() === 'selectedindex') { + // TODO(rafaelw): Maybe template should remove all binding instructions. + this.removeAttribute(name); + return new SelectedIndexBinding(this, model, path); + } - HTMLTextAreaElement.prototype.bind = bindInput; - HTMLTextAreaElement.prototype.unbind = unbindInput; - HTMLTextAreaElement.prototype.unbindAll = unbindAllInput; + return HTMLElement.prototype.createBinding.call(this, name, model, path); + } var BIND = 'bind'; var REPEAT = 'repeat'; @@ -599,54 +497,69 @@ // simulate proper end-of-microtask behavior for Object.observe. Without // this, we'll continue delivering to a single observer without allowing // other observers in the same microtask to make progress. - var current; - var next; - function Runner() { - var self = this; + function Runner(nextRunner) { + this.nextRunner = nextRunner; this.value = false; - var lastValue = this.value; + this.lastValue = this.value; + this.scheduled = []; + this.scheduledIds = []; + this.running = false; + this.observer = new PathObserver(this, 'value', this.run, this); + } - var scheduled = []; - var running = false; + Runner.prototype = { + schedule: function(async, id) { + if (this.scheduledIds[id]) + return; - this.schedule = function(fn) { - if (scheduled.indexOf(fn) >= 0) - return true; - if (running) - return false; + if (this.running) + return this.nextRunner.schedule(async, id); - scheduled.push(fn); - if (lastValue === self.value) - self.value = !self.value; + this.scheduledIds[id] = true; + this.scheduled.push(async); - return true; - } + if (this.lastValue !== this.value) + return; - var observer = new PathObserver(this, 'value', function() { - running = true; + this.value = !this.value; + }, - for (var i = 0; i < scheduled.length; i++) { - var fn = scheduled[i]; - scheduled[i] = undefined; - fn(); - } + run: function() { + this.running = true; - scheduled = []; - lastValue = self.value; + for (var i = 0; i < this.scheduled.length; i++) { + var async = this.scheduled[i]; + var id = async[idExpando]; + this.scheduledIds[id] = false; - current = next; - next = self; + if (typeof async === 'function') + async(); + else + async.resolve(); + } - running = false; - }); + this.scheduled = []; + this.scheduledIds = []; + this.lastValue = this.value; + + this.running = false; + } } - current = new Runner(); - next = new Runner(); + var runner = new Runner(new Runner()); - function ensureScheduled(fn) { - current.schedule(fn) || next.schedule(fn); + var nextId = 1; + var idExpando = '__scheduledId__'; + + function ensureScheduled(async) { + var id = async[idExpando]; + if (!async[idExpando]) { + id = nextId++; + async[idExpando] = id; + } + + runner.schedule(async, id); } return ensureScheduled; @@ -701,6 +614,7 @@ var templateContentsTable = new SideTable(); var templateContentsOwnerTable = new SideTable(); var templateInstanceRefTable = new SideTable(); + var contentBindingMapTable = new SideTable(); // http://dvcs.w3.org/hg/webcomponents/raw-file/tip/spec/templates/index.html#dfn-template-contents-owner function getTemplateContentsOwner(doc) { @@ -774,6 +688,8 @@ return false; var templateElement = el; + templateElement.templateIsDecorated_ = true; + var isNative = isNativeTemplate(templateElement); var bootstrapContents = isNative; var liftContents = !isNative; @@ -782,12 +698,12 @@ if (!isNative && isAttributeTemplate(templateElement)) { assert(!opt_instanceRef); templateElement = extractTemplateFromAttributeTemplate(el); + templateElement.templateIsDecorated_ = true; + isNative = isNativeTemplate(templateElement); liftRoot = true; } - templateElement.templateIsDecorated_ = true; - if (!isNative) { fixTemplateElementPrototype(templateElement); var doc = getTemplateContentsOwner(templateElement.ownerDocument); @@ -869,61 +785,61 @@ ensureScheduled(setModelFn); } + function TemplateBinding(node, property, model, path) { + this.closed = false; + this.node = node; + this.property = property; + this.model = model; + this.path = path; + this.node.inputs.bind(this.property, model, path || ''); + } + + TemplateBinding.prototype = createObject({ + __proto__: NodeBinding.prototype, + get value() {}, + boundValueChanged: function() {}, + close: function() { + if (this.closed) + return; + this.node.inputs.unbind(this.property); + this.node = undefined; + this.model = undefined; + this.closed = true; + } + }); + mixin(HTMLTemplateElement.prototype, { - bind: function(name, model, path) { - switch (name) { - case BIND: - case REPEAT: - case IF: - var templateIterator = templateIteratorTable.get(this); - if (!templateIterator) { - templateIterator = new TemplateIterator(this); - templateIteratorTable.set(this, templateIterator); - } + createBinding: function(name, model, path) { + if (name === BIND || name === REPEAT || name === IF) { + var iterator = templateIteratorTable.get(this); + if (!iterator) { + iterator = new TemplateIterator(this); + templateIteratorTable.set(this, iterator); + } - templateIterator.inputs.bind(name, model, path || ''); - break; - default: - return Element.prototype.bind.call(this, name, model, path); - break; + return new TemplateBinding(iterator, name, model, path || ''); } - }, - unbind: function(name, model, path) { - switch (name) { - case BIND: - case REPEAT: - case IF: - var templateIterator = templateIteratorTable.get(this); - if (!templateIterator) - break; - - // the template iterator will remove its instances and - // abandon() itself if its inputs.size is 0. - templateIterator.inputs.unbind(name); - break; - default: - return Element.prototype.unbind.call(this, name, model, path); - break; - } + return HTMLElement.prototype.createBinding.call(this, name, model, path); }, - unbindAll: function() { - this.unbind(BIND); - this.unbind(REPEAT); - this.unbind(IF); - Element.prototype.unbindAll.call(this); - }, + createInstance: function(model, delegate, bound) { + var content = this.ref.content; + var map = contentBindingMapTable.get(content); + if (!map) { + // TODO(rafaelw): Setup a MutationObserver on content to detect + // when the instanceMap is invalid. + map = createInstanceBindingMap(content) || []; + contentBindingMapTable.set(content, map); + } - createInstance: function(model, delegate) { - var instance = createDeepCloneAndDecorateTemplates(this.ref.content, - delegate); - // TODO(rafaelw): This is a hack, and is neccesary for the polyfil - // because custom elements are not upgraded during cloneNode() - if (typeof HTMLTemplateElement.__instanceCreated == 'function') - HTMLTemplateElement.__instanceCreated(instance); + var instance = map.hasSubTemplate ? + deepCloneIgnoreTemplateContent(content) : content.cloneNode(true); - addBindings(instance, model, delegate); + addMapBindings(instance, map, model, delegate, bound); + // TODO(rafaelw): We can do this more lazily, but setting a sentinal + // in the parent of the template element, and creating it when it's + // asked for by walking back to find the iterating template. addTemplateInstanceRecord(instance, model); return instance; }, @@ -966,46 +882,43 @@ } }); - var TEXT = 0; - var BINDING = 1; - - function Token(type, value) { - this.type = type; - this.value = value; + function isSimpleBinding(tokens) { + // tokens ==? ['', path, ''] + return tokens.length == 3 && tokens[0].length == 0 && tokens[2].length == 0; } + // Returns + // a) undefined if there are no mustaches. + // b) [TEXT, (PATH, TEXT)+] if there is at least one mustache. function parseMustacheTokens(s) { - var result = []; + if (!s || !s.length) + return; + + var tokens; var length = s.length; - var index = 0, lastIndex = 0; + var startIndex = 0, lastIndex = 0, endIndex = 0; while (lastIndex < length) { - index = s.indexOf('{{', lastIndex); - if (index < 0) { - result.push(new Token(TEXT, s.slice(lastIndex))); - break; - } else { - // There is a non-empty text run before the next path token. - if (index > 0 && lastIndex < index) { - result.push(new Token(TEXT, s.slice(lastIndex, index))); - } - lastIndex = index + 2; - index = s.indexOf('}}', lastIndex); - if (index < 0) { - var text = s.slice(lastIndex - 2); - var lastToken = result[result.length - 1]; - if (lastToken && lastToken.type == TEXT) - lastToken.value += text; - else - result.push(new Token(TEXT, text)); - break; - } + startIndex = s.indexOf('{{', lastIndex); + endIndex = startIndex < 0 ? -1 : s.indexOf('}}', startIndex + 2); - var value = s.slice(lastIndex, index).trim(); - result.push(new Token(BINDING, value)); - lastIndex = index + 2; + if (endIndex < 0) { + if (!tokens) + return; + + tokens.push(s.slice(lastIndex)); // TEXT + break; } + + tokens = tokens || []; + tokens.push(s.slice(lastIndex, startIndex)); // TEXT + tokens.push(s.slice(startIndex + 2, endIndex).trim()); // PATH + lastIndex = endIndex + 2; } - return result; + + if (lastIndex === length) + tokens.push(''); // TEXT + + return tokens; } function bindOrDelegate(node, name, model, path, delegate) { @@ -1019,33 +932,25 @@ } } - node.bind(name, model, path); + return node.bind(name, model, path); } - function parseAndBind(node, name, text, model, delegate) { - var tokens = parseMustacheTokens(text); - if (!tokens.length || (tokens.length == 1 && tokens[0].type == TEXT)) - return; - - if (tokens.length == 1 && tokens[0].type == BINDING) { - bindOrDelegate(node, name, model, tokens[0].value, delegate); - return; - } - - var replacementBinding = new CompoundBinding(); - for (var i = 0; i < tokens.length; i++) { - var token = tokens[i]; - if (token.type == BINDING) - bindOrDelegate(replacementBinding, i, model, token.value, delegate); + function processBindings(bindings, node, model, delegate, bound) { + for (var i = 0; i < bindings.length; i += 2) { + var binding = setupBinding(node, bindings[i], bindings[i + 1], model, + delegate); + if (bound) + bound.push(binding); } + } - replacementBinding.combinator = function(values) { + function newTokenCombinator(tokens) { + return function(values) { var newValue = ''; - for (var i = 0; i < tokens.length; i++) { - var token = tokens[i]; - if (token.type === TEXT) { - newValue += token.value; + for (var i = 0, text = true; i < tokens.length; i++, text = !text) { + if (text) { + newValue += tokens[i]; } else { var value = values[i]; if (value !== undefined) @@ -1055,81 +960,158 @@ return newValue; }; + } + + function setupBinding(node, name, tokens, model, delegate) { + if (isSimpleBinding(tokens)) { + return bindOrDelegate(node, name, model, tokens[1], delegate); + } - node.bind(name, replacementBinding, 'value'); + tokens.combinator = tokens.combinator || newTokenCombinator(tokens); + + var replacementBinding = new CompoundBinding(tokens.combinator); + replacementBinding.scheduled = true; + for (var i = 1; i < tokens.length; i = i + 2) { + bindOrDelegate(replacementBinding, i, model, tokens[i], delegate); + } + replacementBinding.resolve(); + return node.bind(name, replacementBinding, 'value'); } - function addAttributeBindings(element, model, delegate) { + function parseAttributeBindings(element) { assert(element); - var attrs = {}; + var bindings; + var isTemplateNode = isTemplate(element); + var ifFound = false; + var bindFound = false; + for (var i = 0; i < element.attributes.length; i++) { var attr = element.attributes[i]; - attrs[attr.name] = attr.value; + var name = attr.name; + var value = attr.value; + + if (isTemplateNode) { + if (name === IF) { + ifFound = true; + } else if (name === BIND || name === REPEAT) { + bindFound = true; + value = value || '{{}}'; // Accept 'naked' bind & repeat. + } + } + + var tokens = parseMustacheTokens(value); + if (!tokens) + continue; + + bindings = bindings || []; + bindings.push(name, tokens); + } + + // Treat