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