From 06d0955074f79de553cc34fbf945045dc458e064 Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 30 Mar 2012 12:07:19 -0700 Subject: [PATCH] feat(ngModel): update model on each key stroke (revert ngModelInstant) It turns out that listening only on "blur" event is not sufficient in many scenarios, especially when you use form validation you always had to use ngModelnstant e.g. if you want to disable a button based on valid/invalid form. The feedback we got from our apps as well as external apps is that the ngModelInstant should be the default. In the future we might provide alternative ways of suppressing updates on each key stroke, but it's not going to be the default behavior. Apps already using the ngModelInstant can safely remove it from their templates. Input fields without ngModelInstant directive will start propagating the input changes into the model on each key stroke. --- docs/content/guide/dev_guide.forms.ngdoc | 12 +-- src/AngularPublic.js | 1 - src/ng/directive/input.js | 121 +++++++++-------------- src/ngScenario/Scenario.js | 2 +- src/ngScenario/dsl.js | 3 +- test/BinderSpec.js | 14 --- test/ng/directive/formSpec.js | 15 +-- test/ng/directive/inputSpec.js | 57 +++-------- 8 files changed, 76 insertions(+), 149 deletions(-) diff --git a/docs/content/guide/dev_guide.forms.ngdoc b/docs/content/guide/dev_guide.forms.ngdoc index c643a613e194..0c3a7fc34957 100644 --- a/docs/content/guide/dev_guide.forms.ngdoc +++ b/docs/content/guide/dev_guide.forms.ngdoc @@ -20,7 +20,7 @@ In addition it provides {@link api/angular.module.ng.$compileProvider.directive.
- Name:
+ Name:
E-mail:
Gender: male female
@@ -50,11 +50,7 @@ In addition it provides {@link api/angular.module.ng.$compileProvider.directive. -Note that: - - * the {@link api/angular.module.ng.$compileProvider.directive.ng-model-instant ng-model-instant} causes the `user.name` to be updated immediately. - - * `novalidate` is used to disable browser's native form validation. +Note that `novalidate` is used to disable browser's native form validation. @@ -76,7 +72,7 @@ This ensures that the user is not distracted with an error until after interacti
Name: -
+
E-mail:
Gender: male female
@@ -147,7 +143,7 @@ This allows us to extend the above example with these features: I agree:
+ required />
Please agree and sign.
diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 8597de9cd98a..1326bf8ce9f4 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -98,7 +98,6 @@ function publishExternalAPI(angular){ ngModel: ngModelDirective, ngList: ngListDirective, ngChange: ngChangeDirective, - ngModelInstant: ngModelInstantDirective, required: requiredDirective, ngRequired: requiredDirective, ngValue: ngValueDirective diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index 3d53f7caa73a..aab12e3423bf 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -367,12 +367,43 @@ function isEmpty(value) { } -function textInputType(scope, element, attr, ctrl) { - element.bind('blur', function() { - scope.$apply(function() { - ctrl.$setViewValue(trim(element.val())); +function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { + + var listener = function() { + var value = trim(element.val()); + + if (ctrl.$viewValue !== value) { + scope.$apply(function() { + ctrl.$setViewValue(value); + }); + } + }; + + // if the browser does support "input" event, we are fine + if ($sniffer.hasEvent('input')) { + element.bind('input', listener); + } else { + var timeout; + + element.bind('keydown', function(event) { + var key = event.keyCode; + + // ignore + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + + if (!timeout) { + timeout = $browser.defer(function() { + listener(); + timeout = null; + }); + } }); - }); + + // if user paste into input using mouse, we need "change" event to catch it + element.bind('change', listener); + } + ctrl.$render = function() { element.val(isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); @@ -448,8 +479,8 @@ function textInputType(scope, element, attr, ctrl) { } }; -function numberInputType(scope, element, attr, ctrl) { - textInputType(scope, element, attr, ctrl); +function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); ctrl.$parsers.push(function(value) { var empty = isEmpty(value); @@ -510,8 +541,8 @@ function numberInputType(scope, element, attr, ctrl) { }); } -function urlInputType(scope, element, attr, ctrl) { - textInputType(scope, element, attr, ctrl); +function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); var urlValidator = function(value) { if (isEmpty(value) || URL_REGEXP.test(value)) { @@ -527,8 +558,8 @@ function urlInputType(scope, element, attr, ctrl) { ctrl.$parsers.push(urlValidator); } -function emailInputType(scope, element, attr, ctrl) { - textInputType(scope, element, attr, ctrl); +function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, $sniffer, $browser); var emailValidator = function(value) { if (isEmpty(value) || EMAIL_REGEXP.test(value)) { @@ -709,13 +740,14 @@ function checkboxInputType(scope, element, attr, ctrl) { */ -var inputDirective = [function() { +var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { return { restrict: 'E', require: '?ngModel', link: function(scope, element, attr, ctrl) { if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl); + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, + $browser); } } }; @@ -1004,69 +1036,6 @@ var ngChangeDirective = valueFn({ }); -/** - * @ngdoc directive - * @name angular.module.ng.$compileProvider.directive.ng-model-instant - * - * @element input - * - * @description - * By default, Angular udpates the model only on `blur` event - when the input looses focus. - * If you want to update after every key stroke, use `ng-model-instant`. - * - * @example - * - * - * First name:
- * Last name:
- * - * First name ({{firstName}}) is only updated on `blur` event, but the last name ({{lastName}}) - * is updated immediately, because of using `ng-model-instant`. - *
- * - * it('should update first name on blur', function() { - * input('firstName').enter('santa', 'blur'); - * expect(binding('firstName')).toEqual('santa'); - * }); - * - * it('should update last name immediately', function() { - * input('lastName').enter('santa', 'keydown'); - * expect(binding('lastName')).toEqual('santa'); - * }); - * - *
- */ -var ngModelInstantDirective = ['$browser', function($browser) { - return { - require: 'ngModel', - link: function(scope, element, attr, ctrl) { - var handler = function() { - scope.$apply(function() { - ctrl.$setViewValue(trim(element.val())); - }); - }; - - var timeout; - element.bind('keydown', function(event) { - var key = event.keyCode; - - // command modifiers arrows - if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; - - if (!timeout) { - timeout = $browser.defer(function() { - handler(); - timeout = null; - }); - } - }); - - element.bind('change input', handler); - } - }; -}]; - - var requiredDirective = [function() { return { require: '?ngModel', diff --git a/src/ngScenario/Scenario.js b/src/ngScenario/Scenario.js index cd3c335fa6de..7335d5deacc4 100644 --- a/src/ngScenario/Scenario.js +++ b/src/ngScenario/Scenario.js @@ -327,7 +327,7 @@ function browserTrigger(element, type, keys) { (function(fn){ var parentTrigger = fn.trigger; fn.trigger = function(type) { - if (/(click|change|keydown|blur)/.test(type)) { + if (/(click|change|keydown|blur|input)/.test(type)) { var processDefaults = []; this.each(function(index, node) { processDefaults.push(browserTrigger(node, type)); diff --git a/src/ngScenario/dsl.js b/src/ngScenario/dsl.js index 8a1bccb108a2..7f399394562e 100644 --- a/src/ngScenario/dsl.js +++ b/src/ngScenario/dsl.js @@ -198,12 +198,13 @@ angular.scenario.dsl('binding', function() { */ angular.scenario.dsl('input', function() { var chain = {}; + var supportInputEvent = 'oninput' in document.createElement('div'); chain.enter = function(value, event) { return this.addFutureAction("input '" + this.name + "' enter '" + value + "'", function($window, $document, done) { var input = $document.elements('[ng\\:model="$1"]', this.name).filter(':input'); input.val(value); - input.trigger(event || 'blur'); + input.trigger(event || supportInputEvent && 'input' || 'change'); done(); }); }; diff --git a/test/BinderSpec.js b/test/BinderSpec.js index 306cbc437320..6d5dd91e0f2f 100644 --- a/test/BinderSpec.js +++ b/test/BinderSpec.js @@ -142,20 +142,6 @@ describe('Binder', function() { expect(html.indexOf('action="foo();"')).toBeGreaterThan(0); }); - it('RepeaterAdd', inject(function($rootScope, $compile) { - element = $compile('
')($rootScope); - $rootScope.items = [{x:'a'}, {x:'b'}]; - $rootScope.$apply(); - var first = childNode(element, 1); - var second = childNode(element, 2); - expect(first.val()).toEqual('a'); - expect(second.val()).toEqual('b'); - - first.val('ABC'); - browserTrigger(first, 'blur'); - expect($rootScope.items[0].x).toEqual('ABC'); - })); - it('ItShouldRemoveExtraChildrenWhenIteratingOverHash', inject(function($rootScope, $compile) { element = $compile('
{{i}}
')($rootScope); var items = {}; diff --git a/test/ng/directive/formSpec.js b/test/ng/directive/formSpec.js index 5c34b5ad34dd..491a31dba10b 100644 --- a/test/ng/directive/formSpec.js +++ b/test/ng/directive/formSpec.js @@ -1,7 +1,7 @@ 'use strict'; describe('form', function() { - var doc, control, scope, $compile; + var doc, control, scope, $compile, changeInputValue; beforeEach(module(function($compileProvider) { $compileProvider.directive('storeModelCtrl', function() { @@ -14,9 +14,14 @@ describe('form', function() { }); })); - beforeEach(inject(function($injector) { + beforeEach(inject(function($injector, $sniffer) { $compile = $injector.get('$compile'); scope = $injector.get('$rootScope'); + + changeInputValue = function(elm, value) { + elm.val(value); + browserTrigger(elm, $sniffer.hasEvent('input') ? 'input' : 'change'); + }; })); afterEach(function() { @@ -126,10 +131,8 @@ describe('form', function() { var inputA = doc.find('input').eq(0), inputB = doc.find('input').eq(1); - inputA.val('val1'); - browserTrigger(inputA, 'blur'); - inputB.val('val2'); - browserTrigger(inputB, 'blur'); + changeInputValue(inputA, 'val1'); + changeInputValue(inputB, 'val2'); expect(scope.firstName).toBe('val1'); expect(scope.lastName).toBe('val2'); diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 734cb34dcc97..395f37c38a83 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -236,7 +236,7 @@ describe('NgModelController', function() { describe('ng-model', function() { it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty)', - inject(function($compile, $rootScope) { + inject(function($compile, $rootScope, $sniffer) { var element = $compile('')($rootScope); $rootScope.$digest(); @@ -254,14 +254,14 @@ describe('ng-model', function() { expect(element.hasClass('ng-invalid-email')).toBe(true); element.val('invalid-again'); - browserTrigger(element, 'blur'); + browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); expect(element).toBeInvalid(); expect(element).toBeDirty(); expect(element.hasClass('ng-valid-email')).toBe(false); expect(element.hasClass('ng-invalid-email')).toBe(true); element.val('vojta@google.com'); - browserTrigger(element, 'blur'); + browserTrigger(element, $sniffer.hasEvent('input') ? 'input' : 'change'); expect(element).toBeValid(); expect(element).toBeDirty(); expect(element.hasClass('ng-valid-email')).toBe(true); @@ -282,7 +282,7 @@ describe('ng-model', function() { describe('input', function() { - var formElm, inputElm, scope, $compile; + var formElm, inputElm, scope, $compile, changeInputValueTo; function compileInput(inputHtml) { formElm = jqLite('' + inputHtml + ''); @@ -290,14 +290,14 @@ describe('input', function() { $compile(formElm)(scope); } - function changeInputValueTo(value) { - inputElm.val(value); - browserTrigger(inputElm, 'blur'); - } - - beforeEach(inject(function($injector) { + beforeEach(inject(function($injector, $sniffer) { $compile = $injector.get('$compile'); scope = $injector.get('$rootScope'); + + changeInputValueTo = function(value) { + inputElm.val(value); + browserTrigger(inputElm, $sniffer.hasEvent('input') ? 'input' : 'change'); + }; })); afterEach(function() { @@ -379,7 +379,7 @@ describe('input', function() { it('should ignore input without ng-model attr', function() { compileInput(''); - browserTrigger(inputElm, 'blur'); + changeInputValueTo(''); expect(inputElm.hasClass('ng-valid')).toBe(false); expect(inputElm.hasClass('ng-invalid')).toBe(false); expect(inputElm.hasClass('ng-pristine')).toBe(false); @@ -715,7 +715,7 @@ describe('input', function() { expect(inputElm[1].checked).toBe(true); expect(inputElm[2].checked).toBe(false); - browserTrigger(inputElm[2]); + browserTrigger(inputElm[2], 'click'); expect(scope.color).toBe('blue'); }); @@ -735,7 +735,7 @@ describe('input', function() { expect(inputElm[0].checked).toBe(true); expect(inputElm[1].checked).toBe(false); - browserTrigger(inputElm[1]); + browserTrigger(inputElm[1], 'click'); expect(scope.value).toBe('red'); scope.$apply(function() { @@ -753,7 +753,7 @@ describe('input', function() { it('should ignore checkbox without ng-model attr', function() { compileInput(''); - browserTrigger(inputElm, 'blur'); + changeInputValueTo(''); expect(inputElm.hasClass('ng-valid')).toBe(false); expect(inputElm.hasClass('ng-invalid')).toBe(false); expect(inputElm.hasClass('ng-pristine')).toBe(false); @@ -851,7 +851,7 @@ describe('input', function() { compileInput(''); inputElm = formElm.find('textarea'); - browserTrigger(inputElm, 'blur'); + changeInputValueTo(''); expect(inputElm.hasClass('ng-valid')).toBe(false); expect(inputElm.hasClass('ng-invalid')).toBe(false); expect(inputElm.hasClass('ng-pristine')).toBe(false); @@ -1053,33 +1053,6 @@ describe('input', function() { }); - describe('ng-model-instant', function() { - - it('should bind keydown, change, input events', inject(function($browser) { - compileInput(''); - - inputElm.val('value1'); - browserTrigger(inputElm, 'keydown'); - - // should be async (because of keydown) - expect(scope.value).toBeUndefined(); - - $browser.defer.flush(); - expect(scope.value).toBe('value1'); - - inputElm.val('value2'); - browserTrigger(inputElm, 'change'); - expect(scope.value).toBe('value2'); - - if (msie < 9) return; - - inputElm.val('value3'); - browserTrigger(inputElm, 'input'); - expect(scope.value).toBe('value3'); - })); - }); - - describe('ng-value', function() { it('should evaluate and set constant expressions', function() {