diff options
| author | Misko Hevery | 2011-04-19 16:34:49 -0700 | 
|---|---|---|
| committer | Misko Hevery | 2011-06-08 15:21:33 -0700 | 
| commit | af285dd370aa1b6779bf67ac3bdc19da512aaac5 (patch) | |
| tree | 663140aa80b8ec312bdc0390f552d8c8f86a8dda | |
| parent | 89e001b18a4f6d18caea1e9a3d015639feb4f1ee (diff) | |
| download | angular.js-af285dd370aa1b6779bf67ac3bdc19da512aaac5.tar.bz2 | |
Added ng:options directive
Closes #301
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | src/Angular.js | 5 | ||||
| -rw-r--r-- | src/directives.js | 6 | ||||
| -rw-r--r-- | src/formatters.js | 2 | ||||
| -rw-r--r-- | src/parser.js | 3 | ||||
| -rw-r--r-- | src/widgets.js | 288 | ||||
| -rw-r--r-- | test/BinderSpec.js | 6 | ||||
| -rw-r--r-- | test/testabilityPatch.js | 2 | ||||
| -rw-r--r-- | test/widgetsSpec.js | 341 | 
9 files changed, 498 insertions, 159 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 5acb5832..83aaa8c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,19 @@  ### New Features  - Added prepend() to jqLite +- Added ng:options directive (http://docs.angularjs.org/#!angular.directive.ng:options)  ### Bug Fixes  - Number filter would return incorrect value when fractional part had leading zeros. +  ### Breaking changes  - $service now has $service.invoke for method injection ($service(self, fn) no longer works)  - injection name inference no longer supports method curry and linking functions. Both must be    explicitly specified using $inject property. +- Dynamic Iteration (ng:repeater) on <option> elements is no longer supported. Use ng:options +  <a name="0.9.16"><a/> diff --git a/src/Angular.js b/src/Angular.js index 26026cf3..b5e7e12f 100644 --- a/src/Angular.js +++ b/src/Angular.js @@ -52,8 +52,9 @@ if ('i' !== 'I'.toLowerCase()) {  function fromCharCode(code) { return String.fromCharCode(code); } -var $$element         = '$element', -    $$update          = '$update', +var _undefined        = undefined, +    _null             = null, +    $$element         = '$element',      $$scope           = '$scope',      $$validate        = '$validate',      $angular          = 'angular', diff --git a/src/directives.js b/src/directives.js index 34a1b27d..016ba9fe 100644 --- a/src/directives.js +++ b/src/directives.js @@ -443,10 +443,8 @@ var REMOVE_ATTRIBUTES = {  angularDirective("ng:bind-attr", function(expression){    return function(element){      var lastValue = {}; -    var updateFn = element.data($$update) || noop;      this.$onEval(function(){ -      var values = this.$eval(expression), -          dirty = noop; +      var values = this.$eval(expression);        for(var key in values) {          var value = compileBindTemplate(values[key]).call(this, element),              specialName = REMOVE_ATTRIBUTES[lowercase(key)]; @@ -464,10 +462,8 @@ angularDirective("ng:bind-attr", function(expression){            } else {              element.attr(key, value);            } -          dirty = updateFn;          }        } -      dirty();      }, element);    };  }); diff --git a/src/formatters.js b/src/formatters.js index 906db619..b484f26f 100644 --- a/src/formatters.js +++ b/src/formatters.js @@ -204,6 +204,7 @@ angularFormatter.trim = formatter(   * @workInProgress   * @ngdoc formatter   * @name angular.formatter.index + * @deprecated   * @description   * Index formatter is meant to be used with `select` input widget. It is useful when one needs   * to select from a set of objects. To create pull-down one can iterate over the array of object @@ -250,6 +251,7 @@ angularFormatter.trim = formatter(       </doc:scenario>     </doc:example>   */ +//TODO: delete me since this is replaced by ng:options  angularFormatter.index = formatter(    function(object, array){      return '' + indexOf(array || [], object); diff --git a/src/parser.js b/src/parser.js index 59d7899a..0c4a391a 100644 --- a/src/parser.js +++ b/src/parser.js @@ -239,6 +239,8 @@ function parser(text, json){        pipeFunction =          function (){ throwError("is not valid json", {text:text, index:0}); };    } +  //TODO: Shouldn't all of the public methods have assertAllConsumed? +  //TODO: I think these should be public as part of the parser api instead of scope.$eval().    return {        assignable: assertConsumed(assignable),        primary: assertConsumed(primary), @@ -659,4 +661,3 @@ function parser(text, json){ - diff --git a/src/widgets.js b/src/widgets.js index 4245f99c..a9d42bdf 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -166,18 +166,19 @@  function modelAccessor(scope, element) {    var expr = element.attr('name'); -  var assign; +  var exprFn, assignFn;    if (expr) { -    assign = parser(expr).assignable().assign; -    if (!assign) throw new Error("Expression '" + expr + "' is not assignable."); +    exprFn = parser(expr).assignable(); +    assignFn = exprFn.assign; +    if (!assignFn) throw new Error("Expression '" + expr + "' is not assignable.");      return {        get: function() { -        return scope.$eval(expr); +        return exprFn(scope);        },        set: function(value) {          if (value !== undefined) {            return scope.$tryEval(function(){ -            assign(scope, value); +            assignFn(scope, value);            }, element);          }        } @@ -561,64 +562,241 @@ function inputWidgetSelector(element){  angularWidget('input', inputWidgetSelector);  angularWidget('textarea', inputWidgetSelector);  angularWidget('button', inputWidgetSelector); -angularWidget('select', function(element){ -  this.descend(true); -  return inputWidgetSelector.call(this, element); -}); +/** + * @workInProgress + * @ngdoc directive + * @name angular.directive.ng:options + * + * @description + * Dynamically generate a list of `<option>` elements for a `<select>` element using the array + * obtained by evaluating the `ng:options` expression. + * + * When an item in the select menu is select, the array element 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 _expresion_ `for` _item_ `in` _array_. + * + *   * _array_: an expression which evaluates to an array of objects to bind. + *   * _item_: local variable which will reffer to the item in the _array_ during the itteration + *   * _expression_: The result of this expression will is `option` label. The + *        `expression` most likely reffers to the _item_ varibale. + * + * @example +    <doc:example> +      <doc:source> +        <script> +        function MyCntrl(){ +          this.colors = [ +            {name:'black'}, +            {name:'white'}, +            {name:'red'}, +            {name:'blue'}, +            {name:'green'} +          ]; +          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): +          <select name="color" ng:options="c.name for c in colors"> +            <option value="">-- chose color --</option> +          </select><br/> -/* - * Consider this: - * <select name="selection"> - *   <option ng:repeat="x in [1,2]">{{x}}</option> - * </select> - * - * The issue is that the select gets evaluated before option is unrolled. - * This means that the selection is undefined, but the browser - * default behavior is to show the top selection in the list. - * To fix that we register a $update function on the select element - * and the option creation then calls the $update function when it is - * unrolled. The $update function then calls this update function, which - * then tries to determine if the model is unassigned, and if so it tries to - * chose one of the options from the list. +          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;" +               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'); +           select('color').option(''); +           expect(binding('color')).toMatch('null'); +         }); +      </doc:scenario> +    </doc:example>   */ -angularWidget('option', function(){ + +var NG_OPTIONS_REGEXP = /^(.*)\s+for\s+([\$\w][\$\w\d]*)\s+in\s+(.*)$/; +angularWidget('select', function(element){    this.descend(true);    this.directives(true); -  return function(option) { -    var select = option.parent(); -    var isMultiple = select[0].type == 'select-multiple'; -    var scope = select.scope(); -    var model = modelAccessor(scope, select); - -    //if parent select doesn't have a name, don't bother doing anything any more -    if (!model) return; - -    var formattedModel = modelFormattedAccessor(scope, select); -    var view = isMultiple -      ? optionsAccessor(scope, select) -      : valueAccessor(scope, select); -    var lastValue = option.attr($value); -    var wasSelected = option.attr('ng-' + $selected); -    option.data($$update, isMultiple -      ? function(){ -          view.set(model.get()); +  var isMultiselect = element.attr('multiple'); +  var expression = element.attr('ng:options'); +  var match; +  if (!expression) { +    return inputWidgetSelector.call(this, element); +  } +  if (! (match = expression.match(NG_OPTIONS_REGEXP))) { +    throw Error( +        "Expected ng:options in form of '_expresion_ for _item_ in _collection_' but got '" + +        expression + "'."); +  } +  var displayFn = expressionCompile(match[1]).fnSelf; +  var itemName = match[2]; +  var collectionFn = expressionCompile(match[3]).fnSelf; +  // we can't just jqLite('<option>') since jqLite is not smart enough +  // to create it in <select> and IE barfs otherwise. +  var option = jqLite(document.createElement('option')); +  return function(select){ +    var scope = this; +    var optionElements = []; +    var optionTexts = []; +    var lastSelectValue = isMultiselect ? {} : false; +    var nullOption = option.clone().val(''); +    var missingOption = option.clone().val('?'); +    var model = modelAccessor(scope, element); + +    // find existing special options +    forEach(select.children(), function(option){ +      if (option.value == '') nullOption = false; +    }); + +    select.bind('change', function(){ +      var collection = collectionFn(scope) || []; +      var value = select.val(); +      var index, length; +      if (isMultiselect) { +        value = []; +        for (index = 0, length = optionElements.length; index < length; index++) { +          if (optionElements[index][0].selected) { +            value.push(collection[index]); +          } +        } +      } else { +        if (value == '?') { +          value = undefined; +        } else { +          value = (value == '' ? null : collection[value]); +        } +      } +      if (!isUndefined(value)) model.set(value); +      scope.$tryEval(function(){ +        scope.$root.$eval(); +      }); +    }); + +    scope.$onEval(function(){ +      var scope = this; +      var collection = collectionFn(scope) || []; +      var value; +      var length; +      var fragment; +      var index; +      var optionText; +      var optionElement; +      var optionScope = scope.$new(); +      var modelValue = model.get(); +      var currentItem; +      var selectValue = ''; +      var isMulti = isMultiselect; + +      if (isMulti) { +        selectValue = new HashMap(); +        if (modelValue && isNumber(length = modelValue.length)) { +          for (index = 0; index < length; index++) { +            selectValue.put(modelValue[index], true); +          }          } -      : function(){ -          var currentValue = option.attr($value); -          var isSelected = option.attr('ng-' + $selected); -          var modelValue = model.get(); -          if (wasSelected != isSelected || lastValue != currentValue) { -            wasSelected = isSelected; -            lastValue = currentValue; -            if (isSelected || !modelValue == null || modelValue == undefined ) -              formattedModel.set(currentValue); -            if (currentValue == modelValue) { -              view.set(lastValue); +      } +      try { +        for (index = 0, length = collection.length; index < length; index++) { +          currentItem = optionScope[itemName] = collection[index]; +          optionText = displayFn(optionScope); +          if (optionTexts.length > index) { +            // reuse +            optionElement = optionElements[index]; +            if (optionText != optionTexts[index]) { +              (optionElement).text(optionTexts[index] = optionText); +            } +          } else { +            // grow +            if (!fragment) { +              fragment = document.createDocumentFragment();              } +            optionTexts.push(optionText); +            optionElements.push(optionElement = option.clone()); +            optionElement.attr('value', index).text(optionText); +            fragment.appendChild(optionElement[0]);            } +          if (isMulti) { +            if (lastSelectValue[index] != (value = selectValue.remove(currentItem))) { +              optionElement[0].selected = !!(lastSelectValue[index] = value); +            } +          } else { +            if (modelValue == currentItem) { +              selectValue = index; +            } +          } +        } +        if (fragment) select.append(jqLite(fragment)); +        // shrink children +        while(optionElements.length > index) { +          optionElements.pop().remove(); +          delete lastSelectValue[optionElements.length];          } -    ); + +        if (!isMulti) { +          if (selectValue === '' && modelValue) { +            // We could not find a match +            selectValue = '?'; +          } + +          // update the selected item +          if (lastSelectValue !== selectValue) { +            if (nullOption) { +              if (lastSelectValue == '') nullOption.remove(); +              if (selectValue === '') select.prepend(nullOption); +            } + +            if (missingOption) { +              if (lastSelectValue == '?') missingOption.remove(); +              if (selectValue === '?') select.prepend(missingOption); +            } + +            select.val(lastSelectValue = selectValue); +          } +        } + +      } finally { +        optionScope = null; +      } +    });    };  }); @@ -932,7 +1110,7 @@ angularWidget('@ng:repeat', function(expression, element){      var match = expression.match(/^\s*(.+)\s+in\s+(.*)\s*$/),          lhs, rhs, valueIdent, keyIdent;      if (! match) { -      throw Error("Expected ng:repeat in form of 'item in collection' but got '" + +      throw Error("Expected ng:repeat in form of '_item_ in _collection_' but got '" +        expression + "'.");      }      lhs = match[1]; diff --git a/test/BinderSpec.js b/test/BinderSpec.js index d78573bb..85387d8f 100644 --- a/test/BinderSpec.js +++ b/test/BinderSpec.js @@ -463,12 +463,6 @@ describe('Binder', function(){      assertEquals('123{{a}}{{b}}{{c}}', scope.$element.text());    }); -  it('OptionShouldUpdateParentToGetProperBinding', function(){ -    var scope = this.compile('<select name="s"><option ng:repeat="i in [0,1]" value="{{i}}" ng:bind="i"></option></select>'); -    scope.$set('s', 1); -    scope.$eval(); -    assertEquals(1, scope.$element[0].selectedIndex); -  });    it('RepeaterShouldBindInputsDefaults', function () {      var scope = this.compile('<div><input value="123" name="item.name" ng:repeat="item in items"></div>'); diff --git a/test/testabilityPatch.js b/test/testabilityPatch.js index 0776f620..121d6900 100644 --- a/test/testabilityPatch.js +++ b/test/testabilityPatch.js @@ -212,7 +212,7 @@ function sortedHtml(element, showNgClass) {              attr.value !='auto' &&              attr.value !='false' &&              attr.value !='inherit' && -            attr.value !='0' && +            (attr.value !='0' || attr.name =='value') &&              attr.name !='loop' &&              attr.name !='complete' &&              attr.name !='maxLength' && diff --git a/test/widgetsSpec.js b/test/widgetsSpec.js index b412cd6d..176f6b9c 100644 --- a/test/widgetsSpec.js +++ b/test/widgetsSpec.js @@ -367,93 +367,6 @@ describe("widget", function(){          expect(element[0].childNodes[0].selected).toEqual(true);        }); -      it('should honor the value field in option', function(){ -        compile( -            '<select name="selection" ng:format="number">' + -              '<option value="{{$index}}" ng:repeat="name in [\'A\', \'B\', \'C\']">{{name}}</option>' + -            '</select>'); -        // childNodes[0] is repeater comment -        expect(scope.selection).toEqual(0); - -        browserTrigger(element[0].childNodes[2], 'change'); -        expect(scope.selection).toEqual(1); - -        scope.selection = 2; -        scope.$eval(); -        expect(element[0].childNodes[3].selected).toEqual(true); -      }); - -      it('should unroll select options before eval', function(){ -        compile( -            '<select name="selection" ng:required>' + -              '<option value="{{$index}}" ng:repeat="opt in options">{{opt}}</option>' + -            '</select>', -            jqLite(document.body)); -        scope.selection = 1; -        scope.options = ['one', 'two']; -        scope.$eval(); -        expect(element[0].value).toEqual('1'); -        expect(element.hasClass(NG_VALIDATION_ERROR)).toEqual(false); -      }); - -      it('should update select when value changes', function(){ -        compile( -            '<select name="selection">' + -              '<option value="...">...</option>' + -              '<option value="{{value}}">B</option>' + -            '</select>'); -        scope.selection = 'B'; -        scope.$eval(); -        expect(element[0].childNodes[1].selected).toEqual(false); -        scope.value = 'B'; -        scope.$eval(); -        expect(element[0].childNodes[1].selected).toEqual(true); -      }); - -      it('should select default option on repeater', function(){ -        compile( -            '<select name="selection">' + -              '<option ng:repeat="no in [1,2]">{{no}}</option>' + -            '</select>'); -        expect(scope.selection).toEqual('1'); -      }); - -      it('should select selected option on repeater', function(){ -        compile( -            '<select name="selection">' + -              '<option ng:repeat="no in [1,2]">{{no}}</option>' + -              '<option selected>ABC</option>' + -            '</select>'); -        expect(scope.selection).toEqual('ABC'); -      }); - -      it('should select dynamically selected option on repeater', function(){ -        compile( -            '<select name="selection">' + -              '<option ng:repeat="no in [1,2]" ng:bind-attr="{selected:\'{{no==2}}\'}">{{no}}</option>' + -            '</select>'); -        expect(scope.selection).toEqual('2'); -      }); - -      it('should allow binding to objects through JSON', function(){ -        compile( -            '<select name="selection" ng:format="json">' + -              '<option ng:repeat="obj in objs" value="{{obj}}">{{obj.name}}</option>' + -            '</select>'); -        scope.objs = [{name:'A'}, {name:'B'}]; -        scope.$eval(); -        expect(scope.selection).toEqual({name:'A'}); -      }); - -      it('should allow binding to objects through index', function(){ -        compile( -            '<select name="selection" ng:format="index:objs">' + -              '<option ng:repeat="obj in objs" value="{{$index}}">{{obj.name}}</option>' + -            '</select>'); -        scope.objs = [{name:'A'}, {name:'B'}]; -        scope.$eval(); -        expect(scope.selection).toBe(scope.objs[0]); -      });        it('should compile children of a select without a name, but not create a model for it',            function() { @@ -695,6 +608,256 @@ describe("widget", function(){      });    }); +  describe('ng:options', function(){ +    var select, scope; + +    function createSelect(multiple, blank, unknown){ +      select = jqLite( +          '<select name="selected" ' + (multiple ? ' multiple' : '') + +                 ' ng:options="value.name for value in values">' + +            (blank ? '<option value="">blank</option>' : '') + +            (unknown ? '<option value="?">unknown</option>' : '') + +          '</select>'); +      scope = compile(select); +    }; + +    function createSingleSelect(blank, unknown){ +      createSelect(false, blank, unknown); +    }; + +    function createMultiSelect(blank, unknown){ +      createSelect(true, blank, unknown); +    }; + +    afterEach(function(){ +      dealoc(select); +      dealoc(scope); +    }); + +    it('should throw when not formated "? for ? in ?"', function(){ +      expect(function(){ +        compile('<select name="selected" ng:options="i dont parse"></select>'); +      }).toThrow("Expected ng:options in form of '_expresion_ for _item_ in _collection_' but got 'i dont parse'."); + +      $logMock.error.logs.shift(); +    }); + +    it('should render a list', function(){ +      createSingleSelect(); +      scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; +      scope.selected = scope.values[0]; +      scope.$eval(); +      var options = select.find('option'); +      expect(options.length).toEqual(3); +      expect(sortedHtml(options[0])).toEqual('<option value="0">A</option>'); +      expect(sortedHtml(options[1])).toEqual('<option value="1">B</option>'); +      expect(sortedHtml(options[2])).toEqual('<option value="2">C</option>'); +    }); + +    it('should grow list', function(){ +      createSingleSelect(); +      scope.values = []; +      scope.$eval(); +      expect(select.find('option').length).toEqual(1); // because we add special empty option +      expect(sortedHtml(select.find('option')[0])).toEqual('<option></option>'); + +      scope.values.push({name:'A'}); +      scope.selected = scope.values[0]; +      scope.$eval(); +      expect(select.find('option').length).toEqual(1); +      expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); + +      scope.values.push({name:'B'}); +      scope.$eval(); +      expect(select.find('option').length).toEqual(2); +      expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); +      expect(sortedHtml(select.find('option')[1])).toEqual('<option value="1">B</option>'); +    }); + +    it('should shrink list', function(){ +      createSingleSelect(); +      scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; +      scope.selected = scope.values[0]; +      scope.$eval(); +      expect(select.find('option').length).toEqual(3); + +      scope.values.pop(); +      scope.$eval(); +      expect(select.find('option').length).toEqual(2); +      expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); +      expect(sortedHtml(select.find('option')[1])).toEqual('<option value="1">B</option>'); + +      scope.values.pop(); +      scope.$eval(); +      expect(select.find('option').length).toEqual(1); +      expect(sortedHtml(select.find('option')[0])).toEqual('<option value="0">A</option>'); + +      scope.values.pop(); +      scope.selected = null; +      scope.$eval(); +      expect(select.find('option').length).toEqual(1); // we add back the special empty option +    }); + +    it('should update list', function(){ +      createSingleSelect(); +      scope.values = [{name:'A'}, {name:'B'}, {name:'C'}]; +      scope.selected = scope.values[0]; +      scope.$eval(); + +      scope.values = [{name:'B'}, {name:'C'}, {name:'D'}]; +      scope.selected = scope.values[0]; +      scope.$eval(); +      var options = select.find('option'); +      expect(options.length).toEqual(3); +      expect(sortedHtml(options[0])).toEqual('<option value="0">B</option>'); +      expect(sortedHtml(options[1])).toEqual('<option value="1">C</option>'); +      expect(sortedHtml(options[2])).toEqual('<option value="2">D</option>'); +    }); + +    it('should preserve existing options', function(){ +      createSingleSelect(true); + +      scope.$eval(); +      expect(select.find('option').length).toEqual(1); + +      scope.values = [{name:'A'}]; +      scope.selected = scope.values[0]; +      scope.$eval(); +      expect(select.find('option').length).toEqual(2); +      expect(jqLite(select.find('option')[0]).text()).toEqual('blank'); +      expect(jqLite(select.find('option')[1]).text()).toEqual('A'); + +      scope.values = []; +      scope.selected = null; +      scope.$eval(); +      expect(select.find('option').length).toEqual(1); +      expect(jqLite(select.find('option')[0]).text()).toEqual('blank'); +    }); + +    describe('binding', function(){ +      it('should bind to scope value', function(){ +        createSingleSelect(); +        scope.values = [{name:'A'}, {name:'B'}]; +        scope.selected = scope.values[0]; +        scope.$eval(); +        expect(select.val()).toEqual('0'); + +        scope.selected = scope.values[1]; +        scope.$eval(); +        expect(select.val()).toEqual('1'); +      }); + +      it('should insert a blank option if bound to null', function(){ +        createSingleSelect(); +        scope.values = [{name:'A'}]; +        scope.selected = null; +        scope.$eval(); +        expect(select.find('option').length).toEqual(2); +        expect(select.val()).toEqual(''); +        expect(jqLite(select.find('option')[0]).val()).toEqual(''); + +        scope.selected = scope.values[0]; +        scope.$eval(); +        expect(select.val()).toEqual('0'); +        expect(select.find('option').length).toEqual(1); +      }); + +      it('should reuse blank option if bound to null', function(){ +        createSingleSelect(true); +        scope.values = [{name:'A'}]; +        scope.selected = null; +        scope.$eval(); +        expect(select.find('option').length).toEqual(2); +        expect(select.val()).toEqual(''); +        expect(jqLite(select.find('option')[0]).val()).toEqual(''); + +        scope.selected = scope.values[0]; +        scope.$eval(); +        expect(select.val()).toEqual('0'); +        expect(select.find('option').length).toEqual(2); +      }); + +      it('should insert a unknown option if bound to not in list', function(){ +        createSingleSelect(); +        scope.values = [{name:'A'}]; +        scope.selected = {}; +        scope.$eval(); +        expect(select.find('option').length).toEqual(2); +        expect(select.val()).toEqual('?'); +        expect(jqLite(select.find('option')[0]).val()).toEqual('?'); + +        scope.selected = scope.values[0]; +        scope.$eval(); +        expect(select.val()).toEqual('0'); +        expect(select.find('option').length).toEqual(1); +      }); +    }); + +    describe('on change', function(){ +      it('should update model on change', function(){ +        createSingleSelect(); +        scope.values = [{name:'A'}, {name:'B'}]; +        scope.selected = scope.values[0]; +        scope.$eval(); +        expect(select.val()).toEqual('0'); + +        select.val('1'); +        browserTrigger(select, 'change'); +        expect(scope.selected).toEqual(scope.values[1]); +      }); + +      it('should update model to null on change', function(){ +        createSingleSelect(true); +        scope.values = [{name:'A'}, {name:'B'}]; +        scope.selected = scope.values[0]; +        select.val('0'); +        scope.$eval(); + +        select.val(''); +        browserTrigger(select, 'change'); +        expect(scope.selected).toEqual(null); +      }); +    }); + +    describe('select-many', function(){ +      it('should read multiple selection', function(){ +        createMultiSelect(); +        scope.values = [{name:'A'}, {name:'B'}]; + +        scope.selected = []; +        scope.$eval(); +        expect(select.find('option').length).toEqual(2); +        expect(jqLite(select.find('option')[0]).attr('selected')).toEqual(false); +        expect(jqLite(select.find('option')[1]).attr('selected')).toEqual(false); + +        scope.selected.push(scope.values[1]); +        scope.$eval(); +        expect(select.find('option').length).toEqual(2); +        expect(select.find('option')[0].selected).toEqual(false); +        expect(select.find('option')[1].selected).toEqual(true); + +        scope.selected.push(scope.values[0]); +        scope.$eval(); +        expect(select.find('option').length).toEqual(2); +        expect(select.find('option')[0].selected).toEqual(true); +        expect(select.find('option')[1].selected).toEqual(true); +      }); + +      it('should update model on change', function(){ +        createMultiSelect(); +        scope.values = [{name:'A'}, {name:'B'}]; + +        scope.selected = []; +        scope.$eval(); +        select.find('option')[0].selected = true; + +        browserTrigger(select, 'change'); +        expect(scope.selected).toEqual([scope.values[0]]); +      }); +    }); + +  }); +    describe('@ng:repeat', function() { @@ -739,10 +902,10 @@ describe("widget", function(){        var scope = compile('<ul><li ng:repeat="i dont parse"></li></ul>');        expect(scope.$service('$log').error.logs.shift()[0]). -        toEqualError("Expected ng:repeat in form of 'item in collection' but got 'i dont parse'."); +        toEqualError("Expected ng:repeat in form of '_item_ in _collection_' but got 'i dont parse'.");        expect(scope.$element.attr('ng-exception')). -        toMatch(/Expected ng:repeat in form of 'item in collection' but got 'i dont parse'/); +        toMatch(/Expected ng:repeat in form of '_item_ in _collection_' but got 'i dont parse'/);        expect(scope.$element).toHaveClass('ng-exception');        dealoc(scope);  | 
