aboutsummaryrefslogtreecommitdiffstats
path: root/src/widget/select.js
diff options
context:
space:
mode:
authorMisko Hevery2011-09-08 13:56:29 -0700
committerIgor Minar2011-10-11 11:01:45 -0700
commit4f78fd692c0ec51241476e6be9a4df06cd62fdd6 (patch)
tree91f70bb89b9c095126fbc093f51cedbac5cb0c78 /src/widget/select.js
parentdf6d2ba3266de405ad6c2f270f24569355706e76 (diff)
downloadangular.js-4f78fd692c0ec51241476e6be9a4df06cd62fdd6.tar.bz2
feat(forms): new and improved forms
Diffstat (limited to 'src/widget/select.js')
-rw-r--r--src/widget/select.js427
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();
+ }
+ };
+ }
+ });
+});