diff --git a/angularFiles.js b/angularFiles.js index 1647ba48481a..ffe6dc7ef936 100755 --- a/angularFiles.js +++ b/angularFiles.js @@ -63,6 +63,7 @@ angularFiles = { 'src/ng/directive/ngStyle.js', 'src/ng/directive/ngSwitch.js', 'src/ng/directive/ngTransclude.js', + 'src/ng/directive/ngUpdateModel.js', 'src/ng/directive/script.js', 'src/ng/directive/select.js', 'src/ng/directive/style.js' diff --git a/docs/content/guide/forms.ngdoc b/docs/content/guide/forms.ngdoc index 0b91fc61f8ee..62ce6744f9d0 100644 --- a/docs/content/guide/forms.ngdoc +++ b/docs/content/guide/forms.ngdoc @@ -180,6 +180,46 @@ This allows us to extend the above example with these features: +# Non-immediate (debounced) or custom triggered model updates + +By default, any change on the content will trigger a model update and form validation. You can override this behavior using the `ng-update-model-on` +attribute to bind only to a comma-delimited list of events. I.e. `ng-update-model-on="blur"` will update and validate only after the control loses +focus. + +If you want to keep the default behavior and just add new events that may trigger the model update +and validation, add "default" as one of the specified events. I.e. `ng-update-model-on="default,mousedown"` + +You can delay the model update/validation using `ng-update-model-debounce`. I.e. `ng-update-model-debounce="500"` will wait for half a second since +the last content change before triggering the model update and form validation. This debouncing feature is not available on radio buttons. + +Custom debouncing timeouts can be set for each event for each event if you use an object in `ng-update-model-on`. +I.e. `ng-update-model-on="{default: 500, blur: 0}"` + +Using the object notation allows any valid Angular expression to be used inside, including data and function calls from the scope. + +If those attributes are added to an element, they will be applied to all the child elements and controls that inherit from it unless they are +overriden. + +The following example shows how to override immediate updates. Changes on the inputs within the form will update the model +only when the control loses focus (blur event). + + + +
+
+ Name: +
+
+
model = {{user | json}}
+
+ +
+
+ # Custom Validation diff --git a/src/AngularPublic.js b/src/AngularPublic.js index 0c02adeca685..67da68e7b5b5 100644 --- a/src/AngularPublic.js +++ b/src/AngularPublic.js @@ -48,6 +48,8 @@ ngValueDirective, ngAttributeAliasDirectives, ngEventDirectives, + ngUpdateModelOnDirective, + ngUpdateModelDebounceDirective, $AnchorScrollProvider, $AnimateProvider, @@ -183,6 +185,8 @@ function publishExternalAPI(angular){ ngChange: ngChangeDirective, required: requiredDirective, ngRequired: requiredDirective, + ngUpdateModelOn: ngUpdateModelOnDirective, + ngUpdateModelDebounce: ngUpdateModelDebounceDirective, ngValue: ngValueDirective }). directive({ diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js index c31bb4004947..f1869efa1d48 100644 --- a/src/ng/directive/input.js +++ b/src/ng/directive/input.js @@ -37,6 +37,12 @@ var inputType = { * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. * @param {boolean=} [ngTrim=true] If set to false Angular will not automatically trim the input. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update (debouncing). + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. You can also specify an + * object in which the key is the event and the value the particular timeout to be applied to it. * * @example @@ -117,6 +123,11 @@ var inputType = { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -192,6 +203,11 @@ var inputType = { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -268,6 +284,11 @@ var inputType = { * patterns defined as scope expressions. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -334,6 +355,9 @@ var inputType = { * interaction with the input element. * @param {string} ngValue Angular expression which sets the value to which the expression should * be set when selected. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -384,6 +408,11 @@ var inputType = { * @param {string=} ngFalseValue The value to which the expression should be set when not selected. * @param {string=} ngChange Angular expression to be executed when input changes due to user * interaction with the input element. + * @param {integer=} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + * @param {string=} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. * * @example @@ -454,7 +483,7 @@ function addNativeHtml5Validators(ctrl, validatorName, element) { } } -function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { +function textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { var validity = element.prop('validity'); // In composition mode, users are still inputing intermediate text buffer, // hold the listener until composition is done. @@ -472,16 +501,27 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { }); } - var listener = function() { - if (composing) return; - var value = element.val(); + var timeout = null, + eventList, + updateTimeout, + updateDefaultTimeout; - // By default we will trim the value - // If the attribute ng-trim exists we will avoid trimming - // e.g. - if (toBoolean(attr.ngTrim || 'T')) { - value = trim(value); - } + var isEmpty = function(value) { + return isUndefined(value) || value === '' || value === null || value !== value; + }; + + // Get update model details from controllers + if (isDefined(updModOnCtrl)) { + eventList = updModOnCtrl.$getEventList(); + updateTimeout = updModOnCtrl.$getDebounceTimeout(); + } + + if (isDefined(updModlTimCtrl)) { + updateDefaultTimeout = updModlTimCtrl.$getDefaultTimeout(); + } + + var update = function() { + var value = getValue(); if (ctrl.$viewValue !== value || // If the value is still empty/falsy, and there is no `required` error, run validators @@ -498,41 +538,82 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { } }; - // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the - // input event on backspace, delete or cut - if ($sniffer.hasEvent('input')) { - element.on('input', listener); - } else { - var timeout; + var listener = function(event) { + if (composing) return; - var deferListener = function() { - if (!timeout) { - timeout = $browser.defer(function() { - listener(); - timeout = null; - }); - } - }; + var value = element.val(); - element.on('keydown', function(event) { - var key = event.keyCode; + // By default we will trim the value + // If the attribute ng-trim exists we will avoid trimming + // e.g. + if (toBoolean(attr.ngTrim || 'T')) { + value = trim(value); + } + + var callbackTimeout = (!isEmpty(updateTimeout)) + ? updateTimeout[event.type] || updateTimeout['default'] || updateDefaultTimeout || 0 + : updateDefaultTimeout || 0; - // ignore - // command modifiers arrows - if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + if (callbackTimeout>0) { + timeout = $timeout(update, callbackTimeout, false, timeout); + } + else { + update(); + } + }; - deferListener(); + var deferListener = function(ev) { + $browser.defer(function() { + listener(ev); }); + }; - // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it - if ($sniffer.hasEvent('paste')) { - element.on('paste cut', deferListener); - } + var defaultEvents = true; + + // Allow adding/overriding bound events + if (!isEmpty(eventList)) { + defaultEvents = false; + // bind to user-defined events + forEach(eventList.split(','), function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + defaultEvents = true; + } + else { + element.on(ev, listener); + } + }); } - // if user paste into input using mouse on older browser - // or form autocomplete on newer browser, we need "change" event to catch it - element.on('change', listener); + if (defaultEvents) { + + // default behavior: bind to input events or keydown+change + + // if the browser does support "input" event, we are fine - except on IE9 which doesn't fire the + // input event on backspace, delete or cut + if ($sniffer.hasEvent('input')) { + element.bind('input', listener); + } else { + element.on('keydown', function(event) { + var key = event.keyCode; + + // ignore + // command modifiers arrows + if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return; + + deferListener('keydown'); + }); + + // if user paste into input using mouse on older browser + // or form autocomplete on newer browser, we need "change" event to catch it + element.on('change', listener); + + // if user modifies input value using context menu in IE, we need "paste" and "cut" events to catch it + if ($sniffer.hasEvent('paste')) { + element.on('paste cut', deferListener); + } + } + } ctrl.$render = function() { element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue); @@ -593,8 +674,8 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) { } } -function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); +function numberInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); ctrl.$parsers.push(function(value) { var empty = ctrl.$isEmpty(value); @@ -638,8 +719,8 @@ function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) { }); } -function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); +function urlInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); var urlValidator = function(value) { return validate(ctrl, 'url', ctrl.$isEmpty(value) || URL_REGEXP.test(value), value); @@ -649,8 +730,8 @@ function urlInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$parsers.push(urlValidator); } -function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { - textInputType(scope, element, attr, ctrl, $sniffer, $browser); +function emailInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser) { + textInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout, $sniffer, $browser); var emailValidator = function(value) { return validate(ctrl, 'email', ctrl.$isEmpty(value) || EMAIL_REGEXP.test(value), value); @@ -660,18 +741,31 @@ function emailInputType(scope, element, attr, ctrl, $sniffer, $browser) { ctrl.$parsers.push(emailValidator); } -function radioInputType(scope, element, attr, ctrl) { - // make the name unique, if not defined - if (isUndefined(attr.name)) { - element.attr('name', nextUid()); - } +function radioInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout) { - element.on('click', function() { + // Get update model details from controllers + var eventList = (isDefined(updModOnCtrl)) ? updModOnCtrl.$getEventList() : 'click'; + + var listener = function() { if (element[0].checked) { scope.$apply(function() { ctrl.$setViewValue(attr.value); }); } + }; + + // make the name unique, if not defined + if (isUndefined(attr.name)) { + element.attr('name', nextUid()); + } + + // bind to user-defined/default events + forEach(eventList.split(','), function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + ev = 'click'; + } + element.bind(ev, listener); }); ctrl.$render = function() { @@ -682,17 +776,61 @@ function radioInputType(scope, element, attr, ctrl) { attr.$observe('value', ctrl.$render); } -function checkboxInputType(scope, element, attr, ctrl) { - var trueValue = attr.ngTrueValue, - falseValue = attr.ngFalseValue; +function checkboxInputType(scope, element, attr, ctrl, updModOnCtrl, updModlTimCtrl, $timeout) { + var timeout = null, + trueValue = attr.ngTrueValue, + falseValue = attr.ngFalseValue, + eventList, + updateDefaultTimeout, + updateTimeout; + + // Get update model details from controllers + eventList = 'click'; + + // Get update model details from controllers + if (isDefined(updModOnCtrl)) { + eventList = updModOnCtrl.$getEventList(); + updateTimeout = updModOnCtrl.$getDebounceTimeout(); + } - if (!isString(trueValue)) trueValue = true; - if (!isString(falseValue)) falseValue = false; + if (isDefined(updModlTimCtrl)) { + updateDefaultTimeout = updModlTimCtrl.$getDefaultTimeout(); + } - element.on('click', function() { + var update = function() { scope.$apply(function() { ctrl.$setViewValue(element[0].checked); }); + }; + + var listener = function(event) { + + var isEmpty = function(value) { + return isUndefined(value) || value === '' || value === null || value !== value; + }; + + var callbackTimeout = (!isEmpty(updateTimeout)) + ? updateTimeout[event.type] || updateTimeout['default'] || updateDefaultTimeout || 0 + : updateDefaultTimeout || 0; + + if (callbackTimeout>0) { + timeout = $timeout(update, callbackTimeout, false, timeout); + } + else { + update(); + } + }; + + if (!isString(trueValue)) trueValue = true; + if (!isString(falseValue)) falseValue = false; + + // bind to user-defined/default events + forEach(eventList.split(','), function(ev) { + ev = trim(ev).toLowerCase(); + if (ev === 'default') { + ev = 'click'; + } + element.bind(ev, listener); }); ctrl.$render = function() { @@ -852,14 +990,14 @@ function checkboxInputType(scope, element, attr, ctrl) { */ -var inputDirective = ['$browser', '$sniffer', function($browser, $sniffer) { +var inputDirective = ['$browser', '$sniffer', '$timeout', function($browser, $sniffer, $timeout) { return { restrict: 'E', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - if (ctrl) { - (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrl, $sniffer, - $browser); + require: ['?ngModel', '^?ngUpdateModelOn', '^?ngUpdateModelDebounce'], + link: function(scope, element, attr, ctrls) { + if (ctrls[0]) { + (inputType[lowercase(attr.type)] || inputType.text)(scope, element, attr, ctrls[0], ctrls[1], ctrls[2], $timeout, + $sniffer, $browser); } } }; diff --git a/src/ng/directive/ngUpdateModel.js b/src/ng/directive/ngUpdateModel.js new file mode 100644 index 000000000000..6fb137bd740f --- /dev/null +++ b/src/ng/directive/ngUpdateModel.js @@ -0,0 +1,97 @@ +'use strict'; + +/** + * @ngdoc directive + * @name ng.directive:ngUpdateModelOn + * @restrict A + * + * @description + * The `ngUpdateModelOn` directive changes default behavior of model updates. You can customize + * which events will be bound to the `input` elements so that the model update will + * only be triggered when they occur. + * + * This option will be applicable to those `input` elements that descend from the + * element containing the directive. So, if you use `ngUpdateModelOn` on a `form` + * element, the default behavior will be used on the `input` elements within. + * + * See {@link guide/forms this link} for more information about debouncing and custom + * events. + * + * @element ANY + * @param {string} ngUpdateModelOn Allows specifying an event or a comma-delimited list of events + * that will trigger a model update. If it is not set, it defaults to any inmediate change. If + * the list contains "default", the original behavior is also kept. You can also specify an + * object in which the key is the event and the value the particular debouncing timeout to be + * applied to it. + */ + +var SIMPLEOBJECT_TEST = /^\s*?\{(.*)\}\s*?$/; + +var NgUpdateModelOnController = ['$attrs', '$scope', + function UpdateModelOnController($attrs, $scope) { + + var attr = $attrs['ngUpdateModelOn']; + var updateModelOnValue; + var updateModelDebounceValue; + + if (SIMPLEOBJECT_TEST.test(attr)) { + updateModelDebounceValue = $scope.$eval(attr); + var keys = []; + for(var k in updateModelDebounceValue) { + keys.push(k); + } + updateModelOnValue = keys.join(','); + } + else { + updateModelOnValue = attr; + } + + this.$getEventList = function() { + return updateModelOnValue; + }; + + this.$getDebounceTimeout = function() { + return updateModelDebounceValue; + }; +}]; + +var ngUpdateModelOnDirective = [function() { + return { + restrict: 'A', + controller: NgUpdateModelOnController + }; +}]; + + +/** + * @ngdoc directive + * @name ng.directive:ngUpdateModelDebounce + * @restrict A + * + * @description + * The `ngUpdateModelDebounce` directive allows specifying a debounced timeout to model updates so they + * are not triggerer instantly but after the timer has expired. + * + * If you need to specify different timeouts for each event, you can use + * {@link ng.directive:ngUpdateModelOn ngUpdateModelOn} directive which the object notation. + * + * @element ANY + * @param {integer} ngUpdateModelDebounce Time in milliseconds to wait since the last registered + * content change before triggering a model update. + */ +var NgUpdateModelDebounceController = ['$attrs', + function UpdateModelDebounceController($attrs) { + + var updateModelDefaultTimeoutValue = $attrs['ngUpdateModelDebounce']; + + this.$getDefaultTimeout = function() { + return updateModelDefaultTimeoutValue; + }; +}]; + +var ngUpdateModelDebounceDirective = [function() { + return { + restrict: 'A', + controller: NgUpdateModelDebounceController + }; +}]; diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js index e3e50e02a69e..9b0c90a266dd 100644 --- a/test/ng/directive/inputSpec.js +++ b/test/ng/directive/inputSpec.js @@ -608,6 +608,175 @@ describe('input', function() { }); + describe('ng-update-model attributes', function() { + + it('should allow overriding the model update trigger event on text inputs', function() { + compileInput(''); + + changeInputValueTo('a'); + expect(scope.name).toBeUndefined(); + browserTrigger(inputElm, 'blur'); + expect(scope.name).toEqual('a'); + }); + + + it('should bind the element to a list of events on text inputs', function() { + compileInput(''); + + changeInputValueTo('a'); + expect(scope.name).toBeUndefined(); + browserTrigger(inputElm, 'blur'); + expect(scope.name).toEqual('a'); + + changeInputValueTo('b'); + expect(scope.name).toEqual('a'); + browserTrigger(inputElm, 'mousemove'); + expect(scope.name).toEqual('b'); + }); + + + it('should allow keeping the default update behavior on text inputs', function() { + compileInput(''); + + changeInputValueTo('a'); + expect(scope.name).toEqual('a'); + }); + + + it('should allow overriding the model update trigger event on checkboxes', function() { + compileInput(''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + + browserTrigger(inputElm, 'blur'); + expect(scope.checkbox).toBe(true); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(true); + }); + + + it('should allow keeping the default update behavior on checkboxes', function() { + compileInput(''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(true); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(false); + }); + + + it('should allow overriding the model update trigger event on radio buttons', function() { + compileInput( + '' + + '' + + ''); + + scope.$apply(function() { + scope.color = 'white'; + }); + browserTrigger(inputElm[2], 'click'); + expect(scope.color).toBe('white'); + + browserTrigger(inputElm[2], 'blur'); + expect(scope.color).toBe('blue'); + + }); + + + it('should allow keeping the default update behavior on radio buttons', function() { + compileInput( + '' + + '' + + ''); + + scope.$apply(function() { + scope.color = 'white'; + }); + browserTrigger(inputElm[2], 'click'); + expect(scope.color).toBe('blue'); + }); + + + it('should trigger only after timeout in text inputs', inject(function($timeout) { + compileInput(''); + + changeInputValueTo('a'); + changeInputValueTo('b'); + changeInputValueTo('c'); + expect(scope.name).toEqual(undefined); + $timeout.flush(); + expect(scope.name).toEqual('c'); + })); + + + it('should trigger only after timeout in checkboxes', inject(function($timeout) { + compileInput(''); + + browserTrigger(inputElm, 'click'); + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(); + expect(scope.checkbox).toBe(false); + })); + + + it('should allow selecting different debounce timeouts for each event', inject(function($timeout) { + compileInput(''); + + changeInputValueTo('a'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(4000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(7000); + expect(scope.name).toEqual('a'); + changeInputValueTo('b'); + browserTrigger(inputElm, 'blur'); + $timeout.flush(4000); + expect(scope.name).toEqual('a'); + $timeout.flush(2000); + expect(scope.name).toEqual('b'); + })); + + + it('should allow selecting different debounce timeouts for each event on checkboxes', inject(function($timeout) { + compileInput(''); + + browserTrigger(inputElm, 'click'); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(8000); + expect(scope.checkbox).toBe(undefined); + $timeout.flush(3000); + expect(scope.checkbox).toBe(true); + browserTrigger(inputElm, 'click'); + browserTrigger(inputElm, 'blur'); + $timeout.flush(3000); + expect(scope.checkbox).toBe(true); + $timeout.flush(3000); + expect(scope.checkbox).toBe(false); + + })); + + + it('should inherit model update settings from ancestor elements', inject(function($timeout) { + var doc = $compile('
' + + '
')(scope); + + var input = doc.find('input').eq(0); + input.val('a'); + expect(scope.name).toEqual(undefined); + browserTrigger(input, 'blur'); + expect(scope.name).toBe(undefined); + $timeout.flush(); + expect(scope.name).toEqual('a'); + dealoc(doc); + })); + + }); + + it('should allow complex reference binding', function() { compileInput('');