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

Commit

Permalink
feat(input): add $touched and $untouched states
Browse files Browse the repository at this point in the history
Sets the ngModel controller property $touched to True and $untouched to False whenever a 'blur' event is triggered over a control with the ngModel directive.
Also adds the $setTouched and $setUntouched methods to the NgModelController.

References #583
  • Loading branch information
guzart authored and matsko committed Jun 11, 2014
1 parent 94bcc03 commit adcc5a0
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 4 deletions.
54 changes: 51 additions & 3 deletions src/ng/directive/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
-VALID_CLASS,
-INVALID_CLASS,
-PRISTINE_CLASS,
-DIRTY_CLASS
-DIRTY_CLASS,
-UNTOUCHED_CLASS,
-TOUCHED_CLASS
*/

var URL_REGEXP = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/;
Expand Down Expand Up @@ -1407,7 +1409,9 @@ var inputDirective = ['$browser', '$sniffer', '$filter', function($browser, $sni
var VALID_CLASS = 'ng-valid',
INVALID_CLASS = 'ng-invalid',
PRISTINE_CLASS = 'ng-pristine',
DIRTY_CLASS = 'ng-dirty';
DIRTY_CLASS = 'ng-dirty',
UNTOUCHED_CLASS = 'ng-untouched',
TOUCHED_CLASS = 'ng-touched';

/**
* @ngdoc type
Expand Down Expand Up @@ -1442,6 +1446,8 @@ var VALID_CLASS = 'ng-valid',
*
* @property {Object} $error An object hash with all errors as keys.
*
* @property {boolean} $untouched True if control has not lost focus yet.
* @property {boolean} $touched True if control has lost focus.
* @property {boolean} $pristine True if user has not interacted with the control yet.
* @property {boolean} $dirty True if user has already interacted with the control.
* @property {boolean} $valid True if there is no error.
Expand Down Expand Up @@ -1555,6 +1561,8 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
this.$parsers = [];
this.$formatters = [];
this.$viewChangeListeners = [];
this.$untouched = true;
this.$touched = false;
this.$pristine = true;
this.$dirty = false;
this.$valid = true;
Expand Down Expand Up @@ -1609,7 +1617,9 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$


// Setup initial state of the control
$element.addClass(PRISTINE_CLASS);
$element
.addClass(PRISTINE_CLASS)
.addClass(UNTOUCHED_CLASS);
toggleValidCss(true);

// convenience method for easy toggling of classes
Expand Down Expand Up @@ -1679,6 +1689,38 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
$animate.addClass($element, PRISTINE_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$setUntouched
*
* @description
* Sets the control to its untouched state.
*
* This method can be called to remove the 'ng-touched' class and set the control to its
* untouched state (ng-untouched class).
*/
this.$setUntouched = function() {
ctrl.$touched = false;
ctrl.$untouched = true;
$animate.setClass($element, UNTOUCHED_CLASS, TOUCHED_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$setTouched
*
* @description
* Sets the control to its touched state.
*
* This method can be called to remove the 'ng-untouched' class and set the control to its
* touched state (ng-touched class).
*/
this.$setTouched = function() {
ctrl.$touched = true;
ctrl.$untouched = false;
$animate.setClass($element, TOUCHED_CLASS, UNTOUCHED_CLASS);
};

/**
* @ngdoc method
* @name ngModel.NgModelController#$rollbackViewValue
Expand Down Expand Up @@ -2014,6 +2056,12 @@ var ngModelDirective = function() {
});
});
}

element.on('blur', function(ev) {
scope.$apply(function() {
modelCtrl.$setTouched();
});
});
}
}
};
Expand Down
2 changes: 2 additions & 0 deletions test/helpers/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ beforeEach(function() {
toBeValid: cssMatcher('ng-valid', 'ng-invalid'),
toBeDirty: cssMatcher('ng-dirty', 'ng-pristine'),
toBePristine: cssMatcher('ng-pristine', 'ng-dirty'),
toBeUntouched: cssMatcher('ng-untouched', 'ng-touched'),
toBeTouched: cssMatcher('ng-touched', 'ng-untouched'),
toBeShown: function() {
this.message = valueFn(
"Expected element " + (this.isNot ? "": "not ") + "to have 'ng-hide' class");
Expand Down
63 changes: 62 additions & 1 deletion test/ng/directive/inputSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ describe('NgModelController', function() {


it('should init the properties', function() {
expect(ctrl.$untouched).toBe(true);
expect(ctrl.$touched).toBe(false);
expect(ctrl.$dirty).toBe(false);
expect(ctrl.$pristine).toBe(true);
expect(ctrl.$valid).toBe(true);
Expand Down Expand Up @@ -133,6 +135,28 @@ describe('NgModelController', function() {
});
});

describe('setUntouched', function() {

it('should set control to its untouched state', function() {
ctrl.$setTouched();

ctrl.$setUntouched();
expect(ctrl.$touched).toBe(false);
expect(ctrl.$untouched).toBe(true);
});
});

describe('setTouched', function() {

it('should set control to its touched state', function() {
ctrl.$setUntouched();

ctrl.$setTouched();
expect(ctrl.$touched).toBe(true);
expect(ctrl.$untouched).toBe(false);
});
});

describe('view -> model', function() {

it('should set the value to $viewValue', function() {
Expand Down Expand Up @@ -265,13 +289,14 @@ describe('NgModelController', function() {

describe('ngModel', function() {

it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty)',
it('should set css classes (ng-valid, ng-invalid, ng-pristine, ng-dirty, ng-untouched, ng-touched)',
inject(function($compile, $rootScope, $sniffer) {
var element = $compile('<input type="email" ng-model="value" />')($rootScope);

$rootScope.$digest();
expect(element).toBeValid();
expect(element).toBePristine();
expect(element).toBeUntouched();
expect(element.hasClass('ng-valid-email')).toBe(true);
expect(element.hasClass('ng-invalid-email')).toBe(false);

Expand All @@ -297,6 +322,9 @@ describe('ngModel', function() {
expect(element.hasClass('ng-valid-email')).toBe(true);
expect(element.hasClass('ng-invalid-email')).toBe(false);

browserTrigger(element, 'blur');
expect(element).toBeTouched();

dealoc(element);
}));

Expand All @@ -309,6 +337,23 @@ describe('ngModel', function() {
expect(element).toHaveClass('ng-invalid-required');
}));

it('should set the control touched state on "blur" event', inject(function($compile, $rootScope) {
var element = $compile('<form name="myForm">' +
'<input name="myControl" ng-model="value" >' +
'</form>')($rootScope);
var inputElm = element.find('input');
var control = $rootScope.myForm.myControl;

expect(control.$touched).toBe(false);
expect(control.$untouched).toBe(true);

browserTrigger(inputElm, 'blur');
expect(control.$touched).toBe(true);
expect(control.$untouched).toBe(false);

dealoc(element);
}));


it('should register/deregister a nested ngModel with parent form when entering or leaving DOM',
inject(function($compile, $rootScope) {
Expand Down Expand Up @@ -2687,6 +2732,22 @@ describe('NgModel animations', function() {
assertValidAnimation(animations[1], 'addClass', 'ng-pristine');
}));

it('should trigger an animation when untouched', inject(function($animate) {
model.$setUntouched();

var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'setClass', 'ng-untouched');
expect(animations[0].args[2]).toBe('ng-touched');
}));

it('should trigger an animation when touched', inject(function($animate) {
model.$setTouched();

var animations = findElementAnimations(input, $animate.queue);
assertValidAnimation(animations[0], 'setClass', 'ng-touched', 'ng-untouched');
expect(animations[0].args[2]).toBe('ng-untouched');
}));

it('should trigger custom errors as addClass/removeClass when invalid/valid', inject(function($animate) {
model.$setValidity('custom-error', false);

Expand Down

6 comments on commit adcc5a0

@Narretz
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be a stupid question, but why is blur used and not focus?

@guzart
Copy link
Contributor Author

@guzart guzart commented on adcc5a0 Jun 12, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use case is so that you can use the property with an ng-if to show validation errors after the user moves away from the field.

@kevinjamesus86
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the blur handler always call $apply, if the $touched state is already $touched == true? I ask because I've noticed that tabbing through forms causes a digest cycle per field per blur, even after $setTouched has been called.

@guzart
Copy link
Contributor Author

@guzart guzart commented on adcc5a0 Aug 1, 2014

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevinjamesus86 Good catch, it's not necessary to perform other $digests if the control is already $touched. #8450

@kevinjamesus86
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@guzart thanks for the snappy response on this, it is much appreciated.

@redjackwong
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I met an issue with this setTouched in $apply, when an input 'blur', it goes into this $apply, and it says that a $digest already in progress. I am in a really huge project that anyone can trigger a $digest at any time. Why not use a safeApply or $evalAsync here to prevent this from happening? I have changed the source code to be $evalAsync, and the problem is gone.

Please sign in to comment.