Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
feat(ngModel): update model on each key stroke (revert ngModelInstant)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
vojtajina committed Apr 3, 2012
1 parent a22e069 commit 06d0955
Show file tree
Hide file tree
Showing 8 changed files with 76 additions and 149 deletions.
12 changes: 4 additions & 8 deletions docs/content/guide/dev_guide.forms.ngdoc
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ In addition it provides {@link api/angular.module.ng.$compileProvider.directive.
<doc:source>
<div ng-controller="Controller">
<form novalidate class="simple-form">
Name: <input type="text" ng-model="user.name" ng-model-instant /><br />
Name: <input type="text" ng-model="user.name" /><br />
E-mail: <input type="email" ng-model="user.email" /><br />
Gender: <input type="radio" ng-model="user.gender" value="male" />male
<input type="radio" ng-model="user.gender" value="female" />female<br />
Expand Down Expand Up @@ -50,11 +50,7 @@ In addition it provides {@link api/angular.module.ng.$compileProvider.directive.
</doc:example>


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.



Expand All @@ -76,7 +72,7 @@ This ensures that the user is not distracted with an error until after interacti
<div ng-controller="Controller">
<form novalidate class="css-form">
Name:
<input type="text" ng-model="user.name" ng-model-instant required /><br />
<input type="text" ng-model="user.name" required /><br />
E-mail: <input type="email" ng-model="user.email" required /><br />
Gender: <input type="radio" ng-model="user.gender" value="male" />male
<input type="radio" ng-model="user.gender" value="female" />female<br />
Expand Down Expand Up @@ -147,7 +143,7 @@ This allows us to extend the above example with these features:

<input type="checkbox" ng-model="user.agree" name="userAgree" required />
I agree: <input ng-show="user.agree" type="text" ng-model="user.agreeSign"
ng-model-instant required /><br />
required /><br />
<div ng-show="!user.agree || !user.agreeSign">Please agree and sign.</div>

<button ng-click="reset()" disabled="{{isUnchanged(user)}}">RESET</button>
Expand Down
1 change: 0 additions & 1 deletion src/AngularPublic.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ function publishExternalAPI(angular){
ngModel: ngModelDirective,
ngList: ngListDirective,
ngChange: ngChangeDirective,
ngModelInstant: ngModelInstantDirective,
required: requiredDirective,
ngRequired: requiredDirective,
ngValue: ngValueDirective
Expand Down
121 changes: 45 additions & 76 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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)) {
Expand Down Expand Up @@ -709,13 +740,14 @@ function checkboxInputType(scope, element, attr, ctrl) {
</doc:scenario>
</doc:example>
*/
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);
}
}
};
Expand Down Expand Up @@ -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
* <doc:example>
* <doc:source>
* First name: <input type="text" ng-model="firstName" /><br />
* Last name: <input type="text" ng-model="lastName" ng-model-instant /><br />
*
* First name ({{firstName}}) is only updated on `blur` event, but the last name ({{lastName}})
* is updated immediately, because of using `ng-model-instant`.
* </doc:source>
* <doc:scenario>
* 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');
* });
* </doc:scenario>
* </doc:example>
*/
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',
Expand Down
2 changes: 1 addition & 1 deletion src/ngScenario/Scenario.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
3 changes: 2 additions & 1 deletion src/ngScenario/dsl.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
};
Expand Down
14 changes: 0 additions & 14 deletions test/BinderSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,20 +142,6 @@ describe('Binder', function() {
expect(html.indexOf('action="foo();"')).toBeGreaterThan(0);
});

it('RepeaterAdd', inject(function($rootScope, $compile) {
element = $compile('<div><input type="text" ng-model="item.x" ng-repeat="item in items"></div>')($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('<div><div ng-repeat="i in items">{{i}}</div></div>')($rootScope);
var items = {};
Expand Down
15 changes: 9 additions & 6 deletions test/ng/directive/formSpec.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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');
Expand Down
Loading

0 comments on commit 06d0955

Please sign in to comment.