diff --git a/docs/content/guide/forms.ngdoc b/docs/content/guide/forms.ngdoc
index 0a0eed47dce2..77b143fa4a89 100644
--- a/docs/content/guide/forms.ngdoc
+++ b/docs/content/guide/forms.ngdoc
@@ -181,6 +181,69 @@ 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 {@link ng.directive:ngModelOptions ngModelOptions}
+directive to bind only to specified list of events. I.e. `ng-model-options="{ updateOn: "blur" }"` will update and validate only after the control loses
+focus. You can set a single event using an array instead of a string. I.e. `ng-model-options="{ updateOn: ["mousedown", "blur"] }"`
+
+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-model-options="{ updateOn: ["default", "blur"] }"`
+
+You can delay the model update/validation by writing a `debounce` key. I.e. `ng-model-options="{ debounce: 500 }"` will wait for half a second since
+the last content change before triggering the model update and form validation.
+
+Custom debouncing timeouts can be set for each event for each event if you use an object in `debounce`. This can be useful to force immediate updates on
+some specific circumstances (like blur events).
+
+I.e. `ng-model-options="{ updateOn: ["default", "blur"], debounce: { default: 500, blur: 0 } }"`
+
+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).
+
+
+
+
+
+
username = "{{username}}"
+
+
+
+ function ControllerUpdateOn($scope) {
+ $scope.username = "";
+ }
+
+
+
+This one shows how to debounce model changes. Model will be updated only 250 milliseconds after last change.
+
+
+
+
+
+
username = "{{user.name}}"
+
+
+
+ function ControllerUpdateOn($scope) {
+ $scope.username = "";
+ }
+
+
+
+
+
# Custom Validation
Angular provides basic implementation for most common html5 {@link ng.directive:input input}
diff --git a/src/ng/directive/input.js b/src/ng/directive/input.js
index 8306f8bebb4a..f765a096a569 100644
--- a/src/ng/directive/input.js
+++ b/src/ng/directive/input.js
@@ -879,6 +879,7 @@ function addNativeHtml5Validators(ctrl, validatorName, element) {
function textInputType(scope, element, attr, ctrl, options, $sniffer, $browser) {
var validity = element.prop('validity');
+
// In composition mode, users are still inputing intermediate text buffer,
// hold the listener until composition is done.
// More about composition events: https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent
@@ -895,9 +896,10 @@ function textInputType(scope, element, attr, ctrl, options, $sniffer, $browser)
});
}
- var listener = function() {
+ var listener = function(ev) {
if (composing) return;
- var value = element.val();
+ var value = element.val(),
+ event = ev ? ev.type : undefined;
// By default we will trim the value
// If the attribute ng-trim exists we will avoid trimming
@@ -912,50 +914,61 @@ function textInputType(scope, element, attr, ctrl, options, $sniffer, $browser)
// even when the first character entered causes an error.
(validity && value === '' && !validity.valueMissing)) {
if (scope.$$phase) {
- ctrl.$setViewValue(value);
+ ctrl.$setViewValue(value, event);
} else {
scope.$apply(function() {
- ctrl.$setViewValue(value);
+ ctrl.$setViewValue(value, event);
});
}
}
};
- // 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;
+ // Allow adding/overriding bound events
+ if ((options) && (options.$eventList.length)) {
+ // bind to user-defined events
+ forEach(options.$eventList, function(ev) {
+ element.on(ev, listener);
+ });
+ }
- var deferListener = function() {
- if (!timeout) {
- timeout = $browser.defer(function() {
- listener();
- timeout = null;
- });
- }
- };
+ // setup default events if requested
+ if (!options || (options.$defaultEvents)) {
+ // 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;
- element.on('keydown', function(event) {
- var key = event.keyCode;
+ var deferListener = function(ev) {
+ if (!timeout) {
+ timeout = $browser.defer(function() {
+ listener(ev);
+ timeout = null;
+ });
+ }
+ };
- // ignore
- // command modifiers arrows
- if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;
+ element.on('keydown', function(event) {
+ var key = event.keyCode;
- deferListener();
- });
+ // ignore
+ // command modifiers arrows
+ if (key === 91 || (15 < key && key < 19) || (37 <= key && key <= 40)) return;
- // 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);
+ deferListener();
+ });
+
+ // 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);
@@ -1191,13 +1204,25 @@ function radioInputType(scope, element, attr, ctrl, options) {
element.attr('name', nextUid());
}
- element.on('click', function() {
+ var listener = function(ev) {
if (element[0].checked) {
scope.$apply(function() {
- ctrl.$setViewValue(attr.value);
+ ctrl.$setViewValue(attr.value, ev ? ev.type : undefined);
});
}
- });
+ };
+
+ // Allow adding/overriding bound events
+ if ((options) && (options.$eventList.length)) {
+ // bind to user-defined events
+ forEach(options.$eventList, function(ev) {
+ element.on(ev, listener);
+ });
+ }
+
+ if (!options || (options.$defaultEvents)) {
+ element.on('click', listener);
+ }
ctrl.$render = function() {
var value = attr.value;
@@ -1214,11 +1239,23 @@ function checkboxInputType(scope, element, attr, ctrl, options) {
if (!isString(trueValue)) trueValue = true;
if (!isString(falseValue)) falseValue = false;
- element.on('click', function() {
+ var listener = function(ev) {
scope.$apply(function() {
- ctrl.$setViewValue(element[0].checked);
+ ctrl.$setViewValue(element[0].checked, ev ? ev.type : undefined);
+ });
+ };
+
+ // Allow adding/overriding bound events
+ if ((options) && (options.$eventList.length)) {
+ // bind to user-defined events
+ forEach(options.$eventList, function(ev) {
+ element.on(ev, listener);
});
- });
+ }
+
+ if (!options || (options.$defaultEvents)) {
+ element.on('click', listener);
+ }
ctrl.$render = function() {
element[0].checked = ctrl.$viewValue;
@@ -1662,25 +1699,22 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
/**
* @ngdoc method
- * @name ngModel.NgModelController#$setViewValue
+ * @name ngModel.NgModelController#$cancelDebounce
*
* @description
- * Update the view value.
- *
- * This method should be called when the view value changes, typically from within a DOM event handler.
- * For example {@link ng.directive:input input} and
- * {@link ng.directive:select select} directives call it.
+ * Cancel a pending debounced update.
*
- * It will update the $viewValue, then pass this value through each of the functions in `$parsers`,
- * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to
- * `$modelValue` and the **expression** specified in the `ng-model` attribute.
- *
- * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called.
- *
- * Note that calling this function does not trigger a `$digest`.
- *
- * @param {string} value Value from the view.
+ * This method should be called before directly update a debounced model from the scope in
+ * order to prevent unintended future changes of the model value because of a delayed event.
*/
+ this.$cancelDebounce = function() {
+ if ( pendingDebounce ) {
+ $timeout.cancel(pendingDebounce);
+ pendingDebounce = null;
+ }
+ };
+
+ // update the view value
this.$realSetViewValue = function(value) {
this.$viewValue = value;
@@ -1709,15 +1743,32 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
});
}
};
+
+ /**
+ * @ngdoc method
+ * @name ngModel.NgModelController#$setViewValue
+ *
+ * @description
+ * Update the view value.
+ *
+ * This method should be called when the view value changes, typically from within a DOM event handler.
+ * For example {@link ng.directive:input input} and
+ * {@link ng.directive:select select} directives call it.
+ *
+ * It will update the $viewValue, then pass this value through each of the functions in `$parsers`,
+ * which includes any validators. The value that comes out of this `$parsers` pipeline, be applied to
+ * `$modelValue` and the **expression** specified in the `ng-model` attribute.
+ *
+ * Lastly, all the registered change listeners, in the `$viewChangeListeners` list, are called.
+ *
+ * Note that calling this function does not trigger a `$digest`.
+ *
+ * @param {string} value Value from the view.
+ */
this.$setViewValue = function(value, trigger) {
var that = this;
- trigger = trigger || 'default';
- var debounceDelay = (isObject(this.$options.debounce) ? this.$options.debounce[trigger] : this.$options.debounce) || 0;
-
- if ( pendingDebounce ) {
- $timeout.cancel(pendingDebounce);
- pendingDebounce = null;
- }
+ var debounceDelay = (isObject(this.$options.debounce) ? (this.$options.debounce[trigger] || this.$options.debounce['default'] || 0) : this.$options.debounce) || 0;
+ that.$cancelDebounce();
if ( debounceDelay ) {
pendingDebounce = $timeout(function() {
pendingDebounce = null;
@@ -1737,12 +1788,6 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
// if scope model value and ngModel value are out of sync
if (ctrl.$modelValue !== value) {
- // Cancel any pending debounced update
- if ( pendingDebounce ) {
- $timeout.cancel(pendingDebounce);
- pendingDebounce = null;
- }
-
var formatters = ctrl.$formatters,
idx = formatters.length;
@@ -2155,11 +2200,123 @@ var ngValueDirective = function() {
};
};
+/**
+ * @ngdoc directive
+ * @name ngModelOptions
+ *
+ * @description
+ * Allows tuning how model updates are done. Using `ngModelOptions` you can specify a custom list of events
+ * that will trigger a model update and/or a debouncing delay so that the actual update only takes place
+ * when a timer expires; this timer will be reset after another change takes place.
+ *
+ * @param {Object=} Object that contains options to apply to the current model. Valid keys are:
+ * - updateOn: string specifying which event should be the input bound to. If an array is supplied instead,
+ * multiple events can be specified. There is a special event called `default` that
+ * matches the default events belonging of the control.
+ * - debounce: integer value which contains the debounce model update value in milliseconds. A value of 0
+ * triggers an immediate update. If an object is supplied instead, you can specify a custom value
+ * for each event. I.e.
+ * `ngModelOptions="{ updateOn: ["default", "blur"], debounce: {'default': 500, 'blur': 0} }"`
+ *
+ * @example
+
+ 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).
+
+
+
+
+
+
+
user.name =
+
+
+
+ var model = element(by.binding('user.name'));
+ var input = element(by.model('user.name'));
+ var other = element(by.model('user.data'));
+ it('should allow custom events', function() {
+ input.sendKeys(' hello');
+ expect(model.getText()).toEqual('say');
+ other.click();
+ expect(model.getText()).toEqual('say hello');
+ });
+
+
+ This one shows how to debounce model changes. Model will be updated only 500 milliseconds after last change.
+
+
+
+
+
+
+
user.name =
+
+
+
+ var model = element(by.binding('user.name'));
+ var input = element(by.model('user.name'));
+ var ptor = protractor.getInstance();
+ it('should delay model update', function() {
+ // We need to tell Protractor not to wait for the debounce timeout to resolve
+ browser.ignoreSynchronization = true;
+ expect(model.getText()).toEqual('say');
+ input.sendKeys(' he');
+ ptor.sleep(100);
+ expect(model.getText()).toEqual('say');
+ input.sendKeys('llo');
+ expect(model.getText()).toEqual('say');
+ ptor.sleep(600);
+ expect(model.getText()).toEqual('say hello');
+ });
+ afterEach(function() {
+ // Don't forget to turn synchronization back on
+ browser.ignoreSynchronization = false;
+ });
+
+
+ */
var ngModelOptionsDirective = function() {
return {
- controller: function($scope, $attrs) {
+ controller: ['$scope', '$attrs', function($scope, $attrs) {
+ var that = this;
this.$options = $scope.$eval($attrs.ngModelOptions);
- }
+ this.$eventList = [];
+ // Allow adding/overriding bound events
+ if (this.$options.updateOn) {
+ if (!isArray(this.$options.updateOn)) {
+ this.$options.updateOn = [ this.$options.updateOn ];
+ }
+ this.$defaultEvents = false;
+ // prepare a list of user-defined events
+ forEach(this.$options.updateOn, function(ev) {
+ ev = trim(ev).toLowerCase();
+ if (ev === 'default') {
+ that.$defaultEvents = true;
+ } else {
+ that.$eventList.push(ev);
+ }
+ });
+ } else {
+ this.$defaultEvents = true;
+ }
+ }]
};
};
\ No newline at end of file
diff --git a/test/ng/directive/inputSpec.js b/test/ng/directive/inputSpec.js
index eba3028e7bce..1e7df5c5beaf 100644
--- a/test/ng/directive/inputSpec.js
+++ b/test/ng/directive/inputSpec.js
@@ -608,6 +608,209 @@ describe('input', function() {
});
+ describe('ng-model-options 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', 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(2000);
+ expect(scope.name).toEqual(undefined);
+ $timeout.flush(9000);
+ expect(scope.name).toEqual('c');
+ }));
+
+
+ it('should trigger only after timeout in checkboxes', inject(function($timeout) {
+ compileInput('');
+
+ browserTrigger(inputElm, 'click');
+ expect(scope.checkbox).toBe(undefined);
+ $timeout.flush(2000);
+ expect(scope.checkbox).toBe(undefined);
+ $timeout.flush(9000);
+ expect(scope.checkbox).toBe(true);
+ }));
+
+
+ it('should trigger only after timeout in checkboxes in radio buttons', inject(function($timeout) {
+ compileInput(
+ '' +
+ '' +
+ '');
+
+ browserTrigger(inputElm[0], 'click');
+ expect(scope.color).toBe('white');
+ browserTrigger(inputElm[1], 'click');
+ expect(scope.color).toBe('white');
+ $timeout.flush(12000);
+ expect(scope.color).toBe('white');
+ $timeout.flush(10000);
+ expect(scope.color).toBe('red');
+
+ }));
+
+ it('should allow selecting different debounce timeouts for each event', inject(function($timeout) {
+ compileInput('');
+
+ changeInputValueTo('a');
+ expect(scope.checkbox).toBe(undefined);
+ $timeout.flush(6000);
+ expect(scope.checkbox).toBe(undefined);
+ $timeout.flush(4000);
+ 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('');
+
+ inputElm[0].checked = false;
+ browserTrigger(inputElm, 'click');
+ expect(scope.checkbox).toBe(undefined);
+ $timeout.flush(8000);
+ expect(scope.checkbox).toBe(undefined);
+ $timeout.flush(3000);
+ expect(scope.checkbox).toBe(true);
+ inputElm[0].checked = 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('