44

I'm trying to create a multiselect dropdown list with checkbox and filter option. I'm trying to get the list hidden with I click outside but could not figure it out how. Appreciate your help.

http://plnkr.co/edit/tw0hLz68O8ueWj7uZ78c

Răzvan Flavius Panda
  • 21,730
  • 17
  • 111
  • 169
Amitava
  • 5,013
  • 9
  • 37
  • 50

9 Answers9

72

Watch out, your solution (the Plunker provided in the question) doesn't close the popups of other boxes when opening a second popup (on a page with multiple selects).

By clicking on a box to open a new popup the click event will always be stopped. The event will never reach any other opened popup (to close them).

I solved this by removing the event.stopPropagation(); line and matching all child elements of the popup.

The popup will only be closed, if the events element doesn't match any child elements of the popup.

I changed the directive code to the following:

select.html (directive code)

link: function(scope, element, attr){

    scope.isPopupVisible = false;

    scope.toggleSelect = function(){
        scope.isPopupVisible = !scope.isPopupVisible;
    }

    $(document).bind('click', function(event){
        var isClickedElementChildOfPopup = element
            .find(event.target)
            .length > 0;

        if (isClickedElementChildOfPopup)
            return;

        scope.$apply(function(){
            scope.isPopupVisible = false;
        });
    });
}

I forked your plunker and applied the changes:

Plunker: Hide popup div on click outside

Screenshot:

Plunker Screenshot

Martijn
  • 24,441
  • 60
  • 174
  • 261
cheneym
  • 4,062
  • 1
  • 16
  • 10
  • 16
    **Performance hint:** If you have allot of these select boxes on your page, you should only bind `click` when the popup is _opened_ and unbind `click` as soon as the popup is _closed_. – cheneym Jun 28 '13 at 13:31
  • cheneym, but what about IE8? I get 'event.target.tagName' is null or not an object –  Oct 08 '13 at 13:09
  • oh, I resolve this problem :) In function, which binding on click event, missing input parameter event. I add it, and your solution excellent working in firefox and IE8 –  Oct 08 '13 at 13:55
  • @ArtsiomMitrokhin Thanks! I updated the example and the Plunker (I don't know why your edit was rejected). – cheneym Oct 21 '13 at 07:06
  • 1
    It seems like elementMatchesAnyInArray is just trying to see if the event.target is a child of element. Couldn't the same be achieved by checking for `element.find(event.target).length > 0`? – dubilla Nov 04 '13 at 21:11
  • 1
    @dubilla thanks, you are right. I updated the example and the plunker. – cheneym Dec 20 '13 at 13:45
  • 2
    How to do this without jQuery? Without jQuery, element.find is limited to just tag name. – Bhoomtawath Plinsut Oct 24 '14 at 16:57
  • 3
    @BhoomtawathPlinsut `element[0].contains(event.target)`. https://developer.mozilla.org/en-US/docs/Web/API/Node/contains – MathieuLescure Apr 21 '15 at 19:50
  • Try to remove jQuery dependency. – Damjan Pavlica Feb 01 '16 at 14:21
  • Y like the solution of @MathieuLescure without jQuery, only AngularJS. – Francisco Jul 07 '16 at 18:26
53

This is an old post but in case this helps anyone here is a working example of click outside that doesn't rely on anything but angular.

module('clickOutside', []).directive('clickOutside', function ($document) {

        return {
           restrict: 'A',
           scope: {
               clickOutside: '&'
           },
           link: function (scope, el, attr) {

               $document.on('click', function (e) {
                   if (el !== e.target && !el[0].contains(e.target)) {
                        scope.$apply(function () {
                            scope.$eval(scope.clickOutside);
                        });
                    }
               });
           }
        }

    });
Danny Fenstermaker
  • 914
  • 10
  • 12
  • 1
    Nice solution although seems to fall short if you have a span or hyperlink to calls a popup but doesn't actually wrap it. You have to apply the attribute to the clickable element which then becomes the target... or it searches to see if it contains the target, but since the hyperlink doesn't wrap the popup, it is unable to prevent the popup from disappearing when clicking inside it. – KingOfHypocrites Jul 20 '15 at 21:05
  • however just wrapping both my link and the popup with a div works great... the simplest answer to this very common problem I could find that worked in a pure angular fashion and particularly great is I don't have to bring in some entire bootstrap coupled library to do it. – KingOfHypocrites Jul 20 '15 at 21:45
  • 8
    I think we need to `off` the listener when scope destroyed. – Thịnh Phạm Dec 07 '16 at 09:40
  • 1
    Perfect and the best solution. – sanjeev shetty Jul 22 '17 at 20:21
  • 4
    Works like a charm. But, I suggest to change the `scope.$apply`, and `scope.$eval` to `scope.applyAsync`and `scope.evalAsync`, for a greater performance. – wmarquardt Apr 26 '18 at 21:54
8

OK I had to call $apply() as the event is happening outside angular world (as per doc).

    element.bind('click', function(event) {
    event.stopPropagation();      
    });

    $document.bind('click', function(){
    scope.isVisible = false;
    scope.$apply();
    });
Amitava
  • 5,013
  • 9
  • 37
  • 50
  • Really thanks, the first bind solve my problem. using that one when i click on element(dropdown directive) it does not disappear but disappear dropdown when i click outside. – Muhammad Zeshan Ghafoor Oct 05 '16 at 07:41
7

I realized it by listening for a global click event like so:

.directive('globalEvents', ['News', function(News) {
    // Used for global events
    return function(scope, element) {
        // Listens for a mouse click
        // Need to close drop down menus
        element.bind('click', function(e) {
            News.setClick(e.target);
        });
    }
}])

The event itself is then broadcasted via a News service

angular.factory('News', ['$rootScope', function($rootScope) {
    var news = {};
    news.setClick = function( target ) {
        this.clickTarget = target;
        $rootScope.$broadcast('click');
    };
}]);

You can then listen for the broadcast anywhere you need to. Here is an example directive:

.directive('dropdown', ['News', function(News) {
  // Drop down menu für the logo button
  return {
    restrict: 'E',
    scope: {},
    link: function(scope, element) {
      var opened = true;
      // Toggles the visibility of the drop down menu
      scope.toggle = function() {
        element.removeClass(opened ? 'closed' : 'opened');
        element.addClass(opened ? 'opened' : 'closed');
      };
      // Listens for the global click event broad-casted by the News service
      scope.$on('click', function() {
        if (element.find(News.clickTarget.tagName)[0] !== News.clickTarget) {
          scope.toggle(false);
        }
      });
      // Init
      scope.toggle();
    }
  }
}])

I hope it helps!

F Lekschas
  • 12,481
  • 10
  • 60
  • 72
  • Not so clear how to use this approach. In my directive scope is from the upper controller and doesn't register no clicks for News. Could you please provide some more details or arrange a fiddle for us. Because this solution seems the best for me. Thank you! – Rootical V. Aug 07 '14 at 08:22
4

I was not totally satisfied with the answers provided so I made my own. Improvements:

  • More defensive updating of the scope. Will check to see if a apply/digest is already in progress
  • div will also close when the user presses the escape key
  • window events are unbound when the div is closed (prevents leaks)
  • window events are unbound when the scope is destroyed (prevents leaks)

    function link(scope, $element, attributes, $window) {

    var el = $element[0],
        $$window = angular.element($window);
    
    function onClick(event) {
        console.log('window clicked');
    
        // might need to polyfill node.contains
        if (el.contains(event.target)) {
            console.log('click inside element');
            return;
    
        }
    
        scope.isActive = !scope.isActive;
        if (!scope.$$phase) {
            scope.$apply();
        }
    }
    
    function onKeyUp(event) {
    
        if (event.keyCode !== 27) {
            return;
        }
    
        console.log('escape pressed');
    
        scope.isActive = false;
        if (!scope.$$phase) {
            scope.$apply();
        }
    }
    
    function bindCloseHandler() {
        console.log('binding window click event');
        $$window.on('click', onClick);
        $$window.on('keyup', onKeyUp);
    }
    
    function unbindCloseHandler() {
        console.log('unbinding window click event');
        $$window.off('click', onClick);
        $$window.off('keyup', onKeyUp);
    }
    
    scope.$watch('isActive', function(newValue, oldValue) {
        if (newValue) {
            bindCloseHandler();
        } else {
            unbindCloseHandler();
        }
    });
    
    // prevent leaks - destroy handlers when scope is destroyed
    scope.$on('$destroy', function() {
        unbindCloseHandler();
    });
    

    }

I get $window directly into the link function. However, you do not need to do this exactly to get $window.

function directive($window) {
    return {
        restrict: 'AE',
        link: function(scope, $element, attributes) {
            link.call(null, scope, $element, attributes, $window);
        }
    };
}
Rodrigo Taboada
  • 2,727
  • 4
  • 24
  • 27
alexreardon
  • 606
  • 5
  • 12
4

There is a cool directive called angular-click-outside. You can use it in your project. It is super simple to use:

https://github.com/IamAdamJowett/angular-click-outside

Danielo515
  • 5,996
  • 4
  • 32
  • 66
3

The answer Danny F posted is awesome and nearly complete, but Thịnh's comment is correct, so here is my modified directive to remove the listeners on the $destroy event of the directive:

const ClickModule = angular
.module('clickOutside', [])
.directive('clickOutside', ['$document', function ($document) {
    return {
        restrict: 'A',
        scope: {
            clickOutside: '&'
        },
        link: function (scope, el, attr) {
            const handler = function (e) {
                if (el !== e.target && !el[0].contains(e.target)) {
                    scope.$apply(function () {
                        console.log("hiiii");
                        //  whatever expression you assign to the click-outside attribute gets executed here
                        //  good for closing dropdowns etc
                        scope.$eval(scope.clickOutside);
                    });
                }
            }

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

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

If you put a log in the handler method, you will still see it fire when an element has been removed from the DOM. Adding my small change is enough to remove it. Not trying to steal anyone's thunder, but this is a fix to an elegant solution.

Bigtrizzy
  • 103
  • 2
  • 7
0

Use angular-click-outside

Installation:

bower install angular-click-outside --save
npm install @iamadamjowett/angular-click-outside
yarn add @iamadamjowett/angular-click-outside

Usage:

angular.module('myApp', ['angular-click-outside'])

//in your html
<div class="menu" click-outside="closeThis">
...
</div>

//And then in your controller
$scope.closeThis = function () {
    console.log('closing');
}
abe312
  • 2,547
  • 25
  • 16
0

I found some issues with the implementation in https://github.com/IamAdamJowett/angular-click-outside

If for example the element clicked on is removed from the DOM, the directive above will trigger the logic. That didn't work for me, since I had some logic in a modal that, after click, removed the element with a ng-if.

I rewrote his implementation. Not battle tested, but seems to be working better (at least in my scenario)

angular
  .module('sbs.directives')
  .directive('clickOutside', ['$document', '$parse', '$timeout', clickOutside]);

const MAX_RECURSIONS = 400;

function clickOutside($document, $parse, $timeout) {
  return {
    restrict: 'A',
    link: function ($scope, elem, attr) {
      // postpone linking to next digest to allow for unique id generation
      $timeout(() => {
        function runLogicIfClickedElementIsOutside(e) {
          // check if our element already hidden and abort if so
          if (angular.element(elem).hasClass('ng-hide')) {
            return;
          }

          // if there is no click target, no point going on
          if (!e || !e.target) {
            return;
          }

          let clickedElementIsOutsideDirectiveRoot = false;
          let hasParent = true;
          let recursions = 0;

          let compareNode = elem[0].parentNode;
          while (
            !clickedElementIsOutsideDirectiveRoot &&
            hasParent &&
            recursions < MAX_RECURSIONS
          ) {
            if (e.target === compareNode) {
              clickedElementIsOutsideDirectiveRoot = true;
            }

            compareNode = compareNode.parentNode;
            hasParent = Boolean(compareNode);
            recursions++; // just in case to avoid eternal loop
          }

          if (clickedElementIsOutsideDirectiveRoot) {
            $timeout(function () {
              const fn = $parse(attr['clickOutside']);
              fn($scope, { event: e });
            });
          }
        }

        // if the devices has a touchscreen, listen for this event
        if (_hasTouch()) {
          $document.on('touchstart', function () {
            setTimeout(runLogicIfClickedElementIsOutside);
          });
        }

        // still listen for the click event even if there is touch to cater for touchscreen laptops
        $document.on('click', runLogicIfClickedElementIsOutside);

        // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around
        $scope.$on('$destroy', function () {
          if (_hasTouch()) {
            $document.off('touchstart', runLogicIfClickedElementIsOutside);
          }

          $document.off('click', runLogicIfClickedElementIsOutside);
        });
      });
    },
  };
}

function _hasTouch() {
  // works on most browsers, IE10/11 and Surface
  return 'ontouchstart' in window || navigator.maxTouchPoints;
}