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).
+
+
+
+
+
+
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..4d9b175a6c1c 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,8 +501,26 @@ function textInputType(scope, element, attr, ctrl, $sniffer, $browser) {
});
}
- var listener = function() {
- if (composing) return;
+ var timeout = null,
+ eventList,
+ updateTimeout,
+ updateDefaultTimeout;
+
+ 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 = element.val();
// By default we will trim the value
@@ -498,41 +545,73 @@ 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 callbackTimeout = (!isEmpty(updateTimeout))
+ ? updateTimeout[event.type] || updateTimeout['default'] || updateDefaultTimeout || 0
+ : updateDefaultTimeout || 0;
- element.on('keydown', function(event) {
- var key = event.keyCode;
+ if (callbackTimeout>0) {
+ timeout = $timeout(update, callbackTimeout, false, timeout);
+ }
+ else {
+ update();
+ }
+ };
- // ignore
- // command modifiers arrows
- if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;
+ var deferListener = function(ev) {
+ $browser.defer(function() {
+ listener(ev);
+ });
+ };
+
+ var defaultEvents = true;
- deferListener();
+ // 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 (defaultEvents) {
- // 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);
+ // 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 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);
+ }
}
- }
- // 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 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);
+ }
ctrl.$render = function() {
element.val(ctrl.$isEmpty(ctrl.$viewValue) ? '' : ctrl.$viewValue);
@@ -593,8 +672,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 +717,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 +728,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 +739,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 +774,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 +988,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/src/ng/timeout.js b/src/ng/timeout.js
index 0986bb685ae3..5c2152cdac05 100644
--- a/src/ng/timeout.js
+++ b/src/ng/timeout.js
@@ -26,8 +26,8 @@ function $TimeoutProvider() {
*
* You can also use `$timeout` to debounce the call of a function using the returned promise
* as the fourth parameter in the next call. See the following example:
- *
- *
+ *
+ * ```js
* var debounce;
* var doRealSave = function() {
* // Save model to DB
@@ -36,13 +36,13 @@ function $TimeoutProvider() {
* // debounce call for 2 seconds
* debounce = $timeout(doRealSave, 2000, false, debounce);
* }
- *