From 075038e1da00027d76741fefa617d5be3308c24d Mon Sep 17 00:00:00 2001 From: fbasso Date: Tue, 7 Oct 2014 17:24:08 +0200 Subject: [PATCH] Implementation of the select element Close #304 --- README.md | 2 + docs/api/index.md | 47 +- .../inputonupdate/inputonupdate.spec.js | 4 + docs/samples/samples.js | 7 + docs/samples/selectsample/description.md | 14 + docs/samples/selectsample/selectsample.hsp | 84 ++++ .../samples/selectsample/selectsample.spec.js | 19 + hsp/rt/attributes/onupdate.js | 2 +- hsp/rt/attributes/select.js | 119 +++++ hsp/rt/eltnode.js | 38 +- hsp/rt/tnode.js | 4 +- test/rt/attributes/modelvalue.spec.hsp | 6 +- test/rt/attributes/select.spec.hsp | 425 ++++++++++++++++++ 13 files changed, 743 insertions(+), 28 deletions(-) create mode 100644 docs/samples/selectsample/description.md create mode 100644 docs/samples/selectsample/selectsample.hsp create mode 100644 docs/samples/selectsample/selectsample.spec.js create mode 100644 hsp/rt/attributes/select.js create mode 100644 test/rt/attributes/select.spec.hsp diff --git a/README.md b/README.md index d7d0cf0..6cd6a1c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ To work on the playground the most simple option is to open 2 terminal windows: - one running `grunt docs:playground` to build the playground and launch the webserver - another one running `grunt docs:watch` to watch the file changes and copy the changed files to the hashspace-gh-pages folder that is referenced by the webserver launched in the first terminal +Then you can use `http://localhost:8000?dev=true` in your favorite browser to get the development version of hashspace + [key_features_blog]: http://ariatemplates.com/blog/2012/11/key-features-for-client-side-templates/ [todomvc]: http://addyosmani.github.com/todomvc/ [angular]:http://angularjs.org/ diff --git a/docs/api/index.md b/docs/api/index.md index 0e11013..43c3034 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -42,7 +42,7 @@ It accepts 3 parameters: * `options {Object}` supported options are: * `includeSyntaxTree {Boolean}` if true, the result object will contain the syntax tree generated by the compiler. * `bypassJSvalidation {Boolean}` if true, the validation of the generated JS file (including non-template code) is bypassed - default:false. - * `mode {String}` the type of module system the code shold comply with: either "commonJS" or "global" + * `mode {String}` the type of module system the code shold comply with: either "commonJS" or "global" * `globalRef {String}` the name of the runtime global reference when the "global" mode is used (default: "hsp") @@ -50,7 +50,7 @@ The returned object contains: * `errors {Array}` the error list - each error having the following structure: * `description {String}` a message describing the error * `line {Number}` the error line number - * `column {Number}` the error column number + * `column {Number}` the error column number * `code {String}` a code extract showing where the error occurs (optional) * `code {String}` the generated JavaScript code * `syntaxTree {JSON}` the syntax tree generated by the parser (optional) @@ -424,6 +424,21 @@ Doing it this way also allows the engine to automatically detect and create butt ``` +##### Select + +Selects elements can be bound with the model or value attribute. +They can only take values available inside the option list. +Trying to set the data model to a value not existing in the options will have no effect (the value is unchanged) +so that the select value and the data model will always be synchronized. + +```html + +``` + --- #### Gestures event handlers attributes @@ -603,7 +618,7 @@ Hashspace natively supports different types of attributes: In order to use a `template` attribute, the template controller has to declare an attribute of such a type. -When the component content is defined, it is automatically bound to this attribute (if only a `template` attribute is defined), or to the only attribute having the defaultContent flag set to true. +When the component content is defined, it is automatically bound to this attribute (if only a `template` attribute is defined), or to the only attribute having the defaultContent flag set to true. --- @@ -836,15 +851,15 @@ var ClassC = klass({ $constructor : function (incr) { ClassB.$constructor.call(this); this.idx += incr; - } -}); + } +}); ``` #### Model updates ##### Object update -To implement data-binding, Hashspace reprocesses JavaScript to introduce a partial polyfill to Object.observe and detect changes that occur to JavaScript objects. Hashspace actually uses a transpiler to encapsulate assignments with an internal `$set()` method that performs the assignment and notifies the potential observers. +To implement data-binding, Hashspace reprocesses JavaScript to introduce a partial polyfill to Object.observe and detect changes that occur to JavaScript objects. Hashspace actually uses a transpiler to encapsulate assignments with an internal `$set()` method that performs the assignment and notifies the potential observers. In the mid-term the $set() utility will become obsolete, once the Object.observe feature is implemented by all web-browsers, and Hashspace will rely on the browser's Object.observe implementation. @@ -1030,7 +1045,7 @@ A test context is a function object that exposes the following properties and me It performs the assignment, notifies the potential observers and forces an hashspace refresh. - Parameters: + Parameters: * {Object} `container` the object that contains a property to be set * {String} `property` the property to be set * {Object} `value` the value to be assigned to the given property @@ -1045,7 +1060,7 @@ A test context is a function object that exposes the following properties and me This method returns an array (if the parameter is not specified), otherwise it returns the corresponding log message. - Parameters: + Parameters: * {integer} `idx` the position of the log message (first index = 0) Furthermore, the logs object exposes the following method: @@ -1055,11 +1070,11 @@ A test context is a function object that exposes the following properties and me #### Selector accessor `.(selector)` -Using the `TestContext` function it is possible to retrive an array of DOM elements, filtered according to the provided selector (as it is done in jQuery by means of the `$` object); it returns a `SelectionWrapper` object. +Using the `TestContext` function it is possible to retrive an array of DOM elements, filtered according to the provided selector (as it is done in jQuery by means of the `$` object); it returns a `SelectionWrapper` object. i.e: ```javascript -var HEAD = testCtxt(".panel .head"); +var HEAD = testCtxt(".panel .head"); ``` --- @@ -1072,7 +1087,7 @@ An instance of `SelectionWrapper` provides the following methods: It permits to further refine the selection by applying a new selector. - Parameters: + Parameters: * {String} `selector`: the selector expression (jquery selector syntax) @@ -1080,7 +1095,7 @@ An instance of `SelectionWrapper` provides the following methods: It returns the textual content of the selection (by recursively going through all DOM sub-nodes) and concatenates the different text node content. - Parameters: + Parameters: * {Boolean} `trim`: whether the returned text has to be trimmed - true by default @@ -1093,7 +1108,7 @@ An instance of `SelectionWrapper` provides the following methods: It returns the value of an attribute of the selected node (it only works on single-element selections). - Parameters: + Parameters: * {String} `attName` the name of the attribute - e.g. "title" @@ -1101,7 +1116,7 @@ An instance of `SelectionWrapper` provides the following methods: This method tells if the first element in the selection is assigned a specified CSS class. - Parameters: + Parameters: * {String} `cssClassName` the class name to check @@ -1109,7 +1124,7 @@ An instance of `SelectionWrapper` provides the following methods: It returns the selection corresponding to the nth element in the selection. - Parameters: + Parameters: * {integer} `idx` the position of the element (first index = 0) @@ -1127,7 +1142,7 @@ An instance of `SelectionWrapper` provides the following methods: It simulates a type event and triggers an hashspace refresh - Parameters: + Parameters: * {String} `text` the text to be typed diff --git a/docs/samples/inputonupdate/inputonupdate.spec.js b/docs/samples/inputonupdate/inputonupdate.spec.js index b05a9b8..b188d07 100644 --- a/docs/samples/inputonupdate/inputonupdate.spec.js +++ b/docs/samples/inputonupdate/inputonupdate.spec.js @@ -1,3 +1,4 @@ +var hsp=require("hsp/rt"); var sample = require('./inputonupdate.hsp'); var fireEvent = require("hsp/utils/eventgenerator").fireEvent; @@ -21,6 +22,7 @@ describe('"Input onupdate" sample', function () { expect(span.innerHTML).to.equal("Oninput result: "); input.value = "a"; fireEvent("keyup", input); + hsp.refresh(); clock.tick(300); expect(span.innerHTML).to.equal("Oninput result: "); @@ -39,6 +41,7 @@ describe('"Input onupdate" sample', function () { expect(span.innerHTML).to.equal("Oninput result: "); input.value = "a"; fireEvent("keyup", input); + hsp.refresh(); clock.tick(1500); expect(span.innerHTML).to.equal("Oninput result: "); @@ -57,6 +60,7 @@ describe('"Input onupdate" sample', function () { expect(span.innerHTML).to.equal("Oninput result: "); textarea.value = "a"; fireEvent("keyup", textarea); + hsp.refresh(); clock.tick(300); expect(span.innerHTML).to.equal("Oninput result: "); diff --git a/docs/samples/samples.js b/docs/samples/samples.js index 01a7a70..ffb9cee 100644 --- a/docs/samples/samples.js +++ b/docs/samples/samples.js @@ -118,6 +118,13 @@ module.exports = [{ src : "inputsample.hsp", main : true }] + }, { + title : "Select fields and options elements with bi-directional bindings", + folder : "selectsample", + files : [{ + src : "selectsample.hsp", + main : true + }] }, { title : "Multi-line inputs: textarea elements", folder : "textarea", diff --git a/docs/samples/selectsample/description.md b/docs/samples/selectsample/description.md new file mode 100644 index 0000000..cfecde0 --- /dev/null +++ b/docs/samples/selectsample/description.md @@ -0,0 +1,14 @@ + +Hashspace listens to the change event of the select elements in order to synchronize its value with the data referenced through the value or model expression. +The options can also be bound, and changing the options list can impact the select value. + +For example, in the following select, we can add or remove the fourth option. + - If we try to set the data model value to 'four' when the option doesn't exist, the select value and the data model will remain unchanged, + - If we remove the option 'four' when this one is selected, the value will be set automatically to the first one. + +[#output] + +**Note:** + - An invalid value (not existing in its options) can't be set in the data model. + - The options list can be change completely. In this case, a valid select value will be kept, otherwise, the first one will be selected. + diff --git a/docs/samples/selectsample/selectsample.hsp b/docs/samples/selectsample/selectsample.hsp new file mode 100644 index 0000000..834f584 --- /dev/null +++ b/docs/samples/selectsample/selectsample.hsp @@ -0,0 +1,84 @@ + + + \ No newline at end of file diff --git a/docs/samples/selectsample/selectsample.spec.js b/docs/samples/selectsample/selectsample.spec.js new file mode 100644 index 0000000..2f6d36a --- /dev/null +++ b/docs/samples/selectsample/selectsample.spec.js @@ -0,0 +1,19 @@ +var hashTester = require('hsp/utils/hashtester'); +var sample = require('./selectsample.hsp'); + +describe('"Select" sample', function () { + + var h; + beforeEach(function () { + h = hashTester.newTestContext(); + sample.template.apply(this, sample.data).render(h.container); + }); + + afterEach(function () { + h.$dispose(); + }); + + it('should render "Select"', function () { + + }); +}); \ No newline at end of file diff --git a/hsp/rt/attributes/onupdate.js b/hsp/rt/attributes/onupdate.js index a1a8000..af06822 100644 --- a/hsp/rt/attributes/onupdate.js +++ b/hsp/rt/attributes/onupdate.js @@ -29,7 +29,7 @@ var OnUpdateHandler = klass({ $setValue: function(name, value) { if (name === "update-timeout") { - var valueAsNumber = parseInt(value); + var valueAsNumber = parseInt(value, 10); if (!isNaN(valueAsNumber)) { this.timerValue = valueAsNumber; } diff --git a/hsp/rt/attributes/select.js b/hsp/rt/attributes/select.js new file mode 100644 index 0000000..3c96dbb --- /dev/null +++ b/hsp/rt/attributes/select.js @@ -0,0 +1,119 @@ +/* + * Copyright 2014 Amadeus s.a.s. + * 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. + */ + +/** + * Get the option value depending on its attributes or inner text + */ +var _getOptionValue = function(optionNode) { + var value = optionNode.getAttribute("value"); + if (value == null) { + value = optionNode.getAttribute("label"); + if (value == null) { + value = optionNode.innerText || optionNode.textContent; + } + optionNode.setAttribute("value", value); // To avoid issues on IE + } + + return value; +}; + +/** + * Get the selected value of a select + */ +var _getSelectedValue = function(selectNode) { + var options = selectNode.getElementsByTagName("option"); + var selectedIndex = selectNode.selectedIndex; + return selectedIndex > 0 ? _getOptionValue(options[selectedIndex]) : selectNode.value; +}; + +var klass = require("../../klass"); + +var SelectHandler = klass({ + $constructor : function (nodeInstance) { + this.nodeInstance = nodeInstance; + this.node = nodeInstance.node; + this._lastValues = {}; + this._selectEvents = ["change"]; + nodeInstance.addEventListeners(this._selectEvents); + }, + + $setValue: function (name, value) { + // Model changes, the value is stored in order to prioritize 'model' on 'value' + if (name == "model" || name == "value") { + this._lastValues[name] = value; + } + }, + + $onAttributesRefresh: function() { + var lastValues = this._lastValues; + var _boundName = this._boundName = lastValues["model"] == null ? "value" : "model"; + var lastValue = lastValues[_boundName]; + if (this._refreshDone && lastValue != _getSelectedValue(this.node)) { + this._synchronize(); + } + }, + + $onContentRefresh: function() { + this._synchronize(); + this._refreshDone = true; + }, + + $handleEvent : function (evt) { + // Change event, the model value must be handle + if (this._selectEvents.indexOf(evt.type) > -1) { + var value = _getSelectedValue(this.node); + var nodeInstance = this.nodeInstance; + var isSet = nodeInstance.setAttributeValueInModel("model", value); + if (!isSet) { + nodeInstance.setAttributeValueInModel("value", value); + } + } + }, + + + /** + * Synchronize the select value with the model, + * the model value is set to the select first, + * if it fails, the model value will be updated with the select value + */ + _synchronize : function() { + var _boundName = this._boundName; + var _boundValue = this.nodeInstance.getAttributeValueInModel(_boundName); + var node = this.node; + + // First, try to change the select value with the data model one + if (_getSelectedValue(node) != _boundValue) { + var selectedIndex = -1; + var options = node.getElementsByTagName("option"); + for(var i = 0; i < options.length; i++) { + var option = options[i]; + if (_getOptionValue(option) == _boundValue) { + selectedIndex = i; + break; + } + } + + if (selectedIndex != -1) { + node.selectedIndex = selectedIndex; + } else { + // Value not available in the options list, so the model needs to be synchronized + this.nodeInstance.setAttributeValueInModel(_boundName, _getSelectedValue(node)); + } + } + } + +}); + +module.exports = SelectHandler; \ No newline at end of file diff --git a/hsp/rt/eltnode.js b/hsp/rt/eltnode.js index 013f1d8..38ab04c 100644 --- a/hsp/rt/eltnode.js +++ b/hsp/rt/eltnode.js @@ -27,6 +27,8 @@ var ClassHandler = require('./attributes/class'); hsp.registerCustomAttributes("class", ClassHandler); var ModelValueHandler = require('./attributes/modelvalue'); hsp.registerCustomAttributes(["model", "value"], ModelValueHandler, 0, ["input", "textarea"]); +var SelectHandler = require('./attributes/select'); +hsp.registerCustomAttributes(["model", "value"], SelectHandler, 0, ["select"]); var OnUpdateHandler = require('./attributes/onupdate'); hsp.registerCustomAttributes(["onupdate", "update-timeout"], OnUpdateHandler, 0, ["input", "textarea"]); @@ -251,7 +253,11 @@ var EltNode = klass({ } } if (result === false) { - event.preventDefault(); + if (event.preventDefault) { + event.preventDefault(); + } else { + event.returnValue = false; + } } return result; }, @@ -384,6 +390,23 @@ var EltNode = klass({ /** API methods for custom attributes **/ + /** + * Get the attribute value in the data model. + * @param {String} name the name of the attribute + * @return {String} the value of the attribute. + */ + getAttributeValueInModel: function (name) { + if (this._custAttrData[name]) { + var exprIndex = this._custAttrData[name].exprIndex; + if (this.eh && typeof exprIndex !== "undefined") { + var expression = this.eh.getExpr(exprIndex); + if (expression.getValue) { + return expression.getValue(this.vscope, this.eh); + } + } + } + return null; + }, /** * Sets the attribute value in the data model. * @param {String} name the name of the attribute @@ -399,8 +422,6 @@ var EltNode = klass({ var currentValue = expression.getValue(this.vscope, this.eh); if (value !== currentValue) { expression.setValue(this.vscope, value); - // force refresh to resync other fields linked to the same data immediately - hsp.refresh(); return true; } } @@ -436,10 +457,9 @@ var EltNode = klass({ getAncestorByCustomAttribute: function(name) { var parent = this.parent; while (parent) { - if (parent._custAttrHandlers[name]) { + if (parent._custAttrHandlers && parent._custAttrHandlers[name]) { break; - } - else { + } else { parent = parent.parent; } } @@ -454,8 +474,10 @@ var EltNode = klass({ getCustomAttributeHandlers: function(name) { var result = []; var handlers = this._custAttrHandlers[name]; - for (var i = 0; i < handlers.length; i++) { - result.push(handlers[i].instance); + if (handlers) { + for (var i = 0; i < handlers.length; i++) { + result.push(handlers[i].instance); + } } return result; } diff --git a/hsp/rt/tnode.js b/hsp/rt/tnode.js index 83d2f66..60e1617 100644 --- a/hsp/rt/tnode.js +++ b/hsp/rt/tnode.js @@ -213,13 +213,13 @@ var TNode = klass({ this.adirty = false; // adirty should not be set to false anywhere else unless updateObjectObservers is not required } if (this.cdirty) { + this.cdirty = false; var cn = this.childNodes; if (cn) { for (var i = 0, sz = cn.length; sz > i; i++) { cn[i].refresh(); } } - this.cdirty = false; } }, @@ -358,7 +358,7 @@ var TNode = klass({ } else if (nd===n1) { process=true; } - + } } return null; diff --git a/test/rt/attributes/modelvalue.spec.hsp b/test/rt/attributes/modelvalue.spec.hsp index 1ffb75c..81a61a9 100644 --- a/test/rt/attributes/modelvalue.spec.hsp +++ b/test/rt/attributes/modelvalue.spec.hsp @@ -148,6 +148,7 @@ describe("Input Elements", function () { var v3 = "bar2"; input1.node.value = v3; fireEvent("keyup",input1.node); // to simulate change + hsp.refresh(); expect(input1.node.value).to.equal(v3); expect(input2.node.value).to.equal(v3); expect(d.comment).to.equal(v3); @@ -156,6 +157,7 @@ describe("Input Elements", function () { var v4 = "blah"; input2.node.value = v4; fireEvent("keyup",input2.node); // to simulate change + hsp.refresh(); expect(input1.node.value).to.equal(v4); expect(input2.node.value).to.equal(v4); expect(d.comment).to.equal(v4); @@ -187,6 +189,7 @@ describe("Input Elements", function () { // change from cb1 (value reference) fireEvent("click",cb1.node); + hsp.refresh(); if (cb1.node.checked) { // on firefox calling click doesn't trigger the onclick event!? expect(cb1.node.checked).to.equal(true); @@ -196,6 +199,7 @@ describe("Input Elements", function () { // change from cb2 (model reference) fireEvent("click",cb2.node); + hsp.refresh(); expect(cb1.node.checked).to.equal(false); expect(cb2.node.checked).to.equal(false); expect(d.isChecked).to.equal(false); @@ -259,7 +263,7 @@ describe("Textarea Elements", function () { // change value from the DOM h("textarea").type("AAA\nBBB\nCCC"); - expect(normalizeCR(model.text)).to.equal("AAA\nBBB\nCCC"); + expect(normalizeCR(model.text)).to.equal("AAA\nBBB\nCCC"); // change value from the model h.$set(model,"text","Hello\nWorld!"); diff --git a/test/rt/attributes/select.spec.hsp b/test/rt/attributes/select.spec.hsp new file mode 100644 index 0000000..5a22806 --- /dev/null +++ b/test/rt/attributes/select.spec.hsp @@ -0,0 +1,425 @@ + + + + + +