From 91f4dcdcf99c0c58f61aa21698f10c630f76e5af Mon Sep 17 00:00:00 2001 From: Arturo Guzman Date: Tue, 3 Jun 2014 00:58:04 -0400 Subject: [PATCH] feat(input): add $touched and $untouched states 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 --- src/ng/directive/input.js | 54 +++++++++++++++++++++++++++-- test/helpers/matchers.js | 2 ++ test/ng/directive/inputSpec.js | 63 +++++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index a526fb553f89..a6aa95f831c5 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -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#!:.?+=&%@!\-\/]))?$/; @@ -1410,7 +1412,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 @@ -1445,6 +1449,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. @@ -1558,6 +1564,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; @@ -1612,7 +1620,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 @@ -1682,6 +1692,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 @@ -2017,6 +2059,12 @@ var ngModelDirective = function() { }); }); } + + element.on('blur', function(ev) { + scope.$apply(function() { + modelCtrl.$setTouched(); + }); + }); } } }; diff --git a/test/helpers/matchers.js b/test/helpers/matchers.js index b18604bf9ba4..421feb061de8 100644 --- a/test/helpers/matchers.js +++ b/test/helpers/matchers.js @@ -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"); diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index c026f9a455a9..fd1025c7801f 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -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); @@ -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() { @@ -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('')($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); @@ -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); })); @@ -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('
' + + '' + + '
')($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) { @@ -2633,6 +2678,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);