25

I have a search input field with a requery function bound to the ng-change.

 <input ng-model="search" ng-change="updateSearch()">

However this fires too quickly on every character. So I end up doing something like this alot:

  $scope.updateSearch = function(){
    $timeout.cancel(searchDelay);
    searchDelay = $timeout(function(){
      $scope.requery($scope.search);
    },300);
  }

So that the request is only made 300ms after the user has stopped typing. Is there any solution to wrap this in a directive?

3
  • 3
    Sure. You can write the code you've already got into a directive. Commented Jan 14, 2014 at 18:48
  • I think $timeout.cancel(searchDelay); is useless in your code. you need to compare the old search with new one to avoid repeating the query.
    – Alborz
    Commented Jan 15, 2014 at 5:06
  • 2
    Please mark an answer as correct, if you have found one to work.
    – Doug
    Commented Jan 29, 2014 at 15:32

4 Answers 4

51

As of angular 1.3 this is way easier to accomplish, using ngModelOptions:

<input ng-model="search" ng-change="updateSearch()" ng-model-options="{debounce:3000}">

Syntax:  {debounce: Miliseconds}
3
  • @kreepN ,i tried this solution, but i am getting it immediately. can you tell me why? here is the plunker Commented Mar 25, 2016 at 12:43
  • @codelearner Just saw your question, but it probably has to do with your plnkr not using a new enough version of angular. It needs 1.3 minimum.
    – KreepN
    Commented Mar 31, 2016 at 3:37
  • It seems like this solution delays the model update, not the change action. It doesn't seem to work when I need the model to update immediately, but also need an action that makes use of the model to be delayed.
    – Doug
    Commented Jun 28, 2017 at 14:34
25

To solve this problem, I created a directive called ngDelay.

ngDelay augments the behavior of ngChange to support the desired delayed behavior, which provides updates whenever the user is inactive, rather than on every keystroke. The trick was to use a child scope, and replace the value of ngChange to a function call that includes the timeout logic and executes the original expression on the parent scope. The second trick was to move any ngModel bindings to the parent scope, if present. These changes are all performed in the compile phase of the ngDelay directive.

Here's a fiddle which contains an example using ngDelay: http://jsfiddle.net/ZfrTX/7/ (Written and edited by me, with help from mainguy and Ryan Q)

You can find this code on GitHub thanks to brentvatne. Thanks Brent!

For quick reference, here's the JavaScript for the ngDelay directive:

app.directive('ngDelay', ['$timeout', function ($timeout) {
    return {
        restrict: 'A',
        scope: true,
        compile: function (element, attributes) {
            var expression = attributes['ngChange'];
            if (!expression)
                return;

            var ngModel = attributes['ngModel'];
            if (ngModel) attributes['ngModel'] = '$parent.' + ngModel;
            attributes['ngChange'] = '$$delay.execute()';

            return {
                post: function (scope, element, attributes) {
                    scope.$$delay = {
                        expression: expression,
                        delay: scope.$eval(attributes['ngDelay']),
                        execute: function () {
                            var state = scope.$$delay;
                            state.then = Date.now();
                            $timeout(function () {
                                if (Date.now() - state.then >= state.delay)
                                    scope.$parent.$eval(expression);
                            }, state.delay);
                        }
                    };
                }
            }
        }
    };
}]);

And if there are any TypeScript wonks, here's the TypeScript using the angular definitions from DefinitelyTyped:

components.directive('ngDelay', ['$timeout', ($timeout: ng.ITimeoutService) => {
    var directive: ng.IDirective = {
        restrict: 'A',
        scope: true,
        compile: (element: ng.IAugmentedJQuery, attributes: ng.IAttributes) => {
            var expression = attributes['ngChange'];
            if (!expression)
                return;

            var ngModel = attributes['ngModel'];
            if (ngModel) attributes['ngModel'] = '$parent.' + ngModel;
            attributes['ngChange'] = '$$delay.execute()';
            return {
                post: (scope: IDelayScope, element: ng.IAugmentedJQuery, attributes: ng.IAttributes) => {
                    scope.$$delay = {
                        expression: <string>expression,
                        delay: <number>scope.$eval(attributes['ngDelay']),
                        execute: function () {
                            var state = scope.$$delay;
                            state.then = Date.now();
                            $timeout(function () {
                                if (Date.now() - state.then >= state.delay)
                                    scope.$parent.$eval(expression);
                            }, state.delay);
                        }
                    };
                }
            }
        }
    };

    return directive;
}]);

interface IDelayScope extends ng.IScope {
    $$delay: IDelayState;
}

interface IDelayState {
    delay: number;
    expression: string;
    execute(): void;
    then?: number;
    action?: ng.IPromise<any>;
}
7
  • 2
    I realy love this directive, that was exactly what I needed! Anyhow, since this does not work when there are mutiple inputs on a page, I allowed myself to modify your awseome code by one little line here: restrict:'A',scope:true,compile:... Look at this forked fiddle to see what i mean: jsfiddle.net/ZfrTX. Thanks again, I upvoted your answer! Too bad it was never accepted. Whish I could...
    – mainguy
    Commented Feb 25, 2014 at 12:39
  • @mainguy Thank you! That was an important oversight on my part. The code has been updated to use an isolate scope.
    – Doug
    Commented Feb 26, 2014 at 15:24
  • 1
    @DougR just an fyi "scope: true" is not an isolate scope, but it seems to do the trick. "scope: {}" would produce an isolate scope with no passed parameters from the outer scope.
    – Ryan Q
    Commented Mar 17, 2014 at 22:21
  • 2
    @RyanQ You are absolutely correct, AND changing it to an isolate scope did actually fix a bug. Look at the updated example. The various values no longer are constrained to be on the same object, which had perplexed me. I was confused because I thought I was working with an isolate scope, when in fact I was working with one that prototypically inherits from the elements scope. Thank you.
    – Doug
    Commented Mar 19, 2014 at 4:13
  • 1
    @DougR - I packaged your solution and put it up on bower with credit to you, for the convenience of other readers. Github repo: github.com/brentvatne/angular-delay You can see it on bower: bower.io/search/?q=angular-delay
    – brentvatne
    Commented Jun 20, 2014 at 6:22
0

This works perfectly for me: JSFiddle

  var app = angular.module('app', []);
    app.directive('delaySearch', function ($timeout) {
        return {
            restrict: 'EA',
            template: ' <input ng-model="search" ng-change="modelChanged()">',
            link: function ($scope, element, attrs) {
                $scope.modelChanged = function () {
                    $timeout(function () {
                        if ($scope.lastSearch != $scope.search) {
                            if ($scope.delayedMethod) {
                                $scope.lastSearch = $scope.search;
                                $scope.delayedMethod({ search: $scope.search });
                            }
                        }
                    }, 300);
                }
            },
            scope: {
                delayedMethod:'&'
            }
        }
    });

Using the directive

In your controller:

app.controller('ctrl', function ($scope,$timeout) {
    $scope.requery = function (search) {
        console.log(search);
    }
});

In your view:

<div ng-app="app">
    <div ng-controller="ctrl">
        <delay-search delayed-method="requery(search)"></delay-search>
    </div>
</div>
1
  • This answer only works for the search value and modelChanged function. What I think the author is looking for is something like ng-delayed-change="<AngularExpression>", which automatically inserts the delay, in all cases.
    – Doug
    Commented Jan 28, 2014 at 23:42
0

I know i'm late to the game but,hopefully this will help anyone still using 1.2. Pre ng-model-options i found this worked for me, as ngchange will not fire when the value is invalid.

this is a slight variation on @doug's answer as it uses ngKeypress which doesn't care what state the model is in.

function delayChangeDirective($timeout) {
    var directive = {
        restrict: 'A',
        priority: 10,
        controller: delayChangeController,
        controllerAs: "$ctrl",
        scope: true,
        compile: function compileHandler(element, attributes) {
            var expression = attributes['ngKeypress'];
            if (!expression)
                return;

            var ngModel = attributes['ngModel'];
            if (ngModel) {
                attributes['ngModel'] = '$parent.' + ngModel;
            }
            attributes['ngKeypress'] = '$$delay.execute()';

            return {
                post: postHandler,
            };

            function postHandler(scope, element, attributes) {
                scope.$$delay = {
                    expression: expression,
                    delay: scope.$eval(attributes['ngKeypressDelay']),
                    execute: function () {
                        var state = scope.$$delay;
                        state.then = Date.now();
                        if (scope.promise) {
                            $timeout.cancel(scope.promise);
                        }

                        scope.promise = $timeout(function() {
                            delayedActionHandler(scope, state, expression);
                            scope.promise = null;
                        }, state.delay);
                    }
                };
            }
        }
    };

    function delayedActionHandler(scope, state, expression) {
        var now = Date.now();
        if (now - state.then >= state.delay) {
            scope.$parent.$eval(expression);
        }
    };

    return directive;
};

Not the answer you're looking for? Browse other questions tagged or ask your own question.