diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index aa73a5f188cd..7c46c2b24477 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -7,6 +7,7 @@ UNTOUCHED_CLASS: false, TOUCHED_CLASS: false, ngModelMinErr: false, + KEYS_PER_DATE_INPUT_TYPE: true */ // Regex code is obtained from SO: https://stackoverflow.com/questions/3143070/javascript-regex-iso-datetime#answer-3143231 @@ -20,6 +21,34 @@ var WEEK_REGEXP = /^(\d{4})-W(\d\d)$/; var MONTH_REGEXP = /^(\d{4})-(\d\d)$/; var TIME_REGEXP = /^(\d\d):(\d\d)(?::(\d\d)(\.\d{1,3})?)?$/; +var KEYS = { + backspace: 8, + del: 46, + down: 40, + up: 38 +}; +var KEY_RANGES = { + numeric: [48, 57], + numpadNumeric: [96, 105], + alpha: [65, 90] +}; +var DEFAULT_KEYS_FOR_DATE_INPUT_TYPE = [ + KEY_RANGES.numeric, + KEY_RANGES.numpadNumeric, + KEYS.up, + KEYS.down, + KEYS.backspace, + KEYS.del +]; +var KEYS_PER_DATE_INPUT_TYPE = { + date: DEFAULT_KEYS_FOR_DATE_INPUT_TYPE, + 'datetime-local': DEFAULT_KEYS_FOR_DATE_INPUT_TYPE, + month: DEFAULT_KEYS_FOR_DATE_INPUT_TYPE.concat([KEY_RANGES.alpha]), + time: DEFAULT_KEYS_FOR_DATE_INPUT_TYPE, + week: DEFAULT_KEYS_FOR_DATE_INPUT_TYPE +}; +var DATE_INPUT_TYPES = Object.keys(KEYS_PER_DATE_INPUT_TYPE); + var inputType = { /** @@ -1087,6 +1116,24 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { stringBasedInputType(ctrl); } +function createKeyupListener(type, callback) { + // A list containing single keyCodes and keyCode-ranges (in the form [min, max]) + var keys = KEYS_PER_DATE_INPUT_TYPE[type]; + + return function keyupListener(evt) { + // Ignore if a modifier key is also pressed + if (!evt || evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey) return; + + var key = evt.keyCode; + var affectsInput = keys.some(function(keyOrRange) { + return !isArray(keyOrRange) + ? (key === keyOrRange) : (keyOrRange[0] <= key) && (key <= keyOrRange[1]); + }); + + if (affectsInput) callback('input'); + }; +} + function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { var type = lowercase(element[0].type); @@ -1134,6 +1181,15 @@ function baseInputType(scope, element, attr, ctrl, $sniffer, $browser) { // input event on backspace, delete or cut if ($sniffer.hasEvent('input')) { element.on('input', listener); + + // On date-family inputs, we also need to listen for `keyup` in case the date is partially + // edited by the user using the keyboard, resulting in a change in the validity state, but + // without an accompanying change in the input value (thus no `input` event). + // (This is only necessary on browsers that support inputs of that type - other browsers set the + // `type` property to "text".) + var browserSupportsType = (type === attr.type); + var listenForKeyup = browserSupportsType && (DATE_INPUT_TYPES.indexOf(type) !== -1); + if (listenForKeyup) element.on('keyup', createKeyupListener(type, listener)); } else { var timeout; @@ -1756,6 +1812,3 @@ var ngValueDirective = function() { } }; }; - - - diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index 053c64931cfe..36887a573221 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -1,6 +1,8 @@ 'use strict'; -/* globals getInputCompileHelper: false */ +/* globals getInputCompileHelper: false, + KEYS_PER_DATE_INPUT_TYPE: false + */ describe('input', function() { var helper, $compile, $rootScope, $browser, $sniffer, $timeout, $q; @@ -645,6 +647,7 @@ describe('input', function() { expect(inputElm.val()).toBe('2013-12'); }); + it('should only change the month of a bound date in any timezone', function() { var inputElm = helper.compileInput(''); @@ -656,6 +659,106 @@ describe('input', function() { expect(inputElm.val()).toBe('2013-09'); }); + + they('should re-validate when partially editing the input value (keyCode: $prop)', + getKeyCodesForType('month'), + function(keyCode) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'month'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeInvalid(); + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + they('should not re-validate when a modifier key is pressed (modifier: $prop)', + ['alt', 'ctrl', 'meta', 'shift'], + function(modifier) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'month'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + var mockEvt = {type: 'keyup', keyCode: 8}; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + + mockEvt[modifier + 'Key'] = true; + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + } + } + ); + + + they('should not re-validate on keys that can\'t affect the input (keyCode: $prop)', + getIgnoredKeyCodesForType('month'), + function(keyCode) { + var inputType = 'month'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + it('should listen for \'keyup\' only on browsers that support this input type', function() { + var inputType = 'month'; + + var shouldListenForKeyup = browserSupportsInputTypeAndEvent(inputType, 'input'); + var keyCode = getKeyCodesForType(inputType)[0]; + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + spyOn(ctrl, '$setViewValue'); + + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + + expect(ctrl.$setViewValue.callCount).toBe(shouldListenForKeyup ? 1 : 0); + }); + + describe('min', function() { var inputElm; beforeEach(function() { @@ -698,6 +801,7 @@ describe('input', function() { }); }); + describe('max', function() { var inputElm; beforeEach(function() { @@ -870,6 +974,106 @@ describe('input', function() { expect($rootScope.form.alias.$error.week).toBeTruthy(); }); + + they('should re-validate when partially editing the input value (keyCode: $prop)', + getKeyCodesForType('week'), + function(keyCode) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'week'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeInvalid(); + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + they('should not re-validate when a modifier key is pressed (modifier: $prop)', + ['alt', 'ctrl', 'meta', 'shift'], + function(modifier) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'week'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + var mockEvt = {type: 'keyup', keyCode: 8}; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + + mockEvt[modifier + 'Key'] = true; + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + } + } + ); + + + they('should not re-validate on keys that can\'t affect the input (keyCode: $prop)', + getIgnoredKeyCodesForType('week'), + function(keyCode) { + var inputType = 'week'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + it('should listen for \'keyup\' only on browsers that support this input type', function() { + var inputType = 'week'; + + var shouldListenForKeyup = browserSupportsInputTypeAndEvent(inputType, 'input'); + var keyCode = getKeyCodesForType(inputType)[0]; + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + spyOn(ctrl, '$setViewValue'); + + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + + expect(ctrl.$setViewValue.callCount).toBe(shouldListenForKeyup ? 1 : 0); + }); + + describe('min', function() { var inputElm; beforeEach(function() { @@ -912,6 +1116,7 @@ describe('input', function() { }); }); + describe('max', function() { var inputElm; @@ -1119,6 +1324,106 @@ describe('input', function() { expect($rootScope.form.alias.$error.datetimelocal).toBeTruthy(); }); + + they('should re-validate when partially editing the input value (keyCode: $prop)', + getKeyCodesForType('datetime-local'), + function(keyCode) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'datetime-local'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeInvalid(); + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + they('should not re-validate when a modifier key is pressed (modifier: $prop)', + ['alt', 'ctrl', 'meta', 'shift'], + function(modifier) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'datetime-local'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + var mockEvt = {type: 'keyup', keyCode: 8}; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + + mockEvt[modifier + 'Key'] = true; + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + } + } + ); + + + they('should not re-validate on keys that can\'t affect the input (keyCode: $prop)', + getIgnoredKeyCodesForType('datetime-local'), + function(keyCode) { + var inputType = 'datetime-local'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + it('should listen for \'keyup\' only on browsers that support this input type', function() { + var inputType = 'datetime-local'; + + var shouldListenForKeyup = browserSupportsInputTypeAndEvent(inputType, 'input'); + var keyCode = getKeyCodesForType(inputType)[0]; + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + spyOn(ctrl, '$setViewValue'); + + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + + expect(ctrl.$setViewValue.callCount).toBe(shouldListenForKeyup ? 1 : 0); + }); + + describe('min', function() { var inputElm; beforeEach(function() { @@ -1161,6 +1466,7 @@ describe('input', function() { }); }); + describe('max', function() { var inputElm; beforeEach(function() { @@ -1444,6 +1750,106 @@ describe('input', function() { expect(+$rootScope.value).toBe(+new Date(2013, 2, 3, 1, 2, 0)); }); + + they('should re-validate when partially editing the input value (keyCode: $prop)', + getKeyCodesForType('time'), + function(keyCode) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'time'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeInvalid(); + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + they('should not re-validate when a modifier key is pressed (modifier: $prop)', + ['alt', 'ctrl', 'meta', 'shift'], + function(modifier) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'time'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + var mockEvt = {type: 'keyup', keyCode: 8}; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + + mockEvt[modifier + 'Key'] = true; + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + } + } + ); + + + they('should not re-validate on keys that can\'t affect the input (keyCode: $prop)', + getIgnoredKeyCodesForType('time'), + function(keyCode) { + var inputType = 'time'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + it('should listen for \'keyup\' only on browsers that support this input type', function() { + var inputType = 'time'; + + var shouldListenForKeyup = browserSupportsInputTypeAndEvent(inputType, 'input'); + var keyCode = getKeyCodesForType(inputType)[0]; + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + spyOn(ctrl, '$setViewValue'); + + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + + expect(ctrl.$setViewValue.callCount).toBe(shouldListenForKeyup ? 1 : 0); + }); + + describe('min', function() { var inputElm; beforeEach(function() { @@ -1486,6 +1892,7 @@ describe('input', function() { }); }); + describe('max', function() { var inputElm; beforeEach(function() { @@ -1744,6 +2151,106 @@ describe('input', function() { dealoc(formElm); }); + + they('should re-validate when partially editing the input value (keyCode: $prop)', + getKeyCodesForType('date'), + function(keyCode) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'date'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeInvalid(); + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + they('should not re-validate when a modifier key is pressed (modifier: $prop)', + ['alt', 'ctrl', 'meta', 'shift'], + function(modifier) { + // This testcase simulates keyboard interactions that lead to a change in the validity state + // of the element, but no change in its value (thus no 'input' event). + // For a more detailed explanation, see: https://github.com/angular/angular.js/pull/12902 + + var inputType = 'date'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + var mockEvt = {type: 'keyup', keyCode: 8}; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + + mockEvt[modifier + 'Key'] = true; + + mockValidity.valid = true; + mockValidity.badInput = false; + inputElm.triggerHandler(mockEvt); + expect(inputElm).toBeInvalid(); + } + } + ); + + + they('should not re-validate on keys that can\'t affect the input (keyCode: $prop)', + getIgnoredKeyCodesForType('date'), + function(keyCode) { + var inputType = 'date'; + + if (browserSupportsInputTypeAndEvent(inputType, 'input')) { + var mockValidity = {valid: true, badInput: false}; + helper.compileInput('', mockValidity); + var inputElm = helper.inputElm; + + expect(inputElm).toBeValid(); + + mockValidity.valid = false; + mockValidity.badInput = true; + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + expect(inputElm).toBeValid(); + } + } + ); + + + it('should listen for \'keyup\' only on browsers that support this input type', function() { + var inputType = 'date'; + + var shouldListenForKeyup = browserSupportsInputTypeAndEvent(inputType, 'input'); + var keyCode = getKeyCodesForType(inputType)[0]; + var inputElm = helper.compileInput(''); + var ctrl = inputElm.controller('ngModel'); + spyOn(ctrl, '$setViewValue'); + + inputElm.triggerHandler({type: 'keyup', keyCode: keyCode}); + + expect(ctrl.$setViewValue.callCount).toBe(shouldListenForKeyup ? 1 : 0); + }); + + describe('min', function() { it('should invalidate', function() { @@ -1783,6 +2290,7 @@ describe('input', function() { }); }); + describe('max', function() { it('should invalidate', function() { @@ -2859,4 +3367,45 @@ describe('input', function() { dealoc(inputElm); }); }); + + + // Helpers + function browserSupportsInputTypeAndEvent(inputType, event) { + var input = jqLite(''); + + var supportsType = (inputType === input.prop('type')); + var supportsEvent = $sniffer.hasEvent(event); + + return supportsType && supportsEvent; + } + + function getKeyCodesForType(type) { + var keyCodes = []; + + (KEYS_PER_DATE_INPUT_TYPE[type] || []).forEach(function(keyOrRange) { + if (isArray(keyOrRange)) { + var min = keyOrRange[0]; + var max = keyOrRange[1]; + + for (var i = min; i <= max; i++) { + keyCodes.push(i); + } + } else { + keyCodes.push(keyOrRange); + } + }); + + return keyCodes; + } + + function getIgnoredKeyCodesForType(type) { + var keyCodes = []; + + var notIgnored = getKeyCodesForType(type); + for (var i = 1; i <= 222; i++) { + if (notIgnored.indexOf(i) === -1) keyCodes.push(i); + } + + return keyCodes; + } });