diff options
| author | Misko Hevery | 2011-09-08 13:56:29 -0700 |
|---|---|---|
| committer | Igor Minar | 2011-10-11 11:01:45 -0700 |
| commit | 4f78fd692c0ec51241476e6be9a4df06cd62fdd6 (patch) | |
| tree | 91f70bb89b9c095126fbc093f51cedbac5cb0c78 /src | |
| parent | df6d2ba3266de405ad6c2f270f24569355706e76 (diff) | |
| download | angular.js-4f78fd692c0ec51241476e6be9a4df06cd62fdd6.tar.bz2 | |
feat(forms): new and improved forms
Diffstat (limited to 'src')
| -rw-r--r-- | src/Angular.js | 105 | ||||
| -rw-r--r-- | src/Browser.js | 10 | ||||
| -rw-r--r-- | src/Scope.js | 3 | ||||
| -rw-r--r-- | src/angular-bootstrap.js | 3 | ||||
| -rw-r--r-- | src/apis.js | 173 | ||||
| -rw-r--r-- | src/directives.js | 86 | ||||
| -rw-r--r-- | src/filters.js | 113 | ||||
| -rw-r--r-- | src/formatters.js | 202 | ||||
| -rw-r--r-- | src/jqLite.js | 15 | ||||
| -rw-r--r-- | src/markups.js | 24 | ||||
| -rw-r--r-- | src/parser.js | 43 | ||||
| -rw-r--r-- | src/scenario/Scenario.js | 2 | ||||
| -rw-r--r-- | src/scenario/dsl.js | 22 | ||||
| -rw-r--r-- | src/service/formFactory.js | 394 | ||||
| -rw-r--r-- | src/service/invalidWidgets.js | 69 | ||||
| -rw-r--r-- | src/service/log.js | 3 | ||||
| -rw-r--r-- | src/service/resource.js | 3 | ||||
| -rw-r--r-- | src/service/route.js | 6 | ||||
| -rw-r--r-- | src/service/window.js | 2 | ||||
| -rw-r--r-- | src/service/xhr.js | 5 | ||||
| -rw-r--r-- | src/validators.js | 482 | ||||
| -rw-r--r-- | src/widget/form.js | 81 | ||||
| -rw-r--r-- | src/widget/input.js | 773 | ||||
| -rw-r--r-- | src/widget/select.js | 427 | ||||
| -rw-r--r-- | src/widgets.js | 1033 |
25 files changed, 2104 insertions, 1975 deletions
diff --git a/src/Angular.js b/src/Angular.js index caa51a06..7c218c6e 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -55,7 +55,6 @@ function fromCharCode(code) { return String.fromCharCode(code); } var _undefined = undefined, _null = null, $$scope = '$scope', - $$validate = '$validate', $angular = 'angular', $array = 'array', $boolean = 'boolean', @@ -93,12 +92,10 @@ var _undefined = undefined, angularDirective = extensionMap(angular, 'directive'), /** @name angular.widget */ angularWidget = extensionMap(angular, 'widget', lowercase), - /** @name angular.validator */ - angularValidator = extensionMap(angular, 'validator'), - /** @name angular.fileter */ + /** @name angular.filter */ angularFilter = extensionMap(angular, 'filter'), - /** @name angular.formatter */ - angularFormatter = extensionMap(angular, 'formatter'), + /** @name angular.service */ + angularInputType = extensionMap(angular, 'inputType', lowercase), /** @name angular.service */ angularService = extensionMap(angular, 'service'), angularCallbacks = extensionMap(angular, 'callbacks'), @@ -156,10 +153,18 @@ function forEach(obj, iterator, context) { return obj; } -function forEachSorted(obj, iterator, context) { +function sortedKeys(obj) { var keys = []; - for (var key in obj) keys.push(key); - keys.sort(); + for (var key in obj) { + if (obj.hasOwnProperty(key)) { + keys.push(key); + } + } + return keys.sort(); +} + +function forEachSorted(obj, iterator, context) { + var keys = sortedKeys(obj) for ( var i = 0; i < keys.length; i++) { iterator.call(context, obj[keys[i]], keys[i]); } @@ -180,7 +185,6 @@ function formatError(arg) { } /** - * @description * A consistent way of creating unique IDs in angular. The ID is a sequence of alpha numeric * characters such as '012ABC'. The reason why we are not using simply a number counter is that * the number string gets longer over time, and it can also overflow, where as the the nextId @@ -599,20 +603,33 @@ function isLeafNode (node) { * @example * <doc:example> * <doc:source> - Salutation: <input type="text" name="master.salutation" value="Hello" /><br/> - Name: <input type="text" name="master.name" value="world"/><br/> - <button ng:click="form = master.$copy()">copy</button> - <hr/> - - The master object is <span ng:hide="master.$equals(form)">NOT</span> equal to the form object. - - <pre>master={{master}}</pre> - <pre>form={{form}}</pre> + <script> + function Ctrl(){ + this.master = { + salutation: 'Hello', + name: 'world' + }; + this.copy = function (){ + this.form = angular.copy(this.master); + } + } + </script> + <div ng:controller="Ctrl"> + Salutation: <input type="text" ng:model="master.salutation" ><br/> + Name: <input type="text" ng:model="master.name"><br/> + <button ng:click="copy()">copy</button> + <hr/> + + The master object is <span ng:hide="master.$equals(form)">NOT</span> equal to the form object. + + <pre>master={{master}}</pre> + <pre>form={{form}}</pre> + </div> * </doc:source> * <doc:scenario> it('should print that initialy the form object is NOT equal to master', function() { - expect(element('.doc-example-live input[name="master.salutation"]').val()).toBe('Hello'); - expect(element('.doc-example-live input[name="master.name"]').val()).toBe('world'); + expect(element('.doc-example-live input[ng\\:model="master.salutation"]').val()).toBe('Hello'); + expect(element('.doc-example-live input[ng\\:model="master.name"]').val()).toBe('world'); expect(element('.doc-example-live span').css('display')).toBe('inline'); }); @@ -691,20 +708,31 @@ function copy(source, destination){ * @example * <doc:example> * <doc:source> - Salutation: <input type="text" name="greeting.salutation" value="Hello" /><br/> - Name: <input type="text" name="greeting.name" value="world"/><br/> - <hr/> - - The <code>greeting</code> object is - <span ng:hide="greeting.$equals({salutation:'Hello', name:'world'})">NOT</span> equal to - <code>{salutation:'Hello', name:'world'}</code>. - - <pre>greeting={{greeting}}</pre> + <script> + function Ctrl(){ + this.master = { + salutation: 'Hello', + name: 'world' + }; + this.greeting = angular.copy(this.master); + } + </script> + <div ng:controller="Ctrl"> + Salutation: <input type="text" ng:model="greeting.salutation"><br/> + Name: <input type="text" ng:model="greeting.name"><br/> + <hr/> + + The <code>greeting</code> object is + <span ng:hide="greeting.$equals(master)">NOT</span> equal to + <code>{salutation:'Hello', name:'world'}</code>. + + <pre>greeting={{greeting}}</pre> + </div> * </doc:source> * <doc:scenario> it('should print that initialy greeting is equal to the hardcoded value object', function() { - expect(element('.doc-example-live input[name="greeting.salutation"]').val()).toBe('Hello'); - expect(element('.doc-example-live input[name="greeting.name"]').val()).toBe('world'); + expect(element('.doc-example-live input[ng\\:model="greeting.salutation"]').val()).toBe('Hello'); + expect(element('.doc-example-live input[ng\\:model="greeting.name"]').val()).toBe('world'); expect(element('.doc-example-live span').css('display')).toBe('none'); }); @@ -915,24 +943,19 @@ function angularInit(config, document){ if (config.css) $browser.addCss(config.base_url + config.css); - else if(msie<8) - $browser.addJs(config.ie_compat, config.ie_compat_id); scope.$apply(); } } -function angularJsConfig(document, config) { +function angularJsConfig(document) { bindJQuery(); var scripts = document.getElementsByTagName("script"), + config = {}, match; - config = extend({ - ie_compat_id: 'ng-ie-compat' - }, config); for(var j = 0; j < scripts.length; j++) { match = (scripts[j].src || "").match(rngScript); if (match) { config.base_url = match[1]; - config.ie_compat = match[1] + 'angular-ie-compat' + (match[2] || '') + '.js'; extend(config, parseKeyValue(match[6])); eachAttribute(jqLite(scripts[j]), function(value, name){ if (/^ng:/.exec(name)) { @@ -974,11 +997,13 @@ function assertArg(arg, name, reason) { (reason || "required")); throw error; } + return arg; } function assertArgFn(arg, name) { - assertArg(isFunction(arg), name, 'not a function, got ' + + assertArg(isFunction(arg), name, 'not a function, got ' + (typeof arg == 'object' ? arg.constructor.name : typeof arg)); + return arg; } diff --git a/src/Browser.js b/src/Browser.js index ed12441a..77d1c684 100644 --- a/src/Browser.js +++ b/src/Browser.js @@ -105,7 +105,7 @@ function Browser(window, document, body, XHR, $log, $sniffer) { window[callbackId].data = data; }; - var script = self.addJs(url.replace('JSON_CALLBACK', callbackId), null, function() { + var script = self.addJs(url.replace('JSON_CALLBACK', callbackId), function() { if (window[callbackId].data) { completeOutstandingRequest(callback, 200, window[callbackId].data); } else { @@ -442,24 +442,18 @@ function Browser(window, document, body, XHR, $log, $sniffer) { * @methodOf angular.service.$browser * * @param {string} url Url to js file - * @param {string=} domId Optional id for the script tag * * @description * Adds a script tag to the head. */ - self.addJs = function(url, domId, done) { + self.addJs = function(url, done) { // we can't use jQuery/jqLite here because jQuery does crazy shit with script elements, e.g.: // - fetches local scripts via XHR and evals them // - adds and immediately removes script elements from the document - // - // We need addJs to be able to add angular-ie-compat.js which is very special and must remain - // part of the DOM so that the embedded images can reference it. jQuery's append implementation - // (v1.4.2) fubars it. var script = rawDocument.createElement('script'); script.type = 'text/javascript'; script.src = url; - if (domId) script.id = domId; if (msie) { script.onreadystatechange = function() { diff --git a/src/Scope.js b/src/Scope.js index c5f5bf1b..e4fc0622 100644 --- a/src/Scope.js +++ b/src/Scope.js @@ -354,7 +354,8 @@ Scope.prototype = { // circuit it with === operator, only when === fails do we use .equals if ((value = watch.get(current)) !== (last = watch.last) && !equals(value, last)) { dirty = true; - watch.fn(current, watch.last = copy(value), last); + watch.last = copy(value); + watch.fn(current, value, last); } } catch (e) { current.$service('$exceptionHandler')(e); diff --git a/src/angular-bootstrap.js b/src/angular-bootstrap.js index f9d9643d..7a1752d2 100644 --- a/src/angular-bootstrap.js +++ b/src/angular-bootstrap.js @@ -101,9 +101,6 @@ var config = angularJsConfig(document); - // angular-ie-compat.js needs to be pregenerated for development with IE<8 - config.ie_compat = serverPath + '../build/angular-ie-compat.js'; - angularInit(config, document); } diff --git a/src/apis.js b/src/apis.js index bec54b8e..6a5bf6c4 100644 --- a/src/apis.js +++ b/src/apis.js @@ -103,9 +103,16 @@ var angularArray = { * @example <doc:example> <doc:source> - <div ng:init="books = ['Moby Dick', 'Great Gatsby', 'Romeo and Juliet']"></div> - <input name='bookName' value='Romeo and Juliet'> <br> - Index of '{{bookName}}' in the list {{books}} is <em>{{books.$indexOf(bookName)}}</em>. + <script> + function Ctrl(){ + this.books = ['Moby Dick', 'Great Gatsby', 'Romeo and Juliet']; + this.bookName = 'Romeo and Juliet'; + } + </script> + <div ng:controller="Ctrl"> + <input ng:model='bookName'> <br> + Index of '{{bookName}}' in the list {{books}} is <em>{{books.$indexOf(bookName)}}</em>. + </div> </doc:source> <doc:scenario> it('should correctly calculate the initial index', function() { @@ -146,17 +153,29 @@ var angularArray = { * @example <doc:example> <doc:source> - <table ng:init="invoice= {items:[{qty:10, description:'gadget', cost:9.95}]}"> + <script> + function Ctrl(){ + this.invoice = { + items:[ { + qty:10, + description:'gadget', + cost:9.95 + } + ] + }; + } + </script> + <table class="invoice" ng:controller="Ctrl"> <tr><th>Qty</th><th>Description</th><th>Cost</th><th>Total</th><th></th></tr> <tr ng:repeat="item in invoice.items"> - <td><input name="item.qty" value="1" size="4" ng:required ng:validate="integer"></td> - <td><input name="item.description"></td> - <td><input name="item.cost" value="0.00" ng:required ng:validate="number" size="6"></td> + <td><input type="integer" ng:model="item.qty" size="4" required></td> + <td><input type="text" ng:model="item.description"></td> + <td><input type="number" ng:model="item.cost" required size="6"></td> <td>{{item.qty * item.cost | currency}}</td> <td>[<a href ng:click="invoice.items.$remove(item)">X</a>]</td> </tr> <tr> - <td><a href ng:click="invoice.items.$add()">add item</a></td> + <td><a href ng:click="invoice.items.$add({qty:1, cost:0})">add item</a></td> <td></td> <td>Total:</td> <td>{{invoice.items.$sum('qty*cost') | currency}}</td> @@ -166,8 +185,8 @@ var angularArray = { <doc:scenario> //TODO: these specs are lame because I had to work around issues #164 and #167 it('should initialize and calculate the totals', function() { - expect(repeater('.doc-example-live table tr', 'item in invoice.items').count()).toBe(3); - expect(repeater('.doc-example-live table tr', 'item in invoice.items').row(1)). + expect(repeater('table.invoice tr', 'item in invoice.items').count()).toBe(3); + expect(repeater('table.invoice tr', 'item in invoice.items').row(1)). toEqual(['$99.50']); expect(binding("invoice.items.$sum('qty*cost')")).toBe('$99.50'); expect(binding("invoice.items.$sum('qty*cost')")).toBe('$99.50'); @@ -178,7 +197,7 @@ var angularArray = { using('.doc-example-live tr:nth-child(3)').input('item.qty').enter('20'); using('.doc-example-live tr:nth-child(3)').input('item.cost').enter('100'); - expect(repeater('.doc-example-live table tr', 'item in invoice.items').row(2)). + expect(repeater('table.invoice tr', 'item in invoice.items').row(2)). toEqual(['$2,000.00']); expect(binding("invoice.items.$sum('qty*cost')")).toBe('$2,099.50'); }); @@ -297,7 +316,7 @@ var angularArray = { {name:'Adam', phone:'555-5678'}, {name:'Julie', phone:'555-8765'}]"></div> - Search: <input name="searchText"/> + Search: <input ng:model="searchText"/> <table id="searchTextResults"> <tr><th>Name</th><th>Phone</th><tr> <tr ng:repeat="friend in friends.$filter(searchText)"> @@ -306,9 +325,9 @@ var angularArray = { <tr> </table> <hr> - Any: <input name="search.$"/> <br> - Name only <input name="search.name"/><br> - Phone only <input name="search.phone"/><br> + Any: <input ng:model="search.$"/> <br> + Name only <input ng:model="search.name"/><br> + Phone only <input ng:model="search.phone"/><br> <table id="searchObjResults"> <tr><th>Name</th><th>Phone</th><tr> <tr ng:repeat="friend in friends.$filter(search)"> @@ -442,22 +461,29 @@ var angularArray = { * with objects created from user input. <doc:example> <doc:source> - [<a href="" ng:click="people.$add()">add empty</a>] - [<a href="" ng:click="people.$add({name:'John', sex:'male'})">add 'John'</a>] - [<a href="" ng:click="people.$add({name:'Mary', sex:'female'})">add 'Mary'</a>] - - <ul ng:init="people=[]"> - <li ng:repeat="person in people"> - <input name="person.name"> - <select name="person.sex"> - <option value="">--chose one--</option> - <option>male</option> - <option>female</option> - </select> - [<a href="" ng:click="people.$remove(person)">X</a>] - </li> - </ul> - <pre>people = {{people}}</pre> + <script> + function Ctrl(){ + this.people = []; + } + </script> + <div ng:controller="Ctrl"> + [<a href="" ng:click="people.$add()">add empty</a>] + [<a href="" ng:click="people.$add({name:'John', sex:'male'})">add 'John'</a>] + [<a href="" ng:click="people.$add({name:'Mary', sex:'female'})">add 'Mary'</a>] + + <ul> + <li ng:repeat="person in people"> + <input ng:model="person.name"> + <select ng:model="person.sex"> + <option value="">--chose one--</option> + <option>male</option> + <option>female</option> + </select> + [<a href="" ng:click="people.$remove(person)">X</a>] + </li> + </ul> + <pre>people = {{people}}</pre> + </div> </doc:source> <doc:scenario> beforeEach(function() { @@ -466,7 +492,7 @@ var angularArray = { it('should create an empty record when "add empty" is clicked', function() { element('.doc-example-live a:contains("add empty")').click(); - expect(binding('people')).toBe('people = [{\n "name":"",\n "sex":null}]'); + expect(binding('people')).toBe('people = [{\n }]'); }); it('should create a "John" record when "add \'John\'" is clicked', function() { @@ -521,7 +547,7 @@ var angularArray = { <ul> <li ng:repeat="item in items"> {{item.name}}: points= - <input type="text" name="item.points"/> <!-- id="item{{$index}} --> + <input type="text" ng:model="item.points"/> <!-- id="item{{$index}} --> </li> </ul> <p>Number of items which have one point: <em>{{ items.$count('points==1') }}</em></p> @@ -585,49 +611,56 @@ var angularArray = { * @example <doc:example> <doc:source> - <div ng:init="friends = [{name:'John', phone:'555-1212', age:10}, - {name:'Mary', phone:'555-9876', age:19}, - {name:'Mike', phone:'555-4321', age:21}, - {name:'Adam', phone:'555-5678', age:35}, - {name:'Julie', phone:'555-8765', age:29}]"></div> - - <pre>Sorting predicate = {{predicate}}; reverse = {{reverse}}</pre> - <hr/> - [ <a href="" ng:click="predicate=''">unsorted</a> ] - <table ng:init="predicate='-age'"> - <tr> - <th><a href="" ng:click="predicate = 'name'; reverse=false">Name</a> - (<a href ng:click="predicate = '-name'; reverse=false">^</a>)</th> - <th><a href="" ng:click="predicate = 'phone'; reverse=!reverse">Phone Number</a></th> - <th><a href="" ng:click="predicate = 'age'; reverse=!reverse">Age</a></th> - <tr> - <tr ng:repeat="friend in friends.$orderBy(predicate, reverse)"> - <td>{{friend.name}}</td> - <td>{{friend.phone}}</td> - <td>{{friend.age}}</td> - <tr> - </table> + <script> + function Ctrl(){ + this.friends = + [{name:'John', phone:'555-1212', age:10}, + {name:'Mary', phone:'555-9876', age:19}, + {name:'Mike', phone:'555-4321', age:21}, + {name:'Adam', phone:'555-5678', age:35}, + {name:'Julie', phone:'555-8765', age:29}] + this.predicate = '-age'; + } + </script> + <div ng:controller="Ctrl"> + <pre>Sorting predicate = {{predicate}}; reverse = {{reverse}}</pre> + <hr/> + [ <a href="" ng:click="predicate=''">unsorted</a> ] + <table class="friend"> + <tr> + <th><a href="" ng:click="predicate = 'name'; reverse=false">Name</a> + (<a href ng:click="predicate = '-name'; reverse=false">^</a>)</th> + <th><a href="" ng:click="predicate = 'phone'; reverse=!reverse">Phone Number</a></th> + <th><a href="" ng:click="predicate = 'age'; reverse=!reverse">Age</a></th> + <tr> + <tr ng:repeat="friend in friends.$orderBy(predicate, reverse)"> + <td>{{friend.name}}</td> + <td>{{friend.phone}}</td> + <td>{{friend.age}}</td> + <tr> + </table> + </div> </doc:source> <doc:scenario> it('should be reverse ordered by aged', function() { expect(binding('predicate')).toBe('Sorting predicate = -age; reverse = '); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.age')). + expect(repeater('table.friend', 'friend in friends').column('friend.age')). toEqual(['35', '29', '21', '19', '10']); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.name')). + expect(repeater('table.friend', 'friend in friends').column('friend.name')). toEqual(['Adam', 'Julie', 'Mike', 'Mary', 'John']); }); it('should reorder the table when user selects different predicate', function() { element('.doc-example-live a:contains("Name")').click(); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.name')). + expect(repeater('table.friend', 'friend in friends').column('friend.name')). toEqual(['Adam', 'John', 'Julie', 'Mary', 'Mike']); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.age')). + expect(repeater('table.friend', 'friend in friends').column('friend.age')). toEqual(['35', '10', '29', '19', '21']); element('.doc-example-live a:contains("Phone")').click(); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.phone')). + expect(repeater('table.friend', 'friend in friends').column('friend.phone')). toEqual(['555-9876', '555-8765', '555-5678', '555-4321', '555-1212']); - expect(repeater('.doc-example-live table', 'friend in friends').column('friend.name')). + expect(repeater('table.friend', 'friend in friends').column('friend.name')). toEqual(['Mary', 'Julie', 'Adam', 'Mike', 'John']); }); </doc:scenario> @@ -704,14 +737,20 @@ var angularArray = { * @example <doc:example> <doc:source> - <div ng:init="numbers = [1,2,3,4,5,6,7,8,9]"> - Limit [1,2,3,4,5,6,7,8,9] to: <input name="limit" value="3"/> + <script> + function Ctrl(){ + this.numbers = [1,2,3,4,5,6,7,8,9]; + this.limit = 3; + } + </script> + <div ng:controller="Ctrl"> + Limit {{numbers}} to: <input type="integer" ng:model="limit"/> <p>Output: {{ numbers.$limitTo(limit) | json }}</p> </div> </doc:source> <doc:scenario> it('should limit the numer array to first three items', function() { - expect(element('.doc-example-live input[name=limit]').val()).toBe('3'); + expect(element('.doc-example-live input[ng\\:model=limit]').val()).toBe('3'); expect(binding('numbers.$limitTo(limit) | json')).toEqual('[1,2,3]'); }); @@ -840,7 +879,7 @@ var angularFunction = { * Hash of a: * string is string * number is number as string - * object is either result of calling $$hashKey function on the object or uniquely generated id, + * object is either result of calling $$hashKey function on the object or uniquely generated id, * that is also assigned to the $$hashKey property of the object. * * @param obj @@ -864,7 +903,9 @@ function hashKey(obj) { /** * HashMap which can use objects as keys */ -function HashMap(){} +function HashMap(array){ + forEach(array, this.put, this); +} HashMap.prototype = { /** * Store key value pair diff --git a/src/directives.js b/src/directives.js index dd67ddc7..852d04cd 100644 --- a/src/directives.js +++ b/src/directives.js @@ -19,8 +19,6 @@ * to `ng:bind`, but uses JSON key / value pairs to do so. * * {@link angular.directive.ng:bind-template ng:bind-template} - Replaces the text value of an * element with a specified template. - * * {@link angular.directive.ng:change ng:change} - Executes an expression when the value of an - * input widget changes. * * {@link angular.directive.ng:class ng:class} - Conditionally set a CSS class on an element. * * {@link angular.directive.ng:class-even ng:class-even} - Like `ng:class`, but works in * conjunction with {@link angular.widget.@ng:repeat} to affect even rows in a collection. @@ -133,16 +131,16 @@ angularDirective("ng:init", function(expression){ }; </script> <div ng:controller="SettingsController"> - Name: <input type="text" name="name"/> + Name: <input type="text" ng:model="name"/> [ <a href="" ng:click="greet()">greet</a> ]<br/> Contact: <ul> <li ng:repeat="contact in contacts"> - <select name="contact.type"> + <select ng:model="contact.type"> <option>phone</option> <option>email</option> </select> - <input type="text" name="contact.value"/> + <input type="text" ng:model="contact.value"/> [ <a href="" ng:click="clearContact(contact)">clear</a> | <a href="" ng:click="removeContact(contact)">X</a> ] </li> @@ -153,16 +151,16 @@ angularDirective("ng:init", function(expression){ <doc:scenario> it('should check controller', function(){ expect(element('.doc-example-live div>:input').val()).toBe('John Smith'); - expect(element('.doc-example-live li[ng\\:repeat-index="0"] input').val()) + expect(element('.doc-example-live li:nth-child(1) input').val()) .toBe('408 555 1212'); - expect(element('.doc-example-live li[ng\\:repeat-index="1"] input').val()) + expect(element('.doc-example-live li:nth-child(2) input').val()) .toBe('john.smith@example.org'); element('.doc-example-live li:first a:contains("clear")').click(); expect(element('.doc-example-live li:first input').val()).toBe(''); element('.doc-example-live li:last a:contains("add")').click(); - expect(element('.doc-example-live li[ng\\:repeat-index="2"] input').val()) + expect(element('.doc-example-live li:nth-child(3) input').val()) .toBe('yourname@example.org'); }); </doc:scenario> @@ -200,8 +198,15 @@ angularDirective("ng:controller", function(expression){ * Enter a name in the Live Preview text box; the greeting below the text box changes instantly. <doc:example> <doc:source> - Enter name: <input type="text" name="name" value="Whirled"> <br> - Hello <span ng:bind="name"></span>! + <script> + function Ctrl(){ + this.name = 'Whirled'; + } + </script> + <div ng:controller="Ctrl"> + Enter name: <input type="text" ng:model="name"> <br/> + Hello <span ng:bind="name"></span>! + </div> </doc:source> <doc:scenario> it('should check ng:bind', function(){ @@ -320,9 +325,17 @@ function compileBindTemplate(template){ * Try it here: enter text in text box and watch the greeting change. <doc:example> <doc:source> - Salutation: <input type="text" name="salutation" value="Hello"><br/> - Name: <input type="text" name="name" value="World"><br/> - <pre ng:bind-template="{{salutation}} {{name}}!"></pre> + <script> + function Ctrl(){ + this.salutation = 'Hello'; + this.name = 'World'; + } + </script> + <div ng:controller="Ctrl"> + Salutation: <input type="text" ng:model="salutation"><br/> + Name: <input type="text" ng:model="name"><br/> + <pre ng:bind-template="{{salutation}} {{name}}!"></pre> + </div> </doc:source> <doc:scenario> it('should check ng:bind', function(){ @@ -351,13 +364,6 @@ angularDirective("ng:bind-template", function(expression, element){ }; }); -var REMOVE_ATTRIBUTES = { - 'disabled':'disabled', - 'readonly':'readOnly', - 'checked':'checked', - 'selected':'selected', - 'multiple':'multiple' -}; /** * @ngdoc directive * @name angular.directive.ng:bind-attr @@ -395,9 +401,16 @@ var REMOVE_ATTRIBUTES = { * Enter a search string in the Live Preview text box and then click "Google". The search executes instantly. <doc:example> <doc:source> - Google for: - <input type="text" name="query" value="AngularJS"/> - <a href="http://www.google.com/search?q={{query}}">Google</a> + <script> + function Ctrl(){ + this.query = 'AngularJS'; + } + </script> + <div ng:controller="Ctrl"> + Google for: + <input type="text" ng:model="query"/> + <a href="http://www.google.com/search?q={{query}}">Google</a> + </div> </doc:source> <doc:scenario> it('should check ng:bind-attr', function(){ @@ -417,18 +430,15 @@ angularDirective("ng:bind-attr", function(expression){ var values = scope.$eval(expression); for(var key in values) { var value = compileBindTemplate(values[key])(scope, element), - specialName = REMOVE_ATTRIBUTES[lowercase(key)]; + specialName = BOOLEAN_ATTR[lowercase(key)]; if (lastValue[key] !== value) { lastValue[key] = value; if (specialName) { if (toBoolean(value)) { element.attr(specialName, specialName); - element.attr('ng-' + specialName, value); } else { element.removeAttr(specialName); - element.removeAttr('ng-' + specialName); } - (element.data($$validate)||noop)(); } else { element.attr(key, value); } @@ -505,12 +515,22 @@ angularDirective("ng:click", function(expression, element){ * @example <doc:example> <doc:source> - <form ng:submit="list.push(text);text='';" ng:init="list=[]"> + <script> + function Ctrl(){ + this.list = []; + this.text = 'hello'; + this.submit = function(){ + this.list.push(this.text); + this.text = ''; + }; + } + </script> + <form ng:submit="submit()" ng:controller="Ctrl"> Enter text and hit enter: - <input type="text" name="text" value="hello"/> + <input type="text" ng:model="text"/> <input type="submit" id="submit" value="Submit" /> + <pre>list={{list}}</pre> </form> - <pre>list={{list}}</pre> </doc:source> <doc:scenario> it('should check ng:submit', function(){ @@ -537,7 +557,7 @@ function ngClass(selector) { return function(element) { this.$watch(expression, function(scope, newVal, oldVal) { if (selector(scope.$index)) { - element.removeClass(isArray(oldVal) ? oldVal.join(' ') : oldVal) + element.removeClass(isArray(oldVal) ? oldVal.join(' ') : oldVal); element.addClass(isArray(newVal) ? newVal.join(' ') : newVal); } }); @@ -689,7 +709,7 @@ angularDirective("ng:class-even", ngClass(function(i){return i % 2 === 1;})); * @example <doc:example> <doc:source> - Click me: <input type="checkbox" name="checked"><br/> + Click me: <input type="checkbox" ng:model="checked"><br/> Show: <span ng:show="checked">I show up when your checkbox is checked.</span> <br/> Hide: <span ng:hide="checked">I hide when your checkbox is checked.</span> </doc:source> @@ -730,7 +750,7 @@ angularDirective("ng:show", function(expression, element){ * @example <doc:example> <doc:source> - Click me: <input type="checkbox" name="checked"><br/> + Click me: <input type="checkbox" ng:model="checked"><br/> Show: <span ng:show="checked">I show up when you checkbox is checked?</span> <br/> Hide: <span ng:hide="checked">I hide when you checkbox is checked?</span> </doc:source> diff --git a/src/filters.js b/src/filters.js index c5d886ea..0fcd442b 100644 --- a/src/filters.js +++ b/src/filters.js @@ -48,9 +48,16 @@ * @example <doc:example> <doc:source> - <input type="text" name="amount" value="1234.56"/> <br/> - default currency symbol ($): {{amount | currency}}<br/> - custom currency identifier (USD$): {{amount | currency:"USD$"}} + <script> + function Ctrl(){ + this.amount = 1234.56; + } + </script> + <div ng:controller="Ctrl"> + <input type="number" ng:model="amount"/> <br/> + default currency symbol ($): {{amount | currency}}<br/> + custom currency identifier (USD$): {{amount | currency:"USD$"}} + </div> </doc:source> <doc:scenario> it('should init with 1234.56', function(){ @@ -93,10 +100,17 @@ angularFilter.currency = function(amount, currencySymbol){ * @example <doc:example> <doc:source> - Enter number: <input name='val' value='1234.56789' /><br/> - Default formatting: {{val | number}}<br/> - No fractions: {{val | number:0}}<br/> - Negative number: {{-val | number:4}} + <script> + function Ctrl(){ + this.val = 1234.56789; + } + </script> + <div ng:controller="Ctrl"> + Enter number: <input ng:model='val'><br/> + Default formatting: {{val | number}}<br/> + No fractions: {{val | number:0}}<br/> + Negative number: {{-val | number:4}} + </div> </doc:source> <doc:scenario> it('should format numbers', function(){ @@ -462,36 +476,43 @@ angularFilter.uppercase = uppercase; * @example <doc:example> <doc:source> - Snippet: <textarea name="snippet" cols="60" rows="3"> - <p style="color:blue">an html - <em onmouseover="this.textContent='PWN3D!'">click here</em> - snippet</p></textarea> - <table> - <tr> - <td>Filter</td> - <td>Source</td> - <td>Rendered</td> - </tr> - <tr id="html-filter"> - <td>html filter</td> - <td> - <pre><div ng:bind="snippet | html"><br/></div></pre> - </td> - <td> - <div ng:bind="snippet | html"></div> - </td> - </tr> - <tr id="escaped-html"> - <td>no filter</td> - <td><pre><div ng:bind="snippet"><br/></div></pre></td> - <td><div ng:bind="snippet"></div></td> - </tr> - <tr id="html-unsafe-filter"> - <td>unsafe html filter</td> - <td><pre><div ng:bind="snippet | html:'unsafe'"><br/></div></pre></td> - <td><div ng:bind="snippet | html:'unsafe'"></div></td> - </tr> - </table> + <script> + function Ctrl(){ + this.snippet = + '<p style="color:blue">an html\n' + + '<em onmouseover="this.textContent=\'PWN3D!\'">click here</em>\n' + + 'snippet</p>'; + } + </script> + <div ng:controller="Ctrl"> + Snippet: <textarea ng:model="snippet" cols="60" rows="3"></textarea> + <table> + <tr> + <td>Filter</td> + <td>Source</td> + <td>Rendered</td> + </tr> + <tr id="html-filter"> + <td>html filter</td> + <td> + <pre><div ng:bind="snippet | html"><br/></div></pre> + </td> + <td> + <div ng:bind="snippet | html"></div> + </td> + </tr> + <tr id="escaped-html"> + <td>no filter</td> + <td><pre><div ng:bind="snippet"><br/></div></pre></td> + <td><div ng:bind="snippet"></div></td> + </tr> + <tr id="html-unsafe-filter"> + <td>unsafe html filter</td> + <td><pre><div ng:bind="snippet | html:'unsafe'"><br/></div></pre></td> + <td><div ng:bind="snippet | html:'unsafe'"></div></td> + </tr> + </table> + </div> </doc:source> <doc:scenario> it('should sanitize the html snippet ', function(){ @@ -543,12 +564,18 @@ angularFilter.html = function(html, option){ * @example <doc:example> <doc:source> - Snippet: <textarea name="snippet" cols="60" rows="3"> - Pretty text with some links: - http://angularjs.org/, - mailto:us@somewhere.org, - another@somewhere.org, - and one more: ftp://127.0.0.1/.</textarea> + <script> + function Ctrl(){ + this.snippet = + 'Pretty text with some links:\n'+ + 'http://angularjs.org/,\n'+ + 'mailto:us@somewhere.org,\n'+ + 'another@somewhere.org,\n'+ + 'and one more: ftp://127.0.0.1/.'; + } + </script> + <div ng:controller="Ctrl"> + Snippet: <textarea ng:model="snippet" cols="60" rows="3"></textarea> <table> <tr> <td>Filter</td> diff --git a/src/formatters.js b/src/formatters.js deleted file mode 100644 index 2fadc9d7..00000000 --- a/src/formatters.js +++ /dev/null @@ -1,202 +0,0 @@ -'use strict'; - -/** - * @workInProgress - * @ngdoc overview - * @name angular.formatter - * @description - * - * Formatters are used for translating data formats between those used for display and those used - * for storage. - * - * Following is the list of built-in angular formatters: - * - * * {@link angular.formatter.boolean boolean} - Formats user input in boolean format - * * {@link angular.formatter.json json} - Formats user input in JSON format - * * {@link angular.formatter.list list} - Formats user input string as an array - * * {@link angular.formatter.number number} - Formats user input strings as a number - * * {@link angular.formatter.trim trim} - Trims extras spaces from end of user input - * - * For more information about how angular formatters work, and how to create your own formatters, - * see {@link guide/dev_guide.templates.formatters Understanding Angular Formatters} in the angular - * Developer Guide. - */ - -function formatter(format, parse) {return {'format':format, 'parse':parse || format};} -function toString(obj) { - return (isDefined(obj) && obj !== null) ? "" + obj : obj; -} - -var NUMBER = /^\s*[-+]?\d*(\.\d*)?\s*$/; - -angularFormatter.noop = formatter(identity, identity); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.json - * - * @description - * Formats the user input as JSON text. - * - * @returns {?string} A JSON string representation of the model. - * - * @example - <doc:example> - <doc:source> - <div ng:init="data={name:'misko', project:'angular'}"> - <input type="text" size='50' name="data" ng:format="json"/> - <pre>data={{data}}</pre> - </div> - </doc:source> - <doc:scenario> - it('should format json', function(){ - expect(binding('data')).toEqual('data={\n \"name\":\"misko\",\n \"project\":\"angular\"}'); - input('data').enter('{}'); - expect(binding('data')).toEqual('data={\n }'); - }); - </doc:scenario> - </doc:example> - */ -angularFormatter.json = formatter(toJson, function(value){ - return fromJson(value || 'null'); -}); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.boolean - * - * @description - * Use boolean formatter if you wish to store the data as boolean. - * - * @returns {boolean} Converts to `true` unless user enters (blank), `f`, `false`, `0`, `no`, `[]`. - * - * @example - <doc:example> - <doc:source> - Enter truthy text: - <input type="text" name="value" ng:format="boolean" value="no"/> - <input type="checkbox" name="value"/> - <pre>value={{value}}</pre> - </doc:source> - <doc:scenario> - it('should format boolean', function(){ - expect(binding('value')).toEqual('value=false'); - input('value').enter('truthy'); - expect(binding('value')).toEqual('value=true'); - }); - </doc:scenario> - </doc:example> - */ -angularFormatter['boolean'] = formatter(toString, toBoolean); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.number - * - * @description - * Use number formatter if you wish to convert the user entered string to a number. - * - * @returns {number} Number from the parsed string. - * - * @example - <doc:example> - <doc:source> - Enter valid number: - <input type="text" name="value" ng:format="number" value="1234"/> - <pre>value={{value}}</pre> - </doc:source> - <doc:scenario> - it('should format numbers', function(){ - expect(binding('value')).toEqual('value=1234'); - input('value').enter('5678'); - expect(binding('value')).toEqual('value=5678'); - }); - </doc:scenario> - </doc:example> - */ -angularFormatter.number = formatter(toString, function(obj){ - if (obj == null || NUMBER.exec(obj)) { - return obj===null || obj === '' ? null : 1*obj; - } else { - throw "Not a number"; - } -}); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.list - * - * @description - * Use list formatter if you wish to convert the user entered string to an array. - * - * @returns {Array} Array parsed from the entered string. - * - * @example - <doc:example> - <doc:source> - Enter a list of items: - <input type="text" name="value" ng:format="list" value=" chair ,, table"/> - <input type="text" name="value" ng:format="list"/> - <pre>value={{value}}</pre> - </doc:source> - <doc:scenario> - it('should format lists', function(){ - expect(binding('value')).toEqual('value=["chair","table"]'); - this.addFutureAction('change to XYZ', function($window, $document, done){ - $document.elements('.doc-example-live :input:last').val(',,a,b,').trigger('change'); - done(); - }); - expect(binding('value')).toEqual('value=["a","b"]'); - }); - </doc:scenario> - </doc:example> - */ -angularFormatter.list = formatter( - function(obj) { return obj ? obj.join(", ") : obj; }, - function(value) { - var list = []; - forEach((value || '').split(','), function(item){ - item = trim(item); - if (item) list.push(item); - }); - return list; - } -); - -/** - * @workInProgress - * @ngdoc formatter - * @name angular.formatter.trim - * - * @description - * Use trim formatter if you wish to trim extra spaces in user text. - * - * @returns {String} Trim excess leading and trailing space. - * - * @example - <doc:example> - <doc:source> - Enter text with leading/trailing spaces: - <input type="text" name="value" ng:format="trim" value=" book "/> - <input type="text" name="value" ng:format="trim"/> - <pre>value={{value|json}}</pre> - </doc:source> - <doc:scenario> - it('should format trim', function(){ - expect(binding('value')).toEqual('value="book"'); - this.addFutureAction('change to XYZ', function($window, $document, done){ - $document.elements('.doc-example-live :input:last').val(' text ').trigger('change'); - done(); - }); - expect(binding('value')).toEqual('value="text"'); - }); - </doc:scenario> - </doc:example> - */ -angularFormatter.trim = formatter( - function(obj) { return obj ? trim("" + obj) : ""; } -); diff --git a/src/jqLite.js b/src/jqLite.js index 5f761f92..0052055c 100644 --- a/src/jqLite.js +++ b/src/jqLite.js @@ -100,6 +100,10 @@ function camelCase(name) { ///////////////////////////////////////////// // jQuery mutation patch +// +// In conjunction with bindJQuery intercepts all jQuery's DOM destruction apis and fires a +// $destroy event on all DOM nodes being removed. +// ///////////////////////////////////////////// function JQLitePatchJQueryRemove(name, dispatchThis) { @@ -129,7 +133,9 @@ function JQLitePatchJQueryRemove(name, dispatchThis) { } else { fireEvent = !fireEvent; } - for(childIndex = 0, childLength = (children = element.children()).length; childIndex < childLength; childIndex++) { + for(childIndex = 0, childLength = (children = element.children()).length; + childIndex < childLength; + childIndex++) { list.push(jQuery(children[childIndex])); } } @@ -283,7 +289,10 @@ var JQLitePrototype = JQLite.prototype = { // these functions return self on setter and // value on get. ////////////////////////////////////////// -var SPECIAL_ATTR = makeMap("multiple,selected,checked,disabled,readonly,required"); +var BOOLEAN_ATTR = {}; +forEach('multiple,selected,checked,disabled,readOnly,required'.split(','), function(value, key) { + BOOLEAN_ATTR[lowercase(value)] = value; +}); forEach({ data: JQLiteData, @@ -331,7 +340,7 @@ forEach({ }, attr: function(element, name, value){ - if (SPECIAL_ATTR[name]) { + if (BOOLEAN_ATTR[name]) { if (isDefined(value)) { if (!!value) { element[name] = true; diff --git a/src/markups.js b/src/markups.js index 1adad3e0..40f4322b 100644 --- a/src/markups.js +++ b/src/markups.js @@ -163,7 +163,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * This example uses `link` variable inside `href` attribute: <doc:example> <doc:source> - <input name="value" /><br /> + <input ng:model="value" /><br /> <a id="link-1" href ng:click="value = 1">link 1</a> (link, don't reload)<br /> <a id="link-2" href="" ng:click="value = 2">link 2</a> (link, don't reload)<br /> <a id="link-3" ng:href="/{{'123'}}" ng:ext-link>link 3</a> (link, reload!)<br /> @@ -262,8 +262,8 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example <doc:example> <doc:source> - Click me to toggle: <input type="checkbox" name="checked"><br/> - <button name="button" ng:disabled="{{checked}}">Button</button> + Click me to toggle: <input type="checkbox" ng:model="checked"><br/> + <button ng:model="button" ng:disabled="{{checked}}">Button</button> </doc:source> <doc:scenario> it('should toggle button', function() { @@ -292,7 +292,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example <doc:example> <doc:source> - Check me to check both: <input type="checkbox" name="master"><br/> + Check me to check both: <input type="checkbox" ng:model="master"><br/> <input id="checkSlave" type="checkbox" ng:checked="{{master}}"> </doc:source> <doc:scenario> @@ -323,7 +323,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example <doc:example> <doc:source> - Check me check multiple: <input type="checkbox" name="checked"><br/> + Check me check multiple: <input type="checkbox" ng:model="checked"><br/> <select id="select" ng:multiple="{{checked}}"> <option>Misko</option> <option>Igor</option> @@ -358,7 +358,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example <doc:example> <doc:source> - Check me to make text readonly: <input type="checkbox" name="checked"><br/> + Check me to make text readonly: <input type="checkbox" ng:model="checked"><br/> <input type="text" ng:readonly="{{checked}}" value="I'm Angular"/> </doc:source> <doc:scenario> @@ -388,7 +388,7 @@ angularTextMarkup('option', function(text, textNode, parentElement){ * @example <doc:example> <doc:source> - Check me to select: <input type="checkbox" name="checked"><br/> + Check me to select: <input type="checkbox" ng:model="checked"><br/> <select> <option>Hello!</option> <option id="greet" ng:selected="{{checked}}">Greetings!</option> @@ -408,10 +408,10 @@ angularTextMarkup('option', function(text, textNode, parentElement){ var NG_BIND_ATTR = 'ng:bind-attr'; -var SPECIAL_ATTRS = {}; +var SIDE_EFFECT_ATTRS = {}; -forEach('src,href,checked,disabled,multiple,readonly,selected'.split(','), function(name) { - SPECIAL_ATTRS['ng:' + name] = name; +forEach('src,href,multiple,selected,checked,disabled,readonly,required'.split(','), function(name) { + SIDE_EFFECT_ATTRS['ng:' + name] = name; }); angularAttrMarkup('{{}}', function(value, name, element){ @@ -421,10 +421,10 @@ angularAttrMarkup('{{}}', function(value, name, element){ value = decodeURI(value); var bindings = parseBindings(value), bindAttr; - if (hasBindings(bindings) || SPECIAL_ATTRS[name]) { + if (hasBindings(bindings) || SIDE_EFFECT_ATTRS[name]) { element.removeAttr(name); bindAttr = fromJson(element.attr(NG_BIND_ATTR) || "{}"); - bindAttr[SPECIAL_ATTRS[name] || name] = value; + bindAttr[SIDE_EFFECT_ATTRS[name] || name] = value; element.attr(NG_BIND_ATTR, toJson(bindAttr)); } }); diff --git a/src/parser.js b/src/parser.js index f8978a0b..4934b9e6 100644 --- a/src/parser.js +++ b/src/parser.js @@ -247,8 +247,6 @@ function parser(text, json){ assignable: assertConsumed(assignable), primary: assertConsumed(primary), statements: assertConsumed(statements), - validator: assertConsumed(validator), - formatter: assertConsumed(formatter), filter: assertConsumed(filter) }; @@ -361,36 +359,6 @@ function parser(text, json){ return pipeFunction(angularFilter); } - function validator(){ - return pipeFunction(angularValidator); - } - - function formatter(){ - var token = expect(); - var formatter = angularFormatter[token.text]; - var argFns = []; - if (!formatter) throwError('is not a valid formatter.', token); - while(true) { - if ((token = expect(':'))) { - argFns.push(expression()); - } else { - return valueFn({ - format:invokeFn(formatter.format), - parse:invokeFn(formatter.parse) - }); - } - } - function invokeFn(fn){ - return function(self, input){ - var args = [input]; - for ( var i = 0; i < argFns.length; i++) { - args.push(argFns[i](self)); - } - return fn.apply(self, args); - }; - } - } - function _pipeFunction(fnScope){ var fn = functionIdent(fnScope); var argsFn = []; @@ -735,16 +703,19 @@ function getterFn(path) { code += 'if(!s) return s;\n' + 'l=s;\n' + 's=s' + key + ';\n' + - 'if(typeof s=="function" && !(s instanceof RegExp)) s = function(){ return l' + - key + '.apply(l, arguments); };\n'; + 'if(typeof s=="function" && !(s instanceof RegExp)) {\n' + + ' fn=function(){ return l' + key + '.apply(l, arguments); };\n' + + ' fn.$unboundFn=s;\n' + + ' s=fn;\n' + + '}\n'; if (key.charAt(1) == '$') { // special code for super-imposed functions var name = key.substr(2); code += 'if(!s) {\n' + ' t = angular.Global.typeOf(l);\n' + ' fn = (angular[t.charAt(0).toUpperCase() + t.substring(1)]||{})["' + name + '"];\n' + - ' if (fn) s = function(){ return fn.apply(l, ' + - '[l].concat(Array.prototype.slice.call(arguments, 0))); };\n' + + ' if (fn) ' + + 's = function(){ return fn.apply(l, [l].concat(Array.prototype.slice.call(arguments, 0))); };\n' + '}\n'; } }); diff --git a/src/scenario/Scenario.js b/src/scenario/Scenario.js index 5ee7bde4..53be6c67 100644 --- a/src/scenario/Scenario.js +++ b/src/scenario/Scenario.js @@ -247,7 +247,7 @@ function browserTrigger(element, type) { 'radio': 'click', 'select-one': 'change', 'select-multiple': 'change' - }[element.type] || 'click'; + }[lowercase(element.type)] || 'click'; } if (lowercase(nodeName_(element)) == 'option') { element.parentNode.value = element.value; diff --git a/src/scenario/dsl.js b/src/scenario/dsl.js index 8bc4030e..e0af0c8c 100644 --- a/src/scenario/dsl.js +++ b/src/scenario/dsl.js @@ -180,7 +180,7 @@ angular.scenario.dsl('input', function() { chain.enter = function(value) { return this.addFutureAction("input '" + this.name + "' enter '" + value + "'", function($window, $document, done) { - var input = $document.elements(':input[name="$1"]', this.name); + var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input'); input.val(value); input.trigger('keydown'); done(); @@ -189,7 +189,7 @@ angular.scenario.dsl('input', function() { chain.check = function() { return this.addFutureAction("checkbox '" + this.name + "' toggle", function($window, $document, done) { - var input = $document.elements(':checkbox[name="$1"]', this.name); + var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':checkbox'); input.trigger('click'); done(); }); @@ -198,7 +198,7 @@ angular.scenario.dsl('input', function() { chain.select = function(value) { return this.addFutureAction("radio button '" + this.name + "' toggle '" + value + "'", function($window, $document, done) { var input = $document. - elements(':radio[name$="@$1"][value="$2"]', this.name, value); + elements('[ng\\:model="$1"][value="$2"]', this.name, value).filter(':radio'); input.trigger('click'); done(); }); @@ -206,7 +206,7 @@ angular.scenario.dsl('input', function() { chain.val = function() { return this.addFutureAction("return input val", function($window, $document, done) { - var input = $document.elements(':input[name="$1"]', this.name); + var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input'); done(null,input.val()); }); }; @@ -268,8 +268,16 @@ angular.scenario.dsl('select', function() { chain.option = function(value) { return this.addFutureAction("select '" + this.name + "' option '" + value + "'", function($window, $document, done) { - var select = $document.elements('select[name="$1"]', this.name); - select.val(value); + var select = $document.elements('select[ng\\:model="$1"]', this.name); + var option = select.find('option[value="' + value + '"]'); + if (option.length) { + select.val(value); + } else { + option = select.find('option:contains("' + value + '")'); + if (option.length) { + select.val(option.val()); + } + } select.trigger('change'); done(); }); @@ -278,7 +286,7 @@ angular.scenario.dsl('select', function() { chain.options = function() { var values = arguments; return this.addFutureAction("select '" + this.name + "' options '" + values + "'", function($window, $document, done) { - var select = $document.elements('select[multiple][name="$1"]', this.name); + var select = $document.elements('select[multiple][ng\\:model="$1"]', this.name); select.val(values); select.trigger('change'); done(); diff --git a/src/service/formFactory.js b/src/service/formFactory.js new file mode 100644 index 00000000..4fc53935 --- /dev/null +++ b/src/service/formFactory.js @@ -0,0 +1,394 @@ +'use strict'; + +/** + * @ngdoc service + * @name angular.service.$formFactory + * + * @description + * Use `$formFactory` to create a new instance of a {@link guide/dev_guide.forms form} + * controller or to find the nearest form instance for a given DOM element. + * + * The form instance is a collection of widgets, and is responsible for life cycle and validation + * of widget. + * + * Keep in mind that both form and widget instances are {@link api/angular.scope scopes}. + * + * @param {Form=} parentForm The form which should be the parent form of the new form controller. + * If none specified default to the `rootForm`. + * @returns {Form} A new <a href="#form">form</a> instance. + * + * @example + * + * This example shows how one could write a widget which would enable data-binding on + * `contenteditable` feature of HTML. + * + <doc:example> + <doc:source> + <script> + function EditorCntl(){ + this.html = '<b>Hello</b> <i>World</i>!'; + } + + function HTMLEditorWidget(element) { + var self = this; + var htmlFilter = angular.filter('html'); + + this.$parseModel = function(){ + // need to protect for script injection + try { + this.$viewValue = htmlFilter(this.$modelValue || '').get(); + if (this.$error.HTML) { + // we were invalid, but now we are OK. + this.$emit('$valid', 'HTML'); + } + } catch (e) { + // if HTML not parsable invalidate form. + this.$emit('$invalid', 'HTML'); + } + } + + this.$render = function(){ + element.html(this.$viewValue); + } + + element.bind('keyup', function(){ + self.$apply(function(){ + self.$emit('$viewChange', element.html()); + }); + }); + } + + angular.directive('ng:contenteditable', function(){ + function linkFn($formFactory, element) { + var exp = element.attr('ng:contenteditable'), + form = $formFactory.forElement(element), + widget; + element.attr('contentEditable', true); + widget = form.$createWidget({ + scope: this, + model: exp, + controller: HTMLEditorWidget, + controllerArgs: [element]}); + // if the element is destroyed, then we need to notify the form. + element.bind('$destroy', function(){ + widget.$destroy(); + }); + } + linkFn.$inject = ['$formFactory']; + return linkFn; + }); + </script> + <form name='editorForm' ng:controller="EditorCntl"> + <div ng:contenteditable="html"></div> + <hr/> + HTML: <br/> + <textarea ng:model="html" cols=80></textarea> + <hr/> + <pre>editorForm = {{editorForm}}</pre> + </form> + </doc:source> + <doc:scenario> + it('should enter invalid HTML', function(){ + expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-valid/); + input('html').enter('<'); + expect(element('form[name=editorForm]').prop('className')).toMatch(/ng-invalid/); + }); + </doc:scenario> + </doc:example> + */ +angularServiceInject('$formFactory', function(){ + + + /** + * @ngdoc proprety + * @name rootForm + * @propertyOf angular.service.$formFactory + * @description + * Static property on `$formFactory` + * + * Each application ({@link guide/dev_guide.scopes.internals root scope}) gets a root form which + * is the top-level parent of all forms. + */ + formFactory.rootForm = formFactory(this); + + + /** + * @ngdoc method + * @name forElement + * @methodOf angular.service.$formFactory + * @description + * Static method on `$formFactory` service. + * + * Retrieve the closest form for a given element or defaults to the `root` form. Used by the + * {@link angular.widget.form form} element. + * @param {Element} element The element where the search for form should initiate. + */ + formFactory.forElement = function (element) { + return element.inheritedData('$form') || formFactory.rootForm; + }; + return formFactory; + + function formFactory(parent) { + return (parent || formFactory.rootForm).$new(FormController); + } + +}); + +function propertiesUpdate(widget) { + widget.$valid = !(widget.$invalid = + !(widget.$readonly || widget.$disabled || equals(widget.$error, {}))); +} + +/** + * @ngdoc property + * @name $error + * @propertyOf angular.service.$formFactory + * @description + * Property of the form and widget instance. + * + * Summary of all of the errors on the page. If a widget emits `$invalid` with `REQUIRED` key, + * then the `$error` object will have a `REQUIRED` key with an array of widgets which have + * emitted this key. `form.$error.REQUIRED == [ widget ]`. + */ + +/** + * @workInProgress + * @ngdoc property + * @name $invalid + * @propertyOf angular.service.$formFactory + * @description + * Property of the form and widget instance. + * + * True if any of the widgets of the form are invalid. + */ + +/** + * @workInProgress + * @ngdoc property + * @name $valid + * @propertyOf angular.service.$formFactory + * @description + * Property of the form and widget instance. + * + * True if all of the widgets of the form are valid. + */ + +/** + * @ngdoc event + * @name angular.service.$formFactory#$valid + * @eventOf angular.service.$formFactory + * @eventType listen on form + * @description + * Upon receiving the `$valid` event from the widget update the `$error`, `$valid` and `$invalid` + * properties of both the widget as well as the from. + * + * @param {String} validationKey The validation key to be used when updating the `$error` object. + * The validation key is what will allow the template to bind to a specific validation error + * such as `<div ng:show="form.$error.KEY">error for key</div>`. + */ + +/** + * @ngdoc event + * @name angular.service.$formFactory#$invalid + * @eventOf angular.service.$formFactory + * @eventType listen on form + * @description + * Upon receiving the `$invalid` event from the widget update the `$error`, `$valid` and `$invalid` + * properties of both the widget as well as the from. + * + * @param {String} validationKey The validation key to be used when updating the `$error` object. + * The validation key is what will allow the template to bind to a specific validation error + * such as `<div ng:show="form.$error.KEY">error for key</div>`. + */ + +/** + * @ngdoc event + * @name angular.service.$formFactory#$validate + * @eventOf angular.service.$formFactory + * @eventType emit on widget + * @description + * Emit the `$validate` event on the widget, giving a widget a chance to emit a + * `$valid` / `$invalid` event base on its state. The `$validate` event is triggered when the + * model or the view changes. + */ + +/** + * @ngdoc event + * @name angular.service.$formFactory#$viewChange + * @eventOf angular.service.$formFactory + * @eventType listen on widget + * @description + * A widget is responsible for emitting this event whenever the view changes do to user interaction. + * The event takes a `$viewValue` parameter, which is the new value of the view. This + * event triggers a call to `$parseView()` as well as `$validate` event on widget. + * + * @param {*} viewValue The new value for the view which will be assigned to `widget.$viewValue`. + */ + +function FormController(){ + var form = this, + $error = form.$error = {}; + + form.$on('$destroy', function(event){ + var widget = event.targetScope; + if (widget.$widgetId) { + delete form[widget.$widgetId]; + } + forEach($error, removeWidget, widget); + }); + + form.$on('$valid', function(event, error){ + var widget = event.targetScope; + delete widget.$error[error]; + propertiesUpdate(widget); + removeWidget($error[error], error, widget); + }); + + form.$on('$invalid', function(event, error){ + var widget = event.targetScope; + addWidget(error, widget); + widget.$error[error] = true; + propertiesUpdate(widget); + }); + + propertiesUpdate(form); + + function removeWidget(queue, errorKey, widget) { + if (queue) { + widget = widget || this; // so that we can be used in forEach; + for (var i = 0, length = queue.length; i < length; i++) { + if (queue[i] === widget) { + queue.splice(i, 1); + if (!queue.length) { + delete $error[errorKey]; + } + } + } + propertiesUpdate(form); + } + } + + function addWidget(errorKey, widget) { + var queue = $error[errorKey]; + if (queue) { + for (var i = 0, length = queue.length; i < length; i++) { + if (queue[i] === widget) { + return; + } + } + } else { + $error[errorKey] = queue = []; + } + queue.push(widget); + propertiesUpdate(form); + } +} + + +/** + * @ngdoc method + * @name $createWidget + * @methodOf angular.service.$formFactory + * @description + * + * Use form's `$createWidget` instance method to create new widgets. The widgets can be created + * using an alias which makes the accessible from the form and available for data-binding, + * useful for displaying validation error messages. + * + * The creation of a widget sets up: + * + * - `$watch` of `expression` on `model` scope. This code path syncs the model to the view. + * The `$watch` listener will: + * + * - assign the new model value of `expression` to `widget.$modelValue`. + * - call `widget.$parseModel` method if present. The `$parseModel` is responsible for copying + * the `widget.$modelValue` to `widget.$viewValue` and optionally converting the data. + * (For example to convert a number into string) + * - emits `$validate` event on widget giving a widget a chance to emit `$valid` / `$invalid` + * event. + * - call `widget.$render()` method on widget. The `$render` method is responsible for + * reading the `widget.$viewValue` and updating the DOM. + * + * - Listen on `$viewChange` event from the `widget`. This code path syncs the view to the model. + * The `$viewChange` listener will: + * + * - assign the value to `widget.$viewValue`. + * - call `widget.$parseView` method if present. The `$parseView` is responsible for copying + * the `widget.$viewValue` to `widget.$modelValue` and optionally converting the data. + * (For example to convert a string into number) + * - emits `$validate` event on widget giving a widget a chance to emit `$valid` / `$invalid` + * event. + * - Assign the `widget.$modelValue` to the `expression` on the `model` scope. + * + * - Creates these set of properties on the `widget` which are updated as a response to the + * `$valid` / `$invalid` events: + * + * - `$error` - object - validation errors will be published as keys on this object. + * Data-binding to this property is useful for displaying the validation errors. + * - `$valid` - boolean - true if there are no validation errors + * - `$invalid` - boolean - opposite of `$valid`. + * @param {Object} params Named parameters: + * + * - `scope` - `{Scope}` - The scope to which the model for this widget is attached. + * - `model` - `{string}` - The name of the model property on model scope. + * - `controller` - {WidgetController} - The controller constructor function. + * The controller constructor should create these instance methods. + * - `$parseView()`: optional method responsible for copying `$viewVale` to `$modelValue`. + * The method may fire `$valid`/`$invalid` events. + * - `$parseModel()`: optional method responsible for copying `$modelVale` to `$viewValue`. + * The method may fire `$valid`/`$invalid` events. + * - `$render()`: required method which needs to update the DOM of the widget to match the + * `$viewValue`. + * + * - `controllerArgs` - `{Array}` (Optional) - Any extra arguments will be curried to the + * WidgetController constructor. + * - `onChange` - `{(string|function())}` (Optional) - Expression to execute when user changes the + * value. + * - `alias` - `{string}` (Optional) - The name of the form property under which the widget + * instance should be published. The name should be unique for each form. + * @returns {Widget} Instance of a widget scope. + */ +FormController.prototype.$createWidget = function(params) { + var form = this, + modelScope = params.scope, + onChange = params.onChange, + alias = params.alias, + scopeGet = parser(params.model).assignable(), + scopeSet = scopeGet.assign, + widget = this.$new(params.controller, params.controllerArgs); + + widget.$error = {}; + // Set the state to something we know will change to get the process going. + widget.$modelValue = Number.NaN; + // watch for scope changes and update the view appropriately + modelScope.$watch(scopeGet, function (scope, value) { + if (!equals(widget.$modelValue, value)) { + widget.$modelValue = value; + widget.$parseModel ? widget.$parseModel() : (widget.$viewValue = value); + widget.$emit('$validate'); + widget.$render && widget.$render(); + } + }); + + widget.$on('$viewChange', function(event, viewValue){ + if (!equals(widget.$viewValue, viewValue)) { + widget.$viewValue = viewValue; + widget.$parseView ? widget.$parseView() : (widget.$modelValue = widget.$viewValue); + scopeSet(modelScope, widget.$modelValue); + if (onChange) modelScope.$eval(onChange); + widget.$emit('$validate'); + } + }); + + propertiesUpdate(widget); + + // assign the widgetModel to the form + if (alias && !form.hasOwnProperty(alias)) { + form[alias] = widget; + widget.$widgetId = alias; + } else { + alias = null; + } + + return widget; +}; diff --git a/src/service/invalidWidgets.js b/src/service/invalidWidgets.js deleted file mode 100644 index 7c1b2a9f..00000000 --- a/src/service/invalidWidgets.js +++ /dev/null @@ -1,69 +0,0 @@ -'use strict'; - -/** - * @workInProgress - * @ngdoc service - * @name angular.service.$invalidWidgets - * - * @description - * Keeps references to all invalid widgets found during validation. - * Can be queried to find whether there are any invalid widgets currently displayed. - * - * @example - */ -angularServiceInject("$invalidWidgets", function(){ - var invalidWidgets = []; - - - /** Remove an element from the array of invalid widgets */ - invalidWidgets.markValid = function(element){ - var index = indexOf(invalidWidgets, element); - if (index != -1) - invalidWidgets.splice(index, 1); - }; - - - /** Add an element to the array of invalid widgets */ - invalidWidgets.markInvalid = function(element){ - var index = indexOf(invalidWidgets, element); - if (index === -1) - invalidWidgets.push(element); - }; - - - /** Return count of all invalid widgets that are currently visible */ - invalidWidgets.visible = function() { - var count = 0; - forEach(invalidWidgets, function(widget){ - count = count + (isVisible(widget) ? 1 : 0); - }); - return count; - }; - - - /* At the end of each eval removes all invalid widgets that are not part of the current DOM. */ - this.$watch(function() { - for(var i = 0; i < invalidWidgets.length;) { - var widget = invalidWidgets[i]; - if (isOrphan(widget[0])) { - invalidWidgets.splice(i, 1); - if (widget.dealoc) widget.dealoc(); - } else { - i++; - } - } - }); - - - /** - * Traverses DOM element's (widget's) parents and considers the element to be an orphan if one of - * it's parents isn't the current window.document. - */ - function isOrphan(widget) { - if (widget == window.document) return false; - var parent = widget.parentNode; - return !parent || isOrphan(parent); - } - - return invalidWidgets; -}); diff --git a/src/service/log.js b/src/service/log.js index 09945732..3dacd117 100644 --- a/src/service/log.js +++ b/src/service/log.js @@ -18,12 +18,13 @@ <script> function LogCtrl($log) { this.$log = $log; + this.message = 'Hello World!'; } </script> <div ng:controller="LogCtrl"> <p>Reload this page with open console, enter text and hit the log button...</p> Message: - <input type="text" name="message" value="Hello World!"/> + <input type="text" ng:model="message"/> <button ng:click="$log.log(message)">log</button> <button ng:click="$log.warn(message)">warn</button> <button ng:click="$log.info(message)">info</button> diff --git a/src/service/resource.js b/src/service/resource.js index f6e0be18..915f2d92 100644 --- a/src/service/resource.js +++ b/src/service/resource.js @@ -160,6 +160,7 @@ <doc:source jsfiddle="false"> <script> function BuzzController($resource) { + this.userId = 'googlebuzz'; this.Activity = $resource( 'https://www.googleapis.com/buzz/v1/activities/:userId/:visibility/:activityId/:comments', {alt:'json', callback:'JSON_CALLBACK'}, @@ -179,7 +180,7 @@ </script> <div ng:controller="BuzzController"> - <input name="userId" value="googlebuzz"/> + <input ng:model="userId"/> <button ng:click="fetch()">fetch</button> <hr/> <div ng:repeat="item in activities.data.items"> diff --git a/src/service/route.js b/src/service/route.js index 73c73b04..b78cca91 100644 --- a/src/service/route.js +++ b/src/service/route.js @@ -260,7 +260,8 @@ angularServiceInject('$route', function($location, $routeParams) { function updateRoute() { var next = parseRoute(), - last = $route.current; + last = $route.current, + Controller; if (next && last && next.$route === last.$route && equals(next.pathParams, last.pathParams) && !next.reloadOnSearch && !forceReload) { @@ -283,7 +284,8 @@ angularServiceInject('$route', function($location, $routeParams) { } } else { copy(next.params, $routeParams); - next.scope = parentScope.$new(next.controller); + (Controller = next.controller) && inferInjectionArgs(Controller); + next.scope = parentScope.$new(Controller); } } rootScope.$broadcast('$afterRouteChange', next, last); diff --git a/src/service/window.js b/src/service/window.js index 2f3f677a..9795e4fc 100644 --- a/src/service/window.js +++ b/src/service/window.js @@ -17,7 +17,7 @@ * @example <doc:example> <doc:source> - <input ng:init="$window = $service('$window'); greeting='Hello World!'" type="text" name="greeting" /> + <input ng:init="$window = $service('$window'); greeting='Hello World!'" type="text" ng:model="greeting" /> <button ng:click="$window.alert(greeting)">ALERT</button> </doc:source> <doc:scenario> diff --git a/src/service/xhr.js b/src/service/xhr.js index 09e7d070..4981c078 100644 --- a/src/service/xhr.js +++ b/src/service/xhr.js @@ -111,6 +111,7 @@ <script> function FetchCntl($xhr) { var self = this; + this.url = 'index.html'; this.fetch = function() { self.code = null; @@ -133,11 +134,11 @@ FetchCntl.$inject = ['$xhr']; </script> <div ng:controller="FetchCntl"> - <select name="method"> + <select ng:model="method"> <option>GET</option> <option>JSON</option> </select> - <input type="text" name="url" value="index.html" size="80"/> + <input type="text" ng:model="url" size="80"/> <button ng:click="fetch()">fetch</button><br> <button ng:click="updateModel('GET', 'index.html')">Sample GET</button> <button ng:click="updateModel('JSON', 'http://angularjs.org/greet.php?callback=JSON_CALLBACK&name=Super%20Hero')">Sample JSONP</button> diff --git a/src/validators.js b/src/validators.js deleted file mode 100644 index 72a995fc..00000000 --- a/src/validators.js +++ /dev/null @@ -1,482 +0,0 @@ -'use strict'; - -/** - * @workInProgress - * @ngdoc overview - * @name angular.validator - * @description - * - * Most of the built-in angular validators are used to check user input against defined types or - * patterns. You can easily create your own custom validators as well. - * - * Following is the list of built-in angular validators: - * - * * {@link angular.validator.asynchronous asynchronous()} - Provides asynchronous validation via a - * callback function. - * * {@link angular.validator.date date()} - Checks user input against default date format: - * "MM/DD/YYYY" - * * {@link angular.validator.email email()} - Validates that user input is a well-formed email - * address. - * * {@link angular.validator.integer integer()} - Validates that user input is an integer - * * {@link angular.validator.json json()} - Validates that user input is valid JSON - * * {@link angular.validator.number number()} - Validates that user input is a number - * * {@link angular.validator.phone phone()} - Validates that user input matches the pattern - * "1(123)123-1234" - * * {@link angular.validator.regexp regexp()} - Restricts valid input to a specified regular - * expression pattern - * * {@link angular.validator.url url()} - Validates that user input is a well-formed URL. - * - * For more information about how angular validators work, and how to create your own validators, - * see {@link guide/dev_guide.templates.validators Understanding Angular Validators} in the angular - * Developer Guide. - */ - -extend(angularValidator, { - 'noop': function() { return null; }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.regexp - * @description - * Use regexp validator to restrict the input to any Regular Expression. - * - * @param {string} value value to validate - * @param {string|regexp} expression regular expression. - * @param {string=} msg error message to display. - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - <script> function Cntl(){ - this.ssnRegExp = /^\d\d\d-\d\d-\d\d\d\d$/; - } - </script> - Enter valid SSN: - <div ng:controller="Cntl"> - <input name="ssn" value="123-45-6789" ng:validate="regexp:ssnRegExp" > - </div> - </doc:source> - <doc:scenario> - it('should invalidate non ssn', function(){ - var textBox = element('.doc-example-live :input'); - expect(textBox.prop('className')).not().toMatch(/ng-validation-error/); - expect(textBox.val()).toEqual('123-45-6789'); - input('ssn').enter('123-45-67890'); - expect(textBox.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'regexp': function(value, regexp, msg) { - if (!value.match(regexp)) { - return msg || - "Value does not match expected format " + regexp + "."; - } else { - return null; - } - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.number - * @description - * Use number validator to restrict the input to numbers with an - * optional range. (See integer for whole numbers validator). - * - * @param {string} value value to validate - * @param {int=} [min=MIN_INT] minimum value. - * @param {int=} [max=MAX_INT] maximum value. - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - Enter number: <input name="n1" ng:validate="number" > <br> - Enter number greater than 10: <input name="n2" ng:validate="number:10" > <br> - Enter number between 100 and 200: <input name="n3" ng:validate="number:100:200" > <br> - </doc:source> - <doc:scenario> - it('should invalidate number', function(){ - var n1 = element('.doc-example-live :input[name=n1]'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('n1').enter('1.x'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - var n2 = element('.doc-example-live :input[name=n2]'); - expect(n2.prop('className')).not().toMatch(/ng-validation-error/); - input('n2').enter('9'); - expect(n2.prop('className')).toMatch(/ng-validation-error/); - var n3 = element('.doc-example-live :input[name=n3]'); - expect(n3.prop('className')).not().toMatch(/ng-validation-error/); - input('n3').enter('201'); - expect(n3.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'number': function(value, min, max) { - var num = 1 * value; - if (num == value) { - if (typeof min != $undefined && num < min) { - return "Value can not be less than " + min + "."; - } - if (typeof min != $undefined && num > max) { - return "Value can not be greater than " + max + "."; - } - return null; - } else { - return "Not a number"; - } - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.integer - * @description - * Use number validator to restrict the input to integers with an - * optional range. (See integer for whole numbers validator). - * - * @param {string} value value to validate - * @param {int=} [min=MIN_INT] minimum value. - * @param {int=} [max=MAX_INT] maximum value. - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - Enter integer: <input name="n1" ng:validate="integer" > <br> - Enter integer equal or greater than 10: <input name="n2" ng:validate="integer:10" > <br> - Enter integer between 100 and 200 (inclusive): <input name="n3" ng:validate="integer:100:200" > <br> - </doc:source> - <doc:scenario> - it('should invalidate integer', function(){ - var n1 = element('.doc-example-live :input[name=n1]'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('n1').enter('1.1'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - var n2 = element('.doc-example-live :input[name=n2]'); - expect(n2.prop('className')).not().toMatch(/ng-validation-error/); - input('n2').enter('10.1'); - expect(n2.prop('className')).toMatch(/ng-validation-error/); - var n3 = element('.doc-example-live :input[name=n3]'); - expect(n3.prop('className')).not().toMatch(/ng-validation-error/); - input('n3').enter('100.1'); - expect(n3.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - */ - 'integer': function(value, min, max) { - var numberError = angularValidator['number'](value, min, max); - if (numberError) return numberError; - if (!("" + value).match(/^\s*[\d+]*\s*$/) || value != Math.round(value)) { - return "Not a whole number"; - } - return null; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.date - * @description - * Use date validator to restrict the user input to a valid date - * in format in format MM/DD/YYYY. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - Enter valid date: - <input name="text" value="1/1/2009" ng:validate="date" > - </doc:source> - <doc:scenario> - it('should invalidate date', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('123/123/123'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'date': function(value) { - var fields = /^(\d\d?)\/(\d\d?)\/(\d\d\d\d)$/.exec(value); - var date = fields ? new Date(fields[3], fields[1]-1, fields[2]) : 0; - return (date && - date.getFullYear() == fields[3] && - date.getMonth() == fields[1]-1 && - date.getDate() == fields[2]) - ? null - : "Value is not a date. (Expecting format: 12/31/2009)."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.email - * @description - * Use email validator if you wist to restrict the user input to a valid email. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - Enter valid email: - <input name="text" ng:validate="email" value="me@example.com"> - </doc:source> - <doc:scenario> - it('should invalidate email', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('a@b.c'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'email': function(value) { - if (value.match(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/)) { - return null; - } - return "Email needs to be in username@host.com format."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.phone - * @description - * Use phone validator to restrict the input phone numbers. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - Enter valid phone number: - <input name="text" value="1(234)567-8901" ng:validate="phone" > - </doc:source> - <doc:scenario> - it('should invalidate phone', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('+12345678'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'phone': function(value) { - if (value.match(/^1\(\d\d\d\)\d\d\d-\d\d\d\d$/)) { - return null; - } - if (value.match(/^\+\d{2,3} (\(\d{1,5}\))?[\d ]+\d$/)) { - return null; - } - return "Phone number needs to be in 1(987)654-3210 format in North America " + - "or +999 (123) 45678 906 internationally."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.url - * @description - * Use phone validator to restrict the input URLs. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - Enter valid URL: - <input name="text" value="http://example.com/abc.html" size="40" ng:validate="url" > - </doc:source> - <doc:scenario> - it('should invalidate url', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('abc://server/path'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'url': function(value) { - if (value.match(/^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/)) { - return null; - } - return "URL needs to be in http://server[:port]/path format."; - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.json - * @description - * Use json validator if you wish to restrict the user input to a valid JSON. - * - * @param {string} value value to validate - * @css ng-validation-error - * - * @example - <doc:example> - <doc:source> - <textarea name="json" cols="60" rows="5" ng:validate="json"> - {name:'abc'} - </textarea> - </doc:source> - <doc:scenario> - it('should invalidate json', function(){ - var n1 = element('.doc-example-live :input'); - expect(n1.prop('className')).not().toMatch(/ng-validation-error/); - input('json').enter('{name}'); - expect(n1.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - 'json': function(value) { - try { - fromJson(value); - return null; - } catch (e) { - return e.toString(); - } - }, - - /** - * @workInProgress - * @ngdoc validator - * @name angular.validator.asynchronous - * @description - * Use asynchronous validator if the validation can not be computed - * immediately, but is provided through a callback. The widget - * automatically shows a spinning indicator while the validity of - * the widget is computed. This validator caches the result. - * - * @param {string} value value to validate - * @param {function(inputToValidate,validationDone)} validate function to call to validate the state - * of the input. - * @param {function(data)=} [update=noop] function to call when state of the - * validator changes - * - * @paramDescription - * The `validate` function (specified by you) is called as - * `validate(inputToValidate, validationDone)`: - * - * * `inputToValidate`: value of the input box. - * * `validationDone`: `function(error, data){...}` - * * `error`: error text to display if validation fails - * * `data`: data object to pass to update function - * - * The `update` function is optionally specified by you and is - * called by <angular/> on input change. Since the - * asynchronous validator caches the results, the update - * function can be called without a call to `validate` - * function. The function is called as `update(data)`: - * - * * `data`: data object as passed from validate function - * - * @css ng-input-indicator-wait, ng-validation-error - * - * @example - <doc:example> - <doc:source> - <script> - function MyCntl(){ - this.myValidator = function (inputToValidate, validationDone) { - setTimeout(function(){ - validationDone(inputToValidate.length % 2); - }, 500); - } - } - </script> - This input is validated asynchronously: - <div ng:controller="MyCntl"> - <input name="text" ng:validate="asynchronous:myValidator"> - </div> - </doc:source> - <doc:scenario> - it('should change color in delayed way', function(){ - var textBox = element('.doc-example-live :input'); - expect(textBox.prop('className')).not().toMatch(/ng-input-indicator-wait/); - expect(textBox.prop('className')).not().toMatch(/ng-validation-error/); - input('text').enter('X'); - expect(textBox.prop('className')).toMatch(/ng-input-indicator-wait/); - sleep(.6); - expect(textBox.prop('className')).not().toMatch(/ng-input-indicator-wait/); - expect(textBox.prop('className')).toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - * - */ - /* - * cache is attached to the element - * cache: { - * inputs : { - * 'user input': { - * response: server response, - * error: validation error - * }, - * current: 'current input' - * } - * } - * - */ - 'asynchronous': function(input, asynchronousFn, updateFn) { - if (!input) return; - var scope = this; - var element = scope.$element; - var cache = element.data('$asyncValidator'); - if (!cache) { - element.data('$asyncValidator', cache = {inputs:{}}); - } - - cache.current = input; - - var inputState = cache.inputs[input], - $invalidWidgets = scope.$service('$invalidWidgets'); - - if (!inputState) { - cache.inputs[input] = inputState = { inFlight: true }; - $invalidWidgets.markInvalid(scope.$element); - element.addClass('ng-input-indicator-wait'); - asynchronousFn(input, function(error, data) { - inputState.response = data; - inputState.error = error; - inputState.inFlight = false; - if (cache.current == input) { - element.removeClass('ng-input-indicator-wait'); - $invalidWidgets.markValid(element); - } - element.data($$validate)(); - }); - } else if (inputState.inFlight) { - // request in flight, mark widget invalid, but don't show it to user - $invalidWidgets.markInvalid(scope.$element); - } else { - (updateFn||noop)(inputState.response); - } - return inputState.error; - } - -}); diff --git a/src/widget/form.js b/src/widget/form.js new file mode 100644 index 00000000..bc34bf0d --- /dev/null +++ b/src/widget/form.js @@ -0,0 +1,81 @@ +'use strict'; + +/** + * @workInProgress + * @ngdoc widget + * @name angular.widget.form + * + * @description + * Angular widget that creates a form scope using the + * {@link angular.service.$formFactory $formFactory} API. The resulting form scope instance is + * attached to the DOM element using the jQuery `.data()` method under the `$form` key. + * See {@link guide/dev_guide.forms forms} on detailed discussion of forms and widgets. + * + * + * # Alias: `ng:form` + * + * In angular forms can be nested. This means that the outer form is valid when all of the child + * forms are valid as well. However browsers do not allow nesting of `<form>` elements, for this + * reason angular provides `<ng:form>` alias which behaves identical to `<form>` but allows + * element nesting. + * + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.text = 'guest'; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + text: <input type="text" name="input" ng:model="text" required> + <span class="error" ng:show="myForm.text.$error.REQUIRED">Required!</span> + </form> + <tt>text = {{text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function(){ + expect(binding('text')).toEqual('guest'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function(){ + input('text').enter(''); + expect(binding('text')).toEqual(''); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularWidget('form', function(form){ + this.descend(true); + this.directives(true); + return annotate('$formFactory', function($formFactory, formElement) { + var name = formElement.attr('name'), + parentForm = $formFactory.forElement(formElement), + form = $formFactory(parentForm); + formElement.data('$form', form); + formElement.bind('submit', function(event){ + event.preventDefault(); + }); + if (name) { + this[name] = form; + } + watch('valid'); + watch('invalid'); + function watch(name) { + form.$watch('$' + name, function(scope, value) { + formElement[value ? 'addClass' : 'removeClass']('ng-' + name); + }); + } + }); +}); + +angularWidget('ng:form', angularWidget('form')); diff --git a/src/widget/input.js b/src/widget/input.js new file mode 100644 index 00000000..f82027f4 --- /dev/null +++ b/src/widget/input.js @@ -0,0 +1,773 @@ +'use strict'; + + +var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/; +var EMAIL_REGEXP = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}$/; +var NUMBER_REGEXP = /^\s*(\-|\+)?(\d+|(\d*(\.\d*)))\s*$/; +var INTEGER_REGEXP = /^\s*(\-|\+)?\d+\s*$/; + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.text + * + * @description + * Standard HTML text input with angular data binding. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.text = 'guest'; + this.word = /^\w*$/; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + Single word: <input type="text" name="input" ng:model="text" + ng:pattern="word" required> + <span class="error" ng:show="myForm.input.$error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.input.$error.PATTERN"> + Single word only!</span> + </form> + <tt>text = {{text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('text')).toEqual('guest'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('text').enter(''); + expect(binding('text')).toEqual(''); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + + it('should be invalid if multi word', function() { + input('text').enter('hello world'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ + + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.email + * + * @description + * Text input with email validation. Sets the `EMAIL` validation error key if not a valid email + * address. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.text = 'me@example.com'; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + Email: <input type="email" name="input" ng:model="text" required> + <span class="error" ng:show="myForm.input.$error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.input.$error.EMAIL"> + Not valid email!</span> + </form> + <tt>text = {{text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + <tt>myForm.$error.EMAIL = {{!!myForm.$error.EMAIL}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('text')).toEqual('me@example.com'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('text').enter(''); + expect(binding('text')).toEqual(''); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + + it('should be invalid if not email', function() { + input('text').enter('xxx'); + expect(binding('text')).toEqual('xxx'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('email', function() { + var widget = this; + this.$on('$validate', function(event){ + var value = widget.$viewValue; + widget.$emit(!value || value.match(EMAIL_REGEXP) ? "$valid" : "$invalid", "EMAIL"); + }); +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.url + * + * @description + * Text input with URL validation. Sets the `URL` validation error key if the content is not a + * valid URL. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.text = 'http://google.com'; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + URL: <input type="url" name="input" ng:model="text" required> + <span class="error" ng:show="myForm.input.$error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.input.$error.url"> + Not valid url!</span> + </form> + <tt>text = {{text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + <tt>myForm.$error.url = {{!!myForm.$error.url}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('text')).toEqual('http://google.com'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('text').enter(''); + expect(binding('text')).toEqual(''); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + + it('should be invalid if not url', function() { + input('text').enter('xxx'); + expect(binding('text')).toEqual('xxx'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('url', function() { + var widget = this; + this.$on('$validate', function(event){ + var value = widget.$viewValue; + widget.$emit(!value || value.match(URL_REGEXP) ? "$valid" : "$invalid", "URL"); + }); +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.list + * + * @description + * Text input that converts between comma-seperated string into an array of strings. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.names = ['igor', 'misko', 'vojta']; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + List: <input type="list" name="input" ng:model="names" required> + <span class="error" ng:show="myForm.list.$error.REQUIRED"> + Required!</span> + </form> + <tt>names = {{names}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('names')).toEqual('["igor","misko","vojta"]'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('names').enter(''); + expect(binding('names')).toEqual('[]'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('list', function() { + function parse(viewValue) { + var list = []; + forEach(viewValue.split(/\s*,\s*/), function(value){ + if (value) list.push(trim(value)); + }); + return list; + } + this.$parseView = function() { + isString(this.$viewValue) && (this.$modelValue = parse(this.$viewValue)); + }; + this.$parseModel = function() { + var modelValue = this.$modelValue; + if (isArray(modelValue) + && (!isString(this.$viewValue) || !equals(parse(this.$viewValue), modelValue))) { + this.$viewValue = modelValue.join(', '); + } + }; +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.number + * + * @description + * Text input with number validation and transformation. Sets the `NUMBER` validation + * error if not a valid number. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} min Sets the `MIN` validation error key if the value entered is less then `min`. + * @param {string=} max Sets the `MAX` validation error key if the value entered is greater then `min`. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.value = 12; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + Number: <input type="number" name="input" ng:model="value" + min="0" max="99" required> + <span class="error" ng:show="myForm.list.$error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.list.$error.NUMBER"> + Not valid number!</span> + </form> + <tt>value = {{value}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('value')).toEqual('12'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('value').enter(''); + expect(binding('value')).toEqual(''); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + + it('should be invalid if over max', function() { + input('value').enter('123'); + expect(binding('value')).toEqual('123'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('number', numericRegexpInputType(NUMBER_REGEXP, 'NUMBER')); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.integer + * + * @description + * Text input with integer validation and transformation. Sets the `INTEGER` + * validation error key if not a valid integer. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} min Sets the `MIN` validation error key if the value entered is less then `min`. + * @param {string=} max Sets the `MAX` validation error key if the value entered is greater then `min`. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.value = 12; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + Integer: <input type="integer" name="input" ng:model="value" + min="0" max="99" required> + <span class="error" ng:show="myForm.list.$error.REQUIRED"> + Required!</span> + <span class="error" ng:show="myForm.list.$error.INTEGER"> + Not valid integer!</span> + </form> + <tt>value = {{value}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('value')).toEqual('12'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('value').enter('1.2'); + expect(binding('value')).toEqual('12'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + + it('should be invalid if over max', function() { + input('value').enter('123'); + expect(binding('value')).toEqual('123'); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('integer', numericRegexpInputType(INTEGER_REGEXP, 'INTEGER')); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.checkbox + * + * @description + * HTML checkbox. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} true-value The value to which the expression should be set when selected. + * @param {string=} false-value The value to which the expression should be set when not selected. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.value1 = true; + this.value2 = 'YES' + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + Value1: <input type="checkbox" ng:model="value1"> <br/> + Value2: <input type="checkbox" ng:model="value2" + true-value="YES" false-value="NO"> <br/> + </form> + <tt>value1 = {{value1}}</tt><br/> + <tt>value2 = {{value2}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should change state', function() { + expect(binding('value1')).toEqual('true'); + expect(binding('value2')).toEqual('YES'); + + input('value1').check(); + input('value2').check(); + expect(binding('value1')).toEqual('false'); + expect(binding('value2')).toEqual('NO'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('checkbox', function (inputElement) { + var widget = this, + trueValue = inputElement.attr('true-value'), + falseValue = inputElement.attr('false-value'); + + if (!isString(trueValue)) trueValue = true; + if (!isString(falseValue)) falseValue = false; + + inputElement.bind('click', function() { + widget.$apply(function() { + widget.$emit('$viewChange', inputElement[0].checked); + }); + }); + + widget.$render = function() { + inputElement[0].checked = widget.$viewValue; + }; + + widget.$parseModel = function() { + widget.$viewValue = this.$modelValue === trueValue; + }; + + widget.$parseView = function() { + widget.$modelValue = widget.$viewValue ? trueValue : falseValue; + }; + +}); + +/** + * @workInProgress + * @ngdoc inputType + * @name angular.inputType.radio + * + * @description + * HTML radio. + * + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string} value The value to which the expression should be set when selected. + * @param {string=} name Property name of the form under which the widgets is published. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.color = 'blue'; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + <input type="radio" ng:model="color" value="red"> Red <br/> + <input type="radio" ng:model="color" value="green"> Green <br/> + <input type="radio" ng:model="color" value="blue"> Blue <br/> + </form> + <tt>color = {{color}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should change state', function() { + expect(binding('color')).toEqual('blue'); + + input('color').select('red'); + expect(binding('color')).toEqual('red'); + }); + </doc:scenario> + </doc:example> + */ +angularInputType('radio', function(inputElement) { + var widget = this, + value = inputElement.attr('value'); + + //correct the name + inputElement.attr('name', widget.$id + '@' + inputElement.attr('name')); + inputElement.bind('click', function() { + widget.$apply(function() { + if (inputElement[0].checked) { + widget.$emit('$viewChange', value); + } + }); + }); + + widget.$render = function() { + inputElement[0].checked = value == widget.$viewValue; + }; + + if (inputElement[0].checked) { + widget.$viewValue = value; + } +}); + + +function numericRegexpInputType(regexp, error) { + return function(inputElement) { + var widget = this, + min = 1 * (inputElement.attr('min') || Number.MIN_VALUE), + max = 1 * (inputElement.attr('max') || Number.MAX_VALUE); + + widget.$on('$validate', function(event){ + var value = widget.$viewValue, + filled = value && trim(value) != '', + valid = isString(value) && value.match(regexp); + + widget.$emit(!filled || valid ? "$valid" : "$invalid", error); + filled && (value = 1 * value); + widget.$emit(valid && value < min ? "$invalid" : "$valid", "MIN"); + widget.$emit(valid && value > max ? "$invalid" : "$valid", "MAX"); + }); + + widget.$parseView = function() { + if (widget.$viewValue.match(regexp)) { + widget.$modelValue = 1 * widget.$viewValue; + } else if (widget.$viewValue == '') { + widget.$modelValue = null; + } + }; + + widget.$parseModel = function() { + if (isNumber(widget.$modelValue)) { + widget.$viewValue = '' + widget.$modelValue; + } + }; + }; +} + + +var HTML5_INPUTS_TYPES = makeMap( + "search,tel,url,email,datetime,date,month,week,time,datetime-local,number,range,color," + + "radio,checkbox,text,button,submit,reset,hidden"); + + +/** + * @workInProgress + * @ngdoc widget + * @name angular.widget.input + * + * @description + * HTML input element widget with angular data-binding. Input widget follows HTML5 input types + * and polyfills the HTML5 validation behavior for older browsers. + * + * The {@link angular.inputType custom angular.inputType}s provides a short hand for declaring new + * inputs. This is a shart hand for text-box based inputs, and there is no need to go through the + * full {@link angular.service.$formFactory $formFactory} widget lifecycle. + * + * + * @param {string} type Widget types as defined by {@link angular.inputType}. If the + * type is in the format of `@ScopeType` then `ScopeType` is loaded from the + * current scope, allowing quick definition of type. + * @param {string} ng:model Assignable angular expression to data-bind to. + * @param {string=} name Property name of the form under which the widgets is published. + * @param {string=} required Sets `REQUIRED` validation error key if the value is not entered. + * @param {string=} ng:pattern Sets `PATTERN` validation error key if the value does not match the + * RegExp pattern expression. Expected value is `/regexp/` for inline patterns or `regexp` for + * patterns defined as scope expressions. + * + * @example + <doc:example> + <doc:source> + <script> + function Ctrl(){ + this.text = 'guest'; + } + </script> + <div ng:controller="Ctrl"> + <form name="myForm"> + text: <input type="text" name="input" ng:model="text" required> + <span class="error" ng:show="myForm.input.$error.REQUIRED"> + Required!</span> + </form> + <tt>text = {{text}}</tt><br/> + <tt>myForm.input.$valid = {{myForm.input.$valid}}</tt><br/> + <tt>myForm.input.$error = {{myForm.input.$error}}</tt><br/> + <tt>myForm.$valid = {{myForm.$valid}}</tt><br/> + <tt>myForm.$error.REQUIRED = {{!!myForm.$error.REQUIRED}}</tt><br/> + </div> + </doc:source> + <doc:scenario> + it('should initialize to model', function() { + expect(binding('text')).toEqual('guest'); + expect(binding('myForm.input.$valid')).toEqual('true'); + }); + + it('should be invalid if empty', function() { + input('text').enter(''); + expect(binding('text')).toEqual(''); + expect(binding('myForm.input.$valid')).toEqual('false'); + }); + </doc:scenario> + </doc:example> + */ +angularWidget('input', function (inputElement){ + this.directives(true); + this.descend(true); + var modelExp = inputElement.attr('ng:model'); + return modelExp && + annotate('$defer', '$formFactory', function($defer, $formFactory, inputElement){ + var form = $formFactory.forElement(inputElement), + // We have to use .getAttribute, since jQuery tries to be smart and use the + // type property. Trouble is some browser change unknown to text. + type = inputElement[0].getAttribute('type') || 'text', + TypeController, + modelScope = this, + patternMatch, widget, + pattern = trim(inputElement.attr('ng:pattern')), + loadFromScope = type.match(/^\s*\@\s*(.*)/); + + + if (!pattern) { + patternMatch = valueFn(true); + } else { + if (pattern.match(/^\/(.*)\/$/)) { + pattern = new RegExp(pattern.substring(1, pattern.length - 2)); + patternMatch = function(value) { + return pattern.test(value); + } + } else { + patternMatch = function(value) { + var patternObj = modelScope.$eval(pattern); + if (!patternObj || !patternObj.test) { + throw new Error('Expected ' + pattern + ' to be a RegExp but was ' + patternObj); + } + return patternObj.test(value); + } + } + } + + type = lowercase(type); + TypeController = (loadFromScope + ? (assertArgFn(this.$eval(loadFromScope[1]), loadFromScope[1])).$unboundFn + : angularInputType(type)) || noop; + + if (!HTML5_INPUTS_TYPES[type]) { + try { + // jquery will not let you so we have to go to bare metal + inputElement[0].setAttribute('type', 'text'); + } catch(e){ + // also turns out that ie8 will not allow changing of types, but since it is not + // html5 anyway we can ignore the error. + } + } + + !TypeController.$inject && (TypeController.$inject = []); + widget = form.$createWidget({ + scope: modelScope, + model: modelExp, + onChange: inputElement.attr('ng:change'), + alias: inputElement.attr('name'), + controller: TypeController, + controllerArgs: [inputElement]}); + + widget.$pattern = + watchElementProperty(this, widget, 'required', inputElement); + watchElementProperty(this, widget, 'readonly', inputElement); + watchElementProperty(this, widget, 'disabled', inputElement); + + + widget.$pristine = !(widget.$dirty = false); + + widget.$on('$validate', function(event) { + var $viewValue = trim(widget.$viewValue); + var inValid = widget.$required && !$viewValue; + var missMatch = $viewValue && !patternMatch($viewValue); + if (widget.$error.REQUIRED != inValid){ + widget.$emit(inValid ? '$invalid' : '$valid', 'REQUIRED'); + } + if (widget.$error.PATTERN != missMatch){ + widget.$emit(missMatch ? '$invalid' : '$valid', 'PATTERN'); + } + }); + + forEach(['valid', 'invalid', 'pristine', 'dirty'], function (name) { + widget.$watch('$' + name, function(scope, value) { + inputElement[value ? 'addClass' : 'removeClass']('ng-' + name); + } + ); + }); + + inputElement.bind('$destroy', function() { + widget.$destroy(); + }); + + if (type != 'checkbox' && type != 'radio') { + // TODO (misko): checkbox / radio does not really belong here, but until we can do + // widget registration with CSS, we are hacking it this way. + widget.$render = function() { + inputElement.val(widget.$viewValue || ''); + }; + + inputElement.bind('keydown change', function(event){ + var key = event.keyCode; + if (/*command*/ key != 91 && + /*modifiers*/ !(15 < key && key < 19) && + /*arrow*/ !(37 < key && key < 40)) { + $defer(function() { + widget.$dirty = !(widget.$pristine = false); + var value = trim(inputElement.val()); + if (widget.$viewValue !== value ) { + widget.$emit('$viewChange', value); + } + }); + } + }); + } + }); + +}); + +angularWidget('textarea', angularWidget('input')); + + +function watchElementProperty(modelScope, widget, name, element) { + var bindAttr = fromJson(element.attr('ng:bind-attr') || '{}'), + match = /\s*{{(.*)}}\s*/.exec(bindAttr[name]); + widget['$' + name] = + // some browsers return true some '' when required is set without value. + isString(element.prop(name)) || !!element.prop(name) || + // this is needed for ie9, since it will treat boolean attributes as false + !!element[0].attributes[name]; + if (bindAttr[name] && match) { + modelScope.$watch(match[1], function(scope, value){ + widget['$' + name] = !!value; + widget.$emit('$validate'); + }); + } +} + diff --git a/src/widget/select.js b/src/widget/select.js new file mode 100644 index 00000000..f397180e --- /dev/null +++ b/src/widget/select.js @@ -0,0 +1,427 @@ +'use strict'; + +/** + * @workInProgress + * @ngdoc widget + * @name angular.widget.select + * + * @description + * HTML `SELECT` element with angular data-binding. + * + * # `ng:options` + * + * Optionally `ng:options` attribute can be used to dynamically generate a list of `<option>` + * elements for a `<select>` element using an array or an object obtained by evaluating the + * `ng:options` expression. + * + * When an item in the select menu is select, the value of array element or object property + * represented by the selected option will be bound to the model identified by the `name` attribute + * of the parent select element. + * + * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can + * be nested into the `<select>` element. This element will then represent `null` or "not selected" + * option. See example below for demonstration. + * + * Note: `ng:options` provides iterator facility for `<option>` element which must be used instead + * of {@link angular.widget.@ng:repeat ng:repeat}. `ng:repeat` is not suitable for use with + * `<option>` element because of the following reasons: + * + * * value attribute of the option element that we need to bind to requires a string, but the + * source of data for the iteration might be in a form of array containing objects instead of + * strings + * * {@link angular.widget.@ng:repeat ng:repeat} unrolls after the select binds causing + * incorect rendering on most browsers. + * * binding to a value not in list confuses most browsers. + * + * @param {string} name assignable expression to data-bind to. + * @param {string=} required The widget is considered valid only if value is entered. + * @param {comprehension_expression=} ng:options in one of the following forms: + * + * * for array data sources: + * * `label` **`for`** `value` **`in`** `array` + * * `select` **`as`** `label` **`for`** `value` **`in`** `array` + * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` + * * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array` + * * for object data sources: + * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` + * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` + * * `select` **`as`** `label` **`group by`** `group` + * **`for` `(`**`key`**`,`** `value`**`) in`** `object` + * + * Where: + * + * * `array` / `object`: an expression which evaluates to an array / object to iterate over. + * * `value`: local variable which will refer to each item in the `array` or each property value + * of `object` during iteration. + * * `key`: local variable which will refer to a property name in `object` during iteration. + * * `label`: The result of this expression will be the label for `<option>` element. The + * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). + * * `select`: The result of this expression will be bound to the model of the parent `<select>` + * element. If not specified, `select` expression will default to `value`. + * * `group`: The result of this expression will be used to group options using the `<optgroup>` + * DOM element. + * + * @example + <doc:example> + <doc:source> + <script> + function MyCntrl(){ + this.colors = [ + {name:'black', shade:'dark'}, + {name:'white', shade:'light'}, + {name:'red', shade:'dark'}, + {name:'blue', shade:'dark'}, + {name:'yellow', shade:'light'} + ]; + this.color = this.colors[2]; // red + } + </script> + <div ng:controller="MyCntrl"> + <ul> + <li ng:repeat="color in colors"> + Name: <input ng:model="color.name"> + [<a href ng:click="colors.$remove(color)">X</a>] + </li> + <li> + [<a href ng:click="colors.push({})">add</a>] + </li> + </ul> + <hr/> + Color (null not allowed): + <select ng:model="color" ng:options="c.name for c in colors"></select><br> + + Color (null allowed): + <div class="nullable"> + <select ng:model="color" ng:options="c.name for c in colors"> + <option value="">-- chose color --</option> + </select> + </div><br/> + + Color grouped by shade: + <select ng:model="color" ng:options="c.name group by c.shade for c in colors"> + </select><br/> + + + Select <a href ng:click="color={name:'not in list'}">bogus</a>.<br> + <hr/> + Currently selected: {{ {selected_color:color} }} + <div style="border:solid 1px black; height:20px" + ng:style="{'background-color':color.name}"> + </div> + </div> + </doc:source> + <doc:scenario> + it('should check ng:options', function(){ + expect(binding('color')).toMatch('red'); + select('color').option('0'); + expect(binding('color')).toMatch('black'); + using('.nullable').select('color').option(''); + expect(binding('color')).toMatch('null'); + }); + </doc:scenario> + </doc:example> + */ + + + //00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777 +var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/; + + +angularWidget('select', function (element){ + this.directives(true); + this.descend(true); + return element.attr('ng:model') && annotate('$formFactory', function($formFactory, selectElement){ + var modelScope = this, + match, + form = $formFactory.forElement(selectElement), + multiple = selectElement.attr('multiple'), + optionsExp = selectElement.attr('ng:options'), + modelExp = selectElement.attr('ng:model'), + widget = form.$createWidget({ + scope: this, + model: modelExp, + onChange: selectElement.attr('ng:change'), + alias: selectElement.attr('name'), + controller: optionsExp ? Options : (multiple ? Multiple : Single)}); + + selectElement.bind('$destroy', function(){ widget.$destroy(); }); + + widget.$pristine = !(widget.$dirty = false); + + watchElementProperty(modelScope, widget, 'required', selectElement); + watchElementProperty(modelScope, widget, 'readonly', selectElement); + watchElementProperty(modelScope, widget, 'disabled', selectElement); + + widget.$on('$validate', function(){ + var valid = !widget.$required || !!widget.$modelValue; + if (valid && multiple && widget.$required) valid = !!widget.$modelValue.length; + if (valid !== !widget.$error.REQUIRED) { + widget.$emit(valid ? '$valid' : '$invalid', 'REQUIRED'); + } + }); + + widget.$on('$viewChange', function(){ + widget.$pristine = !(widget.$dirty = true); + }); + + forEach(['valid', 'invalid', 'pristine', 'dirty'], function (name) { + widget.$watch('$' + name, function(scope, value) { + selectElement[value ? 'addClass' : 'removeClass']('ng-' + name); + }); + }); + + //////////////////////////// + + function Multiple(){ + var widget = this; + + this.$render = function(){ + var items = new HashMap(this.$viewValue); + forEach(selectElement.children(), function(option){ + option.selected = isDefined(items.get(option.value)); + }); + }; + + selectElement.bind('change', function (){ + widget.$apply(function(){ + var array = []; + forEach(selectElement.children(), function(option){ + if (option.selected) { + array.push(option.value); + } + }); + widget.$emit('$viewChange', array); + }); + }); + + } + + function Single(){ + var widget = this; + + widget.$render = function(){ + selectElement.val(widget.$viewValue); + }; + + selectElement.bind('change', function(){ + widget.$apply(function(){ + widget.$emit('$viewChange', selectElement.val()); + }); + }); + + widget.$viewValue = selectElement.val(); + } + + function Options(){ + var widget = this, + match; + + if (! (match = optionsExp.match(NG_OPTIONS_REGEXP))) { + throw Error( + "Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + + " but got '" + optionsExp + "'."); + } + + var widgetScope = this, + displayFn = expressionCompile(match[2] || match[1]), + valueName = match[4] || match[6], + keyName = match[5], + groupByFn = expressionCompile(match[3] || ''), + valueFn = expressionCompile(match[2] ? match[1] : valueName), + valuesFn = expressionCompile(match[7]), + // we can't just jqLite('<option>') since jqLite is not smart enough + // to create it in <select> and IE barfs otherwise. + optionTemplate = jqLite(document.createElement('option')), + optGroupTemplate = jqLite(document.createElement('optgroup')), + nullOption = false, // if false then user will not be able to select it + // This is an array of array of existing option groups in DOM. We try to reuse these if possible + // optionGroupsCache[0] is the options with no option group + // optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element + optionGroupsCache = [[{element: selectElement, label:''}]], + inChangeEvent; + + // find existing special options + forEach(selectElement.children(), function(option){ + if (option.value == '') + // User is allowed to select the null. + nullOption = {label:jqLite(option).text(), id:''}; + }); + selectElement.html(''); // clear contents + + selectElement.bind('change', function(){ + widgetScope.$apply(function(){ + var optionGroup, + collection = valuesFn(modelScope) || [], + key = selectElement.val(), + tempScope = inherit(modelScope), + value, optionElement, index, groupIndex, length, groupLength; + + if (multiple) { + value = []; + for (groupIndex = 0, groupLength = optionGroupsCache.length; + groupIndex < groupLength; + groupIndex++) { + // list of options for that group. (first item has the parent) + optionGroup = optionGroupsCache[groupIndex]; + + for(index = 1, length = optionGroup.length; index < length; index++) { + if ((optionElement = optionGroup[index].element)[0].selected) { + if (keyName) tempScope[keyName] = key; + tempScope[valueName] = collection[optionElement.val()]; + value.push(valueFn(tempScope)); + } + } + } + } else { + if (key == '?') { + value = undefined; + } else if (key == ''){ + value = null; + } else { + tempScope[valueName] = collection[key]; + if (keyName) tempScope[keyName] = key; + value = valueFn(tempScope); + } + } + if (isDefined(value) && modelScope.$viewVal !== value) { + widgetScope.$emit('$viewChange', value); + } + }); + }); + + widgetScope.$watch(render); + widgetScope.$render = render; + + function render() { + var optionGroups = {'':[]}, // Temporary location for the option groups before we render them + optionGroupNames = [''], + optionGroupName, + optionGroup, + option, + existingParent, existingOptions, existingOption, + modelValue = widget.$modelValue, + values = valuesFn(modelScope) || [], + keys = keyName ? sortedKeys(values) : values, + groupLength, length, + groupIndex, index, + optionScope = inherit(modelScope), + selected, + selectedSet = false, // nothing is selected yet + lastElement, + element; + + if (multiple) { + selectedSet = new HashMap(modelValue); + } else if (modelValue === null || nullOption) { + // if we are not multiselect, and we are null then we have to add the nullOption + optionGroups[''].push(extend({selected:modelValue === null, id:'', label:''}, nullOption)); + selectedSet = true; + } + + // We now build up the list of options we need (we merge later) + for (index = 0; length = keys.length, index < length; index++) { + optionScope[valueName] = values[keyName ? optionScope[keyName]=keys[index]:index]; + optionGroupName = groupByFn(optionScope) || ''; + if (!(optionGroup = optionGroups[optionGroupName])) { + optionGroup = optionGroups[optionGroupName] = []; + optionGroupNames.push(optionGroupName); + } + if (multiple) { + selected = selectedSet.remove(valueFn(optionScope)) != undefined; + } else { + selected = modelValue === valueFn(optionScope); + selectedSet = selectedSet || selected; // see if at least one item is selected + } + optionGroup.push({ + id: keyName ? keys[index] : index, // either the index into array or key from object + label: displayFn(optionScope) || '', // what will be seen by the user + selected: selected // determine if we should be selected + }); + } + if (!multiple && !selectedSet) { + // nothing was selected, we have to insert the undefined item + optionGroups[''].unshift({id:'?', label:'', selected:true}); + } + + // Now we need to update the list of DOM nodes to match the optionGroups we computed above + for (groupIndex = 0, groupLength = optionGroupNames.length; + groupIndex < groupLength; + groupIndex++) { + // current option group name or '' if no group + optionGroupName = optionGroupNames[groupIndex]; + + // list of options for that group. (first item has the parent) + optionGroup = optionGroups[optionGroupName]; + + if (optionGroupsCache.length <= groupIndex) { + // we need to grow the optionGroups + optionGroupsCache.push( + existingOptions = [existingParent = { + element: optGroupTemplate.clone().attr('label', optionGroupName), + label: optionGroup.label + }] + ); + selectElement.append(existingParent.element); + } else { + existingOptions = optionGroupsCache[groupIndex]; + existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element + + // update the OPTGROUP label if not the same. + if (existingParent.label != optionGroupName) { + existingParent.element.attr('label', existingParent.label = optionGroupName); + } + } + + lastElement = null; // start at the begining + for(index = 0, length = optionGroup.length; index < length; index++) { + option = optionGroup[index]; + if ((existingOption = existingOptions[index+1])) { + // reuse elements + lastElement = existingOption.element; + if (existingOption.label !== option.label) { + lastElement.text(existingOption.label = option.label); + } + if (existingOption.id !== option.id) { + lastElement.val(existingOption.id = option.id); + } + if (existingOption.selected !== option.selected) { + lastElement.prop('selected', (existingOption.selected = option.selected)); + } + } else { + // grow elements + // jQuery(v1.4.2) Bug: We should be able to chain the method calls, but + // in this version of jQuery on some browser the .text() returns a string + // rather then the element. + (element = optionTemplate.clone()) + .val(option.id) + .attr('selected', option.selected) + .text(option.label); + existingOptions.push(existingOption = { + element: element, + label: option.label, + id: option.id, + selected: option.selected + }); + if (lastElement) { + lastElement.after(element); + } else { + existingParent.element.append(element); + } + lastElement = element; + } + } + // remove any excessive OPTIONs in a group + index++; // increment since the existingOptions[0] is parent element not OPTION + while(existingOptions.length > index) { + existingOptions.pop().element.remove(); + } + } + // remove any excessive OPTGROUPs from select + while(optionGroupsCache.length > groupIndex) { + optionGroupsCache.pop()[0].element.remove(); + } + }; + } + }); +}); diff --git a/src/widgets.js b/src/widgets.js index 1047c3ce..e3c6906f 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -14,14 +14,11 @@ * * Following is the list of built-in angular widgets: * - * * {@link angular.widget.@ng:format ng:format} - Formats data for display to user and for storage. * * {@link angular.widget.@ng:non-bindable ng:non-bindable} - Blocks angular from processing an * HTML element. * * {@link angular.widget.@ng:repeat ng:repeat} - Creates and manages a collection of cloned HTML * elements. - * * {@link angular.widget.@ng:required ng:required} - Verifies presence of user input. - * * {@link angular.widget.@ng:validate ng:validate} - Validates content of user input. - * * {@link angular.widget.HTML HTML input elements} - Standard HTML input elements data-bound by + * * {@link angular.inputType HTML input elements} - Standard HTML input elements data-bound by * angular. * * {@link angular.widget.ng:view ng:view} - Works with $route to "include" partial templates * * {@link angular.widget.ng:switch ng:switch} - Conditionally changes DOM structure @@ -34,915 +31,6 @@ /** * @workInProgress * @ngdoc widget - * @name angular.widget.HTML - * - * @description - * The most common widgets you will use will be in the form of the - * standard HTML set. These widgets are bound using the `name` attribute - * to an expression. In addition, they can have `ng:validate`, `ng:required`, - * `ng:format`, `ng:change` attribute to further control their behavior. - * - * @usageContent - * see example below for usage - * - * <input type="text|checkbox|..." ... /> - * <textarea ... /> - * <select ...> - * <option>...</option> - * </select> - * - * @example - <doc:example> - <doc:source> - <table style="font-size:.9em;"> - <tr> - <th>Name</th> - <th>Format</th> - <th>HTML</th> - <th>UI</th> - <th ng:non-bindable>{{input#}}</th> - </tr> - <tr> - <th>text</th> - <td>String</td> - <td><tt><input type="text" name="input1"></tt></td> - <td><input type="text" name="input1" size="4"></td> - <td><tt>{{input1|json}}</tt></td> - </tr> - <tr> - <th>textarea</th> - <td>String</td> - <td><tt><textarea name="input2"></textarea></tt></td> - <td><textarea name="input2" cols='6'></textarea></td> - <td><tt>{{input2|json}}</tt></td> - </tr> - <tr> - <th>radio</th> - <td>String</td> - <td><tt> - <input type="radio" name="input3" value="A"><br> - <input type="radio" name="input3" value="B"> - </tt></td> - <td> - <input type="radio" name="input3" value="A"> - <input type="radio" name="input3" value="B"> - </td> - <td><tt>{{input3|json}}</tt></td> - </tr> - <tr> - <th>checkbox</th> - <td>Boolean</td> - <td><tt><input type="checkbox" name="input4" value="checked"></tt></td> - <td><input type="checkbox" name="input4" value="checked"></td> - <td><tt>{{input4|json}}</tt></td> - </tr> - <tr> - <th>pulldown</th> - <td>String</td> - <td><tt> - <select name="input5"><br> - <option value="c">C</option><br> - <option value="d">D</option><br> - </select><br> - </tt></td> - <td> - <select name="input5"> - <option value="c">C</option> - <option value="d">D</option> - </select> - </td> - <td><tt>{{input5|json}}</tt></td> - </tr> - <tr> - <th>multiselect</th> - <td>Array</td> - <td><tt> - <select name="input6" multiple size="4"><br> - <option value="e">E</option><br> - <option value="f">F</option><br> - </select><br> - </tt></td> - <td> - <select name="input6" multiple size="4"> - <option value="e">E</option> - <option value="f">F</option> - </select> - </td> - <td><tt>{{input6|json}}</tt></td> - </tr> - </table> - </doc:source> - <doc:scenario> - - it('should exercise text', function(){ - input('input1').enter('Carlos'); - expect(binding('input1')).toEqual('"Carlos"'); - }); - it('should exercise textarea', function(){ - input('input2').enter('Carlos'); - expect(binding('input2')).toEqual('"Carlos"'); - }); - it('should exercise radio', function(){ - expect(binding('input3')).toEqual('null'); - input('input3').select('A'); - expect(binding('input3')).toEqual('"A"'); - input('input3').select('B'); - expect(binding('input3')).toEqual('"B"'); - }); - it('should exercise checkbox', function(){ - expect(binding('input4')).toEqual('false'); - input('input4').check(); - expect(binding('input4')).toEqual('true'); - }); - it('should exercise pulldown', function(){ - expect(binding('input5')).toEqual('"c"'); - select('input5').option('d'); - expect(binding('input5')).toEqual('"d"'); - }); - it('should exercise multiselect', function(){ - expect(binding('input6')).toEqual('[]'); - select('input6').options('e'); - expect(binding('input6')).toEqual('["e"]'); - select('input6').options('e', 'f'); - expect(binding('input6')).toEqual('["e","f"]'); - }); - </doc:scenario> - </doc:example> - */ - -function modelAccessor(scope, element) { - var expr = element.attr('name'); - var exprFn, assignFn; - if (expr) { - exprFn = parser(expr).assignable(); - assignFn = exprFn.assign; - if (!assignFn) throw new Error("Expression '" + expr + "' is not assignable."); - return { - get: function() { - return exprFn(scope); - }, - set: function(value) { - if (value !== undefined) { - assignFn(scope, value); - } - } - }; - } -} - -function modelFormattedAccessor(scope, element) { - var accessor = modelAccessor(scope, element), - formatterName = element.attr('ng:format') || NOOP, - formatter = compileFormatter(formatterName); - if (accessor) { - return { - get: function() { - return formatter.format(scope, accessor.get()); - }, - set: function(value) { - return accessor.set(formatter.parse(scope, value)); - } - }; - } -} - -function compileValidator(expr) { - return parser(expr).validator()(); -} - -function compileFormatter(expr) { - return parser(expr).formatter()(); -} - -/** - * @workInProgress - * @ngdoc widget - * @name angular.widget.@ng:validate - * - * @description - * The `ng:validate` attribute widget validates the user input. If the input does not pass - * validation, the `ng-validation-error` CSS class and the `ng:error` attribute are set on the input - * element. Check out {@link angular.validator validators} to find out more. - * - * @param {string} validator The name of a built-in or custom {@link angular.validator validator} to - * to be used. - * - * @element INPUT - * @css ng-validation-error - * - * @example - * This example shows how the input element becomes red when it contains invalid input. Correct - * the input to make the error disappear. - * - <doc:example> - <doc:source> - I don't validate: - <input type="text" name="value" value="NotANumber"><br/> - - I need an integer or nothing: - <input type="text" name="value" ng:validate="integer"><br/> - </doc:source> - <doc:scenario> - it('should check ng:validate', function(){ - expect(element('.doc-example-live :input:last').prop('className')). - toMatch(/ng-validation-error/); - - input('value').enter('123'); - expect(element('.doc-example-live :input:last').prop('className')). - not().toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - */ -/** - * @workInProgress - * @ngdoc widget - * @name angular.widget.@ng:required - * - * @description - * The `ng:required` attribute widget validates that the user input is present. It is a special case - * of the {@link angular.widget.@ng:validate ng:validate} attribute widget. - * - * @element INPUT - * @css ng-validation-error - * - * @example - * This example shows how the input element becomes red when it contains invalid input. Correct - * the input to make the error disappear. - * - <doc:example> - <doc:source> - I cannot be blank: <input type="text" name="value" ng:required><br/> - </doc:source> - <doc:scenario> - it('should check ng:required', function(){ - expect(element('.doc-example-live :input').prop('className')). - toMatch(/ng-validation-error/); - input('value').enter('123'); - expect(element('.doc-example-live :input').prop('className')). - not().toMatch(/ng-validation-error/); - }); - </doc:scenario> - </doc:example> - */ -/** - * @workInProgress - * @ngdoc widget - * @name angular.widget.@ng:format - * - * @description - * The `ng:format` attribute widget formats stored data to user-readable text and parses the text - * back to the stored form. You might find this useful, for example, if you collect user input in a - * text field but need to store the data in the model as a list. Check out - * {@link angular.formatter formatters} to learn more. - * - * @param {string} formatter The name of the built-in or custom {@link angular.formatter formatter} - * to be used. - * - * @element INPUT - * - * @example - * This example shows how the user input is converted from a string and internally represented as an - * array. - * - <doc:example> - <doc:source> - Enter a comma separated list of items: - <input type="text" name="list" ng:format="list" value="table, chairs, plate"> - <pre>list={{list}}</pre> - </doc:source> - <doc:scenario> - it('should check ng:format', function(){ - expect(binding('list')).toBe('list=["table","chairs","plate"]'); - input('list').enter(',,, a ,,,'); - expect(binding('list')).toBe('list=["a"]'); - }); - </doc:scenario> - </doc:example> - */ -function valueAccessor(scope, element) { - var validatorName = element.attr('ng:validate') || NOOP, - validator = compileValidator(validatorName), - requiredExpr = element.attr('ng:required'), - formatterName = element.attr('ng:format') || NOOP, - formatter = compileFormatter(formatterName), - format, parse, lastError, required, - invalidWidgets = scope.$service('$invalidWidgets') || {markValid:noop, markInvalid:noop}; - if (!validator) throw "Validator named '" + validatorName + "' not found."; - format = formatter.format; - parse = formatter.parse; - if (requiredExpr) { - scope.$watch(requiredExpr, function(scope, newValue) { - required = newValue; - validate(); - }); - } else { - required = requiredExpr === ''; - } - - element.data($$validate, validate); - return { - get: function(){ - if (lastError) - elementError(element, NG_VALIDATION_ERROR, null); - try { - var value = parse(scope, element.val()); - validate(); - return value; - } catch (e) { - lastError = e; - elementError(element, NG_VALIDATION_ERROR, e); - } - }, - set: function(value) { - var oldValue = element.val(), - newValue = format(scope, value); - if (oldValue != newValue) { - element.val(newValue || ''); // needed for ie - } - validate(); - } - }; - - function validate() { - var value = trim(element.val()); - if (element[0].disabled || element[0].readOnly) { - elementError(element, NG_VALIDATION_ERROR, null); - invalidWidgets.markValid(element); - } else { - var error, validateScope = inherit(scope, {$element:element}); - error = required && !value - ? 'Required' - : (value ? validator(validateScope, value) : null); - elementError(element, NG_VALIDATION_ERROR, error); - lastError = error; - if (error) { - invalidWidgets.markInvalid(element); - } else { - invalidWidgets.markValid(element); - } - } - } -} - -function checkedAccessor(scope, element) { - var domElement = element[0], elementValue = domElement.value; - return { - get: function(){ - return !!domElement.checked; - }, - set: function(value){ - domElement.checked = toBoolean(value); - } - }; -} - -function radioAccessor(scope, element) { - var domElement = element[0]; - return { - get: function(){ - return domElement.checked ? domElement.value : null; - }, - set: function(value){ - domElement.checked = value == domElement.value; - } - }; -} - -function optionsAccessor(scope, element) { - var formatterName = element.attr('ng:format') || NOOP, - formatter = compileFormatter(formatterName); - return { - get: function(){ - var values = []; - forEach(element[0].options, function(option){ - if (option.selected) values.push(formatter.parse(scope, option.value)); - }); - return values; - }, - set: function(values){ - var keys = {}; - forEach(values, function(value){ - keys[formatter.format(scope, value)] = true; - }); - forEach(element[0].options, function(option){ - option.selected = keys[option.value]; - }); - } - }; -} - -function noopAccessor() { return { get: noop, set: noop }; } - -/* - * TODO: refactor - * - * The table below is not quite right. In some cases the formatter is on the model side - * and in some cases it is on the view side. This is a historical artifact - * - * The concept of model/view accessor is useful for anyone who is trying to develop UI, and - * so it should be exposed to others. There should be a form object which keeps track of the - * accessors and also acts as their factory. It should expose it as an object and allow - * the validator to publish errors to it, so that the the error messages can be bound to it. - * - */ -var textWidget = inputWidget('keydown change', modelAccessor, valueAccessor, initWidgetValue(), true), - INPUT_TYPE = { - 'text': textWidget, - 'textarea': textWidget, - 'hidden': textWidget, - 'password': textWidget, - 'checkbox': inputWidget('click', modelFormattedAccessor, checkedAccessor, initWidgetValue(false)), - 'radio': inputWidget('click', modelFormattedAccessor, radioAccessor, radioInit), - 'select-one': inputWidget('change', modelAccessor, valueAccessor, initWidgetValue(null)), - 'select-multiple': inputWidget('change', modelAccessor, optionsAccessor, initWidgetValue([])) -// 'file': fileWidget??? - }; - - -function initWidgetValue(initValue) { - return function (model, view) { - var value = view.get(); - if (!value && isDefined(initValue)) { - value = copy(initValue); - } - if (isUndefined(model.get()) && isDefined(value)) { - model.set(value); - } - }; -} - -function radioInit(model, view, element) { - var modelValue = model.get(), viewValue = view.get(), input = element[0]; - input.checked = false; - input.name = this.$id + '@' + input.name; - if (isUndefined(modelValue)) { - model.set(modelValue = null); - } - if (modelValue == null && viewValue !== null) { - model.set(viewValue); - } - view.set(modelValue); -} - -/** - * @workInProgress - * @ngdoc directive - * @name angular.directive.ng:change - * - * @description - * The directive executes an expression whenever the input widget changes. - * - * @element INPUT - * @param {expression} expression to execute. - * - * @example - * @example - <doc:example> - <doc:source> - <div ng:init="checkboxCount=0; textCount=0"></div> - <input type="text" name="text" ng:change="textCount = 1 + textCount"> - changeCount {{textCount}}<br/> - <input type="checkbox" name="checkbox" ng:change="checkboxCount = 1 + checkboxCount"> - changeCount {{checkboxCount}}<br/> - </doc:source> - <doc:scenario> - it('should check ng:change', function(){ - expect(binding('textCount')).toBe('0'); - expect(binding('checkboxCount')).toBe('0'); - - using('.doc-example-live').input('text').enter('abc'); - expect(binding('textCount')).toBe('1'); - expect(binding('checkboxCount')).toBe('0'); - - - using('.doc-example-live').input('checkbox').check(); - expect(binding('textCount')).toBe('1'); - expect(binding('checkboxCount')).toBe('1'); - }); - </doc:scenario> - </doc:example> - */ -function inputWidget(events, modelAccessor, viewAccessor, initFn, textBox) { - return annotate('$defer', function($defer, element) { - var scope = this, - model = modelAccessor(scope, element), - view = viewAccessor(scope, element), - ngChange = element.attr('ng:change') || noop, - lastValue; - if (model) { - initFn.call(scope, model, view, element); - scope.$eval(element.attr('ng:init') || noop); - element.bind(events, function(event){ - function handler(){ - var value = view.get(); - if (!textBox || value != lastValue) { - model.set(value); - lastValue = model.get(); - scope.$eval(ngChange); - } - } - event.type == 'keydown' ? $defer(handler) : scope.$apply(handler); - }); - scope.$watch(model.get, function(scope, value) { - if (!equals(lastValue, value)) { - view.set(lastValue = value); - } - }); - } - }); -} - -function inputWidgetSelector(element){ - this.directives(true); - this.descend(true); - return INPUT_TYPE[lowercase(element[0].type)] || noop; -} - -angularWidget('input', inputWidgetSelector); -angularWidget('textarea', inputWidgetSelector); - - -/** - * @workInProgress - * @ngdoc directive - * @name angular.directive.ng:options - * - * @description - * Dynamically generate a list of `<option>` elements for a `<select>` element using an array or - * an object obtained by evaluating the `ng:options` expression. - * - * When an item in the select menu is select, the value of array element or object property - * represented by the selected option will be bound to the model identified by the `name` attribute - * of the parent select element. - * - * Optionally, a single hard-coded `<option>` element, with the value set to an empty string, can - * be nested into the `<select>` element. This element will then represent `null` or "not selected" - * option. See example below for demonstration. - * - * Note: `ng:options` provides iterator facility for `<option>` element which must be used instead - * of {@link angular.widget.@ng:repeat ng:repeat}. `ng:repeat` is not suitable for use with - * `<option>` element because of the following reasons: - * - * * value attribute of the option element that we need to bind to requires a string, but the - * source of data for the iteration might be in a form of array containing objects instead of - * strings - * * {@link angular.widget.@ng:repeat ng:repeat} unrolls after the select binds causing - * incorect rendering on most browsers. - * * binding to a value not in list confuses most browsers. - * - * @element select - * @param {comprehension_expression} comprehension in one of the following forms: - * - * * for array data sources: - * * `label` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`for`** `value` **`in`** `array` - * * `label` **`group by`** `group` **`for`** `value` **`in`** `array` - * * `select` **`as`** `label` **`group by`** `group` **`for`** `value` **`in`** `array` - * * for object data sources: - * * `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`for (`**`key` **`,`** `value`**`) in`** `object` - * * `label` **`group by`** `group` **`for (`**`key`**`,`** `value`**`) in`** `object` - * * `select` **`as`** `label` **`group by`** `group` - * **`for` `(`**`key`**`,`** `value`**`) in`** `object` - * - * Where: - * - * * `array` / `object`: an expression which evaluates to an array / object to iterate over. - * * `value`: local variable which will refer to each item in the `array` or each property value - * of `object` during iteration. - * * `key`: local variable which will refer to a property name in `object` during iteration. - * * `label`: The result of this expression will be the label for `<option>` element. The - * `expression` will most likely refer to the `value` variable (e.g. `value.propertyName`). - * * `select`: The result of this expression will be bound to the model of the parent `<select>` - * element. If not specified, `select` expression will default to `value`. - * * `group`: The result of this expression will be used to group options using the `<optgroup>` - * DOM element. - * - * @example - <doc:example> - <doc:source> - <script> - function MyCntrl(){ - this.colors = [ - {name:'black', shade:'dark'}, - {name:'white', shade:'light'}, - {name:'red', shade:'dark'}, - {name:'blue', shade:'dark'}, - {name:'yellow', shade:'light'} - ]; - this.color = this.colors[2]; // red - } - </script> - <div ng:controller="MyCntrl"> - <ul> - <li ng:repeat="color in colors"> - Name: <input name="color.name"> - [<a href ng:click="colors.$remove(color)">X</a>] - </li> - <li> - [<a href ng:click="colors.push({})">add</a>] - </li> - </ul> - <hr/> - Color (null not allowed): - <select name="color" ng:options="c.name for c in colors"></select><br> - - Color (null allowed): - <div class="nullable"> - <select name="color" ng:options="c.name for c in colors"> - <option value="">-- chose color --</option> - </select> - </div><br/> - - Color grouped by shade: - <select name="color" ng:options="c.name group by c.shade for c in colors"> - </select><br/> - - - Select <a href ng:click="color={name:'not in list'}">bogus</a>.<br> - <hr/> - Currently selected: {{ {selected_color:color} }} - <div style="border:solid 1px black; height:20px" - ng:style="{'background-color':color.name}"> - </div> - </div> - </doc:source> - <doc:scenario> - it('should check ng:options', function(){ - expect(binding('color')).toMatch('red'); - select('color').option('0'); - expect(binding('color')).toMatch('black'); - using('.nullable').select('color').option(''); - expect(binding('color')).toMatch('null'); - }); - </doc:scenario> - </doc:example> - */ -// 00001111100000000000222200000000000000000000003333000000000000044444444444444444000000000555555555555555550000000666666666666666660000000000000007777 -var NG_OPTIONS_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+group\s+by\s+(.*))?\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*)$/; -angularWidget('select', function(element){ - this.descend(true); - this.directives(true); - - var isMultiselect = element.attr('multiple'), - expression = element.attr('ng:options'), - onChange = expressionCompile(element.attr('ng:change') || ""), - match; - - if (!expression) { - return inputWidgetSelector.call(this, element); - } - if (! (match = expression.match(NG_OPTIONS_REGEXP))) { - throw Error( - "Expected ng:options in form of '_select_ (as _label_)? for (_key_,)?_value_ in _collection_'" + - " but got '" + expression + "'."); - } - - var displayFn = expressionCompile(match[2] || match[1]), - valueName = match[4] || match[6], - keyName = match[5], - groupByFn = expressionCompile(match[3] || ''), - valueFn = expressionCompile(match[2] ? match[1] : valueName), - valuesFn = expressionCompile(match[7]), - // we can't just jqLite('<option>') since jqLite is not smart enough - // to create it in <select> and IE barfs otherwise. - optionTemplate = jqLite(document.createElement('option')), - optGroupTemplate = jqLite(document.createElement('optgroup')), - nullOption = false; // if false then user will not be able to select it - - return function(selectElement){ - - // This is an array of array of existing option groups in DOM. We try to reuse these if possible - // optionGroupsCache[0] is the options with no option group - // optionGroupsCache[?][0] is the parent: either the SELECT or OPTGROUP element - var optionGroupsCache = [[{element: selectElement, label:''}]], - scope = this, - model = modelAccessor(scope, element), - inChangeEvent; - - // find existing special options - forEach(selectElement.children(), function(option){ - if (option.value == '') - // User is allowed to select the null. - nullOption = {label:jqLite(option).text(), id:''}; - }); - selectElement.html(''); // clear contents - - selectElement.bind('change', function(){ - var optionGroup, - collection = valuesFn(scope) || [], - key = selectElement.val(), - tempScope = scope.$new(), - value, optionElement, index, groupIndex, length, groupLength; - - // let's set a flag that the current model change is due to a change event. - // the default action of option selection will cause the appropriate option element to be - // deselected and another one to be selected - there is no need for us to be updating the DOM - // in this case. - inChangeEvent = true; - - try { - if (isMultiselect) { - value = []; - for (groupIndex = 0, groupLength = optionGroupsCache.length; - groupIndex < groupLength; - groupIndex++) { - // list of options for that group. (first item has the parent) - optionGroup = optionGroupsCache[groupIndex]; - - for(index = 1, length = optionGroup.length; index < length; index++) { - if ((optionElement = optionGroup[index].element)[0].selected) { - if (keyName) tempScope[keyName] = key; - tempScope[valueName] = collection[optionElement.val()]; - value.push(valueFn(tempScope)); - } - } - } - } else { - if (key == '?') { - value = undefined; - } else if (key == ''){ - value = null; - } else { - tempScope[valueName] = collection[key]; - if (keyName) tempScope[keyName] = key; - value = valueFn(tempScope); - } - } - if (isDefined(value) && model.get() !== value) { - model.set(value); - onChange(scope); - } - scope.$root.$apply(); - } finally { - tempScope = null; // TODO(misko): needs to be $destroy - inChangeEvent = false; - } - }); - - scope.$watch(function(scope) { - var optionGroups = {'':[]}, // Temporary location for the option groups before we render them - optionGroupNames = [''], - optionGroupName, - optionGroup, - option, - existingParent, existingOptions, existingOption, - values = valuesFn(scope) || [], - keys = values, - key, - groupLength, length, - fragment, - groupIndex, index, - optionElement, - optionScope = scope.$new(), - modelValue = model.get(), - selected, - selectedSet = false, // nothing is selected yet - isMulti = isMultiselect, - lastElement, - element; - - try { - if (isMulti) { - selectedSet = new HashMap(); - if (modelValue && isNumber(length = modelValue.length)) { - for (index = 0; index < length; index++) { - selectedSet.put(modelValue[index], true); - } - } - } else if (modelValue === null || nullOption) { - // if we are not multiselect, and we are null then we have to add the nullOption - optionGroups[''].push(extend({selected:modelValue === null, id:'', label:''}, nullOption)); - selectedSet = true; - } - - // If we have a keyName then we are iterating over on object. Grab the keys and sort them. - if(keyName) { - keys = []; - for (key in values) { - if (values.hasOwnProperty(key)) - keys.push(key); - } - keys.sort(); - } - - // We now build up the list of options we need (we merge later) - for (index = 0; length = keys.length, index < length; index++) { - optionScope[valueName] = values[keyName ? optionScope[keyName]=keys[index]:index]; - optionGroupName = groupByFn(optionScope) || ''; - if (!(optionGroup = optionGroups[optionGroupName])) { - optionGroup = optionGroups[optionGroupName] = []; - optionGroupNames.push(optionGroupName); - } - if (isMulti) { - selected = !!selectedSet.remove(valueFn(optionScope)); - } else { - selected = modelValue === valueFn(optionScope); - selectedSet = selectedSet || selected; // see if at least one item is selected - } - optionGroup.push({ - id: keyName ? keys[index] : index, // either the index into array or key from object - label: displayFn(optionScope) || '', // what will be seen by the user - selected: selected // determine if we should be selected - }); - } - optionGroupNames.sort(); - if (!isMulti && !selectedSet) { - // nothing was selected, we have to insert the undefined item - optionGroups[''].unshift({id:'?', label:'', selected:true}); - } - - // Now we need to update the list of DOM nodes to match the optionGroups we computed above - for (groupIndex = 0, groupLength = optionGroupNames.length; - groupIndex < groupLength; - groupIndex++) { - // current option group name or '' if no group - optionGroupName = optionGroupNames[groupIndex]; - - // list of options for that group. (first item has the parent) - optionGroup = optionGroups[optionGroupName]; - - if (optionGroupsCache.length <= groupIndex) { - // we need to grow the optionGroups - optionGroupsCache.push( - existingOptions = [ - existingParent = { - element: optGroupTemplate.clone().attr('label', optionGroupName), - label: optionGroup.label - } - ] - ); - selectElement.append(existingParent.element); - } else { - existingOptions = optionGroupsCache[groupIndex]; - existingParent = existingOptions[0]; // either SELECT (no group) or OPTGROUP element - - // update the OPTGROUP label if not the same. - if (existingParent.label != optionGroupName) { - existingParent.element.attr('label', existingParent.label = optionGroupName); - } - } - - lastElement = null; // start at the begining - for(index = 0, length = optionGroup.length; index < length; index++) { - option = optionGroup[index]; - if ((existingOption = existingOptions[index+1])) { - // reuse elements - lastElement = existingOption.element; - if (existingOption.label !== option.label) { - lastElement.text(existingOption.label = option.label); - } - if (existingOption.id !== option.id) { - lastElement.val(existingOption.id = option.id); - } - if (!inChangeEvent && existingOption.selected !== option.selected) { - lastElement.prop('selected', (existingOption.selected = option.selected)); - } - } else { - // grow elements - // jQuery(v1.4.2) Bug: We should be able to chain the method calls, but - // in this version of jQuery on some browser the .text() returns a string - // rather then the element. - (element = optionTemplate.clone()) - .val(option.id) - .attr('selected', option.selected) - .text(option.label); - existingOptions.push(existingOption = { - element: element, - label: option.label, - id: option.id, - selected: option.selected - }); - if (lastElement) { - lastElement.after(element); - } else { - existingParent.element.append(element); - } - lastElement = element; - } - } - // remove any excessive OPTIONs in a group - index++; // increment since the existingOptions[0] is parent element not OPTION - while(existingOptions.length > index) { - existingOptions.pop().element.remove(); - } - } - // remove any excessive OPTGROUPs from select - while(optionGroupsCache.length > groupIndex) { - optionGroupsCache.pop()[0].element.remove(); - } - } finally { - optionScope.$destroy(); - } - }); - }; -}); - - -/** - * @workInProgress - * @ngdoc widget * @name angular.widget.ng:include * * @description @@ -960,28 +48,36 @@ angularWidget('select', function(element){ * @example <doc:example> <doc:source jsfiddle="false"> - <select name="url"> - <option value="examples/ng-include/template1.html">template1.html</option> - <option value="examples/ng-include/template2.html">template2.html</option> - <option value="">(blank)</option> - </select> - url of the template: <tt><a href="{{url}}">{{url}}</a></tt> - <hr/> - <ng:include src="url"></ng:include> + <script> + function Ctrl(){ + this.templates = + [ { name: 'template1.html', url: 'examples/ng-include/template1.html'} + , { name: 'template2.html', url: 'examples/ng-include/template2.html'} ]; + this.template = this.templates[0]; + } + </script> + <div ng:controller="Ctrl"> + <select ng:model="template" ng:options="t.name for t in templates"> + <option value="">(blank)</option> + </select> + url of the template: <tt><a href="{{template.url}}">{{template.url}}</a></tt> + <hr/> + <ng:include src="template.url"></ng:include> + </div> </doc:source> <doc:scenario> it('should load template1.html', function(){ - expect(element('.doc-example-live ng\\:include').text()). + expect(element('.doc-example-live .ng-include').text()). toBe('Content of template1.html\n'); }); it('should load template2.html', function(){ - select('url').option('examples/ng-include/template2.html'); - expect(element('.doc-example-live ng\\:include').text()). + select('template').option('1'); + expect(element('.doc-example-live .ng-include').text()). toBe('Content of template2.html\n'); }); it('should change to blank', function(){ - select('url').option(''); - expect(element('.doc-example-live ng\\:include').text()).toEqual(''); + select('template').option(''); + expect(element('.doc-example-live .ng-include').text()).toEqual(''); }); </doc:scenario> </doc:example> @@ -1064,30 +160,34 @@ angularWidget('ng:include', function(element){ * @example <doc:example> <doc:source> - <select name="switch"> - <option>settings</option> - <option>home</option> - <option>other</option> - </select> - <tt>switch={{switch}}</tt> - </hr> - <ng:switch on="switch" > - <div ng:switch-when="settings">Settings Div</div> - <span ng:switch-when="home">Home Span</span> - <span ng:switch-default>default</span> - </ng:switch> - </code> + <script> + function Ctrl(){ + this.items = ['settings', 'home', 'other']; + this.selection = this.items[0]; + } + </script> + <div ng:controller="Ctrl"> + <select ng:model="selection" ng:options="item for item in items"> + </select> + <tt>selection={{selection}}</tt> + <hr/> + <ng:switch on="selection" > + <div ng:switch-when="settings">Settings Div</div> + <span ng:switch-when="home">Home Span</span> + <span ng:switch-default>default</span> + </ng:switch> + </div> </doc:source> <doc:scenario> it('should start in settings', function(){ expect(element('.doc-example-live ng\\:switch').text()).toEqual('Settings Div'); }); it('should change to home', function(){ - select('switch').option('home'); + select('selection').option('home'); expect(element('.doc-example-live ng\\:switch').text()).toEqual('Home Span'); }); it('should select deafault', function(){ - select('switch').option('other'); + select('selection').option('other'); expect(element('.doc-example-live ng\\:switch').text()).toEqual('default'); }); </doc:scenario> @@ -1568,27 +668,36 @@ angularWidget('ng:view', function(element) { * @example <doc:example> <doc:source> - Person 1:<input type="text" name="person1" value="Igor" /><br/> - Person 2:<input type="text" name="person2" value="Misko" /><br/> - Number of People:<input type="text" name="personCount" value="1" /><br/> - - <!--- Example with simple pluralization rules for en locale ---> - Without Offset: - <ng:pluralize count="personCount" - when="{'0': 'Nobody is viewing.', - 'one': '1 person is viewing.', - 'other': '{} people are viewing.'}"> - </ng:pluralize><br> - - <!--- Example with offset ---> - With Offset(2): - <ng:pluralize count="personCount" offset=2 - when="{'0': 'Nobody is viewing.', - '1': '{{person1}} is viewing.', - '2': '{{person1}} and {{person2}} are viewing.', - 'one': '{{person1}}, {{person2}} and one other person are viewing.', - 'other': '{{person1}}, {{person2}} and {} other people are viewing.'}"> - </ng:pluralize> + <script> + function Ctrl(){ + this.person1 = 'Igor'; + this.person2 = 'Misko'; + this.personCount = 1; + } + </script> + <div ng:controller="Ctrl"> + Person 1:<input type="text" ng:model="person1" value="Igor" /><br/> + Person 2:<input type="text" ng:model="person2" value="Misko" /><br/> + Number of People:<input type="text" ng:model="personCount" value="1" /><br/> + + <!--- Example with simple pluralization rules for en locale ---> + Without Offset: + <ng:pluralize count="personCount" + when="{'0': 'Nobody is viewing.', + 'one': '1 person is viewing.', + 'other': '{} people are viewing.'}"> + </ng:pluralize><br> + + <!--- Example with offset ---> + With Offset(2): + <ng:pluralize count="personCount" offset=2 + when="{'0': 'Nobody is viewing.', + '1': '{{person1}} is viewing.', + '2': '{{person1}} and {{person2}} are viewing.', + 'one': '{{person1}}, {{person2}} and one other person are viewing.', + 'other': '{{person1}}, {{person2}} and {} other people are viewing.'}"> + </ng:pluralize> + </div> </doc:source> <doc:scenario> it('should show correct pluralized string', function(){ |
