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/widget/select.js | |
| parent | df6d2ba3266de405ad6c2f270f24569355706e76 (diff) | |
| download | angular.js-4f78fd692c0ec51241476e6be9a4df06cd62fdd6.tar.bz2 | |
feat(forms): new and improved forms
Diffstat (limited to 'src/widget/select.js')
| -rw-r--r-- | src/widget/select.js | 427 | 
1 files changed, 427 insertions, 0 deletions
| 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(); +        } +      }; +    } +  }); +}); | 
