diff options
Diffstat (limited to 'src/widget')
| -rw-r--r-- | src/widget/form.js | 81 | ||||
| -rw-r--r-- | src/widget/input.js | 773 | ||||
| -rw-r--r-- | src/widget/select.js | 427 | 
3 files changed, 1281 insertions, 0 deletions
| 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(); +        } +      }; +    } +  }); +}); | 
