diff options
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(); + } + }; + } + }); +}); |
