37

Was wondering how I would implement a 'Click everywhere but on this element' event.

I have something that you can compare to a list of files in a file explorer. You can select certain elements but if you click outside the element controller it needs to deselect everything.

enter image description here

Added a screenshot to make it more clear. So what I want to do is that if I click anywhere but on the language elements it should fire an event.

Update

To clarify I am not asking how I can do this with jQuery.

Pickels
  • 33,902
  • 26
  • 118
  • 178

8 Answers8

68

EDIT: There were a couple of problems in this old, old answer.

*Also: Marking Community Wiki (no points for me) because errors

  1. N calls for N uses of the directive. This probably isn't desirable for uses within the same scope with matching expressions.

  2. NOTHING WAS TEARING DOWN THE EVENT HANDLERS!!!! BAD! BAD! BAD!

So, I'm updating this answer. Hopefully it didn't cause anyone too much trouble.

Updated answer

Here's a new plunker with those issues fixed ... there are likely other things that individual application developers will run into. This is just an example of how to handle this problem.

app.factory('clickAnywhereButHereService', function($document){
  var tracker = [];
  
  return function($scope, expr) {
    var i, t, len;
    for(i = 0, len = tracker.length; i < len; i++) {
      t = tracker[i];
      if(t.expr === expr && t.scope === $scope) {
        return t;    
      }
    }
    var handler = function() {
      $scope.$apply(expr);
    };
    
    $document.on('click', handler);
    
    // IMPORTANT! Tear down this event handler when the scope is destroyed.
    $scope.$on('$destroy', function(){
      $document.off('click', handler);
    });
    
    t = { scope: $scope, expr: expr };
    tracker.push(t);
    return t;
  };
});

app.directive('clickAnywhereButHere', function($document, clickAnywhereButHereService){
  return {
    restrict: 'A',
    link: function(scope, elem, attr, ctrl) {
      var handler = function(e) {
        e.stopPropagation();
      };
      elem.on('click', handler);
      
      scope.$on('$destroy', function(){
        elem.off('click', handler);
      });
      
      clickAnywhereButHereService(scope, attr.clickAnywhereButHere);
    }
  };
});

Original answer (with fixes for teardown of event handlers)

You were close with the one answer you've found, but I've put together a plunk for you to show you what it was missing.

app.directive('clickAnywhereButHere', function($document){
  return {
    restrict: 'A',
    link: function(scope, elem, attr, ctrl) {
      var elemClickHandler = function(e) {
        e.stopPropagation();
      };
      
      var docClickHandler = function() {
        scope.$apply(attr.clickAnywhereButHere);
      };
      
      elem.on('click', elemClickHandler);
      $document.on('click', docClickHandler);
      
      // teardown the event handlers when the scope is destroyed.
      scope.$on('$destroy', function() {
        elem.off('click', elemClickHandler);
        $document.off('click', docClickHandler);
      });
    }
  }
})

HTML

<a click-anywhere-but-here="clickedSomewhereElse()" 
  ng-click="clickedHere()">Don't Click Me!</a>
Community
  • 1
  • 1
Ben Lesh
  • 107,825
  • 47
  • 247
  • 232
  • 2
    FYI, the $eval and $apply can be combined into one line: `scope.$apply(attr.clickAnywhereButHere);` – Mark Rajcok Feb 19 '13 at 03:48
  • 1
    Very true. (In my defense, I answered this months ago. :P) I'll make the edit. – Ben Lesh Feb 19 '13 at 13:22
  • If you want to make a 'hide when click outside' function using this directive, like I did, remember to `bind` the click function to the `$document` on `show` and `undbind` it again on `hide`. – mlunoe May 23 '13 at 14:04
  • Please @mlunoe can you make an example of your point? I don't know how to detect when ```show``` happens. Thanks! – Marçal Juan Oct 25 '13 at 20:12
  • Yes, sorry. That was not very specific. I implemented this function with a bootstrap popover with information. I needed the popover to dismiss when clicking anywhere else than in the popover. I modified the plunker to fit my needs: http://plnkr.co/edit/ioVy1u?p=preview – mlunoe Oct 29 '13 at 14:53
  • I wish I knew what the downvotes were about... not for the points, but I'm honestly curious what the concern is. – Ben Lesh May 08 '14 at 06:58
  • @BenLesh no idea, just works as it should! got another upvote ;) – emp Jun 10 '14 at 14:27
  • @BenLesh, I upvoted your answer for the simplicity, but it doesn't work as expected when it is used multiple times, as stated by Max Bates. If I have N elements with this directive, when I click outside them all, the function is called N times. Plus, if I click outside some element and inside another one with this directive, the function isn't called. – Renato Mar 04 '15 at 18:04
  • You're right, you could do something like this, as well: [Plunk](http://plnkr.co/edit/ip9rQzkJyuGXcgaeFfVs?p=preview) ... there's obviously a lot more to implement and concerns other apps might have around this. (For example, nothing is tearing down this event right now) – Ben Lesh Mar 09 '15 at 17:38
  • +1 for answer. @Ben Lesh can we pass isolated scope in directive. I tried using it and found that handler() from factory get called as many times directive used on page. – Prashant Thorat Apr 25 '15 at 10:18
  • Could you explain what the tracker does? It seems to work fine without the logic that uses it. – camden_kid Oct 16 '15 at 12:13
28

The problem with the current accepted answer is that if you use the directive multiple times, every DOM element which has the directive attached will prevent bubbling (so if you have it on two elements, and you click in either, the callbacks of both will be blocked).

EDIT - avoid jQuery, clean up -- Define a function on your scope, and pass it to this directive directly (without parentheses), and the event will be passed to it when called.

app.directive('clickAnywhereButHere', function($document, $parse) {
    return {
        restrict: 'A',
        scope: {
            callback : '=clickAnywhereButHere'
        },
        link: function(scope, element, attr, ctrl) {
            var handler = function(event) {
                if (!element[0].contains(event.target)) {
                    scope.callback(event);
                 }
            };

            $document.on('click', handler);
            scope.$on('$destroy', function() {
                $document.off('click', handler);
            });
        }
    }
});

Usage in HTML

<a click-anywhere-but-here="myFunction"></a>

Usage in Controller

 $scope.myFunction = function (event) { ... } 

-

Note that you may need to wrap scope.callback(event) with scope.$apply()

Max Bates
  • 1,238
  • 1
  • 16
  • 21
  • 1
    Instead of `element.has(event.target).length === 0` you can simply write `element[0].contains(evt.target)` (no need for jQuery), see https://developer.mozilla.org/en-US/docs/Web/API/Node.contains You can also write this `$.contains(element[0], evt.target)` using jQuery. – tanguy_k Feb 17 '14 at 11:09
  • 1
    You need to change callback : '=clickAnywhereButHere' to callback : '&clickAnywhereButHere'. it's a function. You may also want to wrap scope.callback(event) in $apply. – SRachamim Apr 03 '14 at 07:36
  • As the solution states, you can pass in the function without parenthesis and it will work (the reference to the function is passed). Passing as '&' requires you to hardcode the arguments, which I don't like doing, even if it forces consistency. – Max Bates Apr 03 '14 at 17:20
  • 2
    Note also that in the event you don't want an isolate scope, you can easily remove the scope declaration and inside the handler, use a syntax like that in blesh's answer: `scope.$apply(attr.clickAnywhereButHere)` – Max Bates May 08 '14 at 00:44
  • to make it a little shorter, $document.click(handler) – sanz Jun 14 '14 at 22:06
  • Ha! I just noticed this answer. +1 ... this is a good answer. I don't know that I agree with the use of '=' to '&' or even just evaluating an expression. But I did miss that teardown, which was a gaff. – Ben Lesh Mar 09 '15 at 17:53
  • The function 'runs', but the isolation remains: the variable I'm updating in the function remains in its original value. Any idea what I'm doing wrong? I tried all kinds of wrapping `scope.$apply` around it, with or without, nothing works. `scope.$id` gives me the same value as the scope I'm looking for, but it's not modifying the original value – casraf Apr 20 '15 at 14:59
  • Bit beyond the scope of this question but are you updating a primitive or an object property? http://stackoverflow.com/questions/14049480/what-are-the-nuances-of-scope-prototypal-prototypical-inheritance-in-angularjs – Max Bates Apr 20 '15 at 22:07
7

If you have alot of elements that needs this directive, here is another solution that is performance optimized. (Example a list with 100+ rows, each with this directive)

This will always contain only one $document listener

angular.module('app').directive('clickElsewhere', ['$document', function ($document) {
return {
  link: function postLink(scope, element, attr) {

    var elsewhere = true;

    element.on('click', function(e) {
      elsewhere = false;
      $document.off('click', clickElsewhere);
      $document.on('click', clickElsewhere);
    });

    var clickElsewhere = function() {
      if (elsewhere) {
        scope.$apply(attr.clickElsewhere);
        $document.off('click', clickElsewhere);
      }
      elsewhere = true;
    };

  }
};
}]);

Problem with solution from Max Bates is that all directives is adding a listener for the $document.on('click', function(...)); event which makes performance issues.

Problem with accepted answer has Max Bates allready stated.

Fabio Bonfante
  • 5,128
  • 1
  • 32
  • 37
Steffan
  • 704
  • 1
  • 11
  • 25
3

Found the anwser on this blog post.

Directive:

app.directive('documentClick', function ($document, $parse) {

  var linkFunction = function ($scope, $element, $attributes) {

    var scopeExpression = $attributes.documentClick;
    var invoker = $parse(scopeExpression);

    $document.on('click', function (event) {

      $scope.$apply(function () {
        invoker($scope, { $event: event });
      });

    });

  };

  return linkFunction;

});

Controller:

app.controller('PageCtrl', function ($scope) {

  $scope.click = function (e) {
    if (!$(e.target).is('.language')) {
      //do stuff
    }
  };

});

View:

<body ng-controller='PageCtrl' document-click='click($event)'></body>
Pickels
  • 33,902
  • 26
  • 118
  • 178
  • That's close, but if you click on your elements, which are inside the document, the click event will bubble and fire your global event. I'm not sure if ng-click stops propagation or not. – Ben Lesh Oct 17 '12 at 13:46
  • Cool, an example that uses $parse and then specifies a "locals" argument in the function returned by $parse. (Examples of this are hard to find.) – Mark Rajcok Feb 19 '13 at 04:33
2

Here is a variant based on Max's solution but more natural in terms of standard angular events directives:

app.directive('clickOut', function($document) {
    return {
        restrict: 'A',
        scope: {
            clickOut: '&'
        },
        link: function (scope, element) {
            var handler = function(event) {
                if (!element[0].contains(event.target)) {
                    scope.$apply(function () {
                        scope.clickOut({ $event : event }); 
                    });
                }
            };

            $document.on('click', handler);
            scope.$on('$destroy', function() {
                $document.off('click', handler);
            });
        }
    };
});

Usage

<div click-out="myFunction()"></div>

Passing click event

<div click-out="myFunction($event)"></div>
Eugene Gluhotorenko
  • 3,094
  • 2
  • 33
  • 52
1

The simplest way is to check the element's scope.$id and the clickScope.$id (the click event target scope.$id).

link: function(scope, element, attrs) { 
    //close element when mouse click outside
    var documentClickHandler = function(event) {
        var clickScope = angular.element(event.target).scope();
        if (clickScope.$id != scope.$id) {
            //clickScope.$parent is for clicking on the directive children scope
            if(clickScope.$parent === null || 
               clickScope.$parent.$id != scope.$id){
               //Click everywhere but on this element
               //Do anything you want here, like close your element;
            }
        }

    };

    $document.on('click', documentClickHandler);

    scope.$on('$destroy', function() {
        $document.off('click', documentClickHandler);
    });
}
sigma207
  • 11
  • 1
0

Benefit of this solution:

  1. You only need one bind to $(document). Other firing of events would depend on the $emit of scope events.
  2. You could use both expressions click-elsewhere="show=false", and click-elsewhere="fn()", thanks to $parse.

Code:

// broadcast click event within AppCtrl
app.controller('AppCtrl', function($rootScope) {
  $(document).on('click', function(e) {
    // use $emit so the event stays inside $rootScope
    $rootScope.$emit('click', {target: e.target});
  };
};

app.directive('clickElsewhere', function($rootScope) {
  return {
    restrict: 'A',
    compile: function($element, attr) {
      // store fn in compile so it only execute once
      var fn = $parse(attr['clickElsewhere']); 

      return function(scope, element) {
        var offEvent = $rootScope.$on('click', function(event, target) {
          if ( (element.find($(target)).length) || element.is($(target)) ) return;

          scope.$apply(function() {
            fn(scope, {$event: event});
          });
        });

        scope.$on('$destroy', offEvent);
      };
    }
  };

Usage in HTML:

  1. click-elsewhere="fn()"
  2. click-elsewhere="show=false"
Lucia
  • 13,033
  • 6
  • 44
  • 50
-1

it's possibly a bit out of context, but you could classify ( with for instance "selected" ) all selected items ans when a user clicks on the 'do not click here' element, you can declassify all items currently classified "selected", and classify the specific element...

  • The question isn't about how to get the select/deselect effect, angular takes care of that with bindings. – Pickels Oct 17 '12 at 09:44