56

I know there are many questions ask similar thing. But no one really solve my issue.

I'm trying to build an directive which will execute an expression when mouse click outside the current element.

Why I need this function? I'm building an app, in this app, there are 3 dropdown menu, 5 dropdown list(like chosen). All these are angular directives. Let's assume all these directives are different. So we have 8 directives. And all of them need a same function: when click out side the element, need hide the dropdown.

I have 2 solutions for this, but both got issue:

Solution A:

app.directive('clickAnywhereButHere', function($document){
  return {
    restrict: 'A',
    link: function(scope, elem, attr, ctrl) {
      elem.bind('click', function(e) {
        // this part keeps it from firing the click on the document.
        e.stopPropagation();
      });
      $document.bind('click', function() {
        // magic here.
        scope.$apply(attr.clickAnywhereButHere);
      })
    }
  }
})

Here is an example for solution A: click here

When you click the first dropdown, then working, then click second input, the first should hide but not.

Solution B:

app.directive('clickAnywhereButHere', ['$document', function ($document) {
    directiveDefinitionObject = {
        link: {
            pre: function (scope, element, attrs, controller) { },
            post: function (scope, element, attrs, controller) {
                onClick = function (event) {
                    var isChild = element.has(event.target).length > 0;
                    var isSelf = element[0] == event.target;
                    var isInside = isChild || isSelf;
                    if (!isInside) {
                        scope.$apply(attrs.clickAnywhereButHere)
                    }
                }
                $document.click(onClick)
            }
        }
    }
    return directiveDefinitionObject
}]);

Here is example for solution B: click here

Solution A working if there is just one directive in the page but not in my app. Because it's prevent bubbling, so first when I click on dropdown1, show dropdown1, then click on dropdown2, click event be prevent, so dropdown1 still showing there even I click outside the dropdown1.

Solution B working in my app which I'm using now. But the issue is it's cause a performance issue. Too many click event be handled on every single click on anywhere in the app. In my current case, there are 8 click event bind with document, so every click execute 8 functions. Which cause my app very slow, especially in IE8.

So is there any better solution for this? Thanks

Stephan Muller
  • 27,018
  • 16
  • 85
  • 126
Zhe
  • 1,060
  • 1
  • 11
  • 19

8 Answers8

33

I would not use event.stopPropagation() since it causes exactly the kind of problems you see in solution A. If possible, I would also resort to blur and focus events. When your dropdown is attached to an input, you can close it when the input loses the focus.

However, handling click events on the document is not so bad either, so if you want to avoid handling the same click event several times, just unbind it from the document when it is not needed anymore. In addition to the expression being evaluated when clicking outside the dropdown, the directive needs to know whether it is active or not:

app.directive('clickAnywhereButHere', ['$document', function ($document) {
    return {
        link: function postLink(scope, element, attrs) {
            var onClick = function (event) {
                var isChild = $(element).has(event.target).length > 0;
                var isSelf = element[0] == event.target;
                var isInside = isChild || isSelf;
                if (!isInside) {
                    scope.$apply(attrs.clickAnywhereButHere)
                }
            }
            scope.$watch(attrs.isActive, function(newValue, oldValue) {
                if (newValue !== oldValue && newValue == true) {
                    $document.bind('click', onClick);
                }
                else if (newValue !== oldValue && newValue == false) {
                    $document.unbind('click', onClick);
                }
            });
        }
    };
}]);

When using the directive, just provide another expression like this:

<your-dropdown click-anywhere-but-here="close()" is-active="isDropdownOpen()"></your-dropdown>

I have not tested your onClick function. I assume it works as expected. Hope this helps.

lex82
  • 11,173
  • 2
  • 44
  • 69
  • Very nice approach, though it's worth mentioning I had to set the onClick function on the scope to make it work for two instances of the directive. – Steven Ryssaert Apr 30 '15 at 18:48
  • 17
    Nice solution, I just needed to remove jQuery dependency, instead of: `var isChild = $(element).has(event.target).length > 0;` you may use: `var isChild = element[0].contains(event.target);` – Michal Moravcik Jul 30 '15 at 15:01
9

You should use ngBlur and ngFocus to show or hide your dropdowns. When somebody clicks it then it gets focused else it gets blurred.

Also, refer to this question How to set focus on input field? for setting focus in AngularJS.

EDIT : For every directive (drop down menu or list, lets call it Y) you will have to show it when you click on an element (lets call it X) and you need to hide it when you click anywhere outside Y (excluding X obviously). Y has property isYvisisble. So when somebody clicks on X (ng-click) then set "isYvisible" to be true and set Focus on Y. When somebody clicks outside Y (ng-blur) then you set "isYvisible" to be false, it gets hidden. You need to share a variable ("isYvisible") between two different element/directives and you can use scope of controller or services to do that. There are other alternatives to that also but that is outside scope of question.

Community
  • 1
  • 1
Rishabh Singhal
  • 1,173
  • 10
  • 22
  • 1
    when the dropdown trigger is an input or some element which have the focus event. It's a good solution. But what I'm trying to do is build an independent directive. So I can't assume there is always a focus event. Not sure the idea to build an independent directive is good or bad. Maybe I'm wrong at the beginning. – Zhe Nov 26 '13 at 22:18
  • Brilliant elegant solution :) – J_A_X Sep 26 '14 at 04:48
4

Your solution A is the most correct, but you should add another parameter to the directive for tracking if it is open:

link: function(scope, elem, attr, ctrl) {
  elem.bind('click', function(e) {
    // this part keeps it from firing the click on the document.
    if (isOpen) {
      e.stopPropagation();
    }
  });
  $document.bind('click', function() {
    // magic here.
    isOpen = false;
    scope.$apply(attr.clickAnywhereButHere);
  })
}
Barry
  • 49
  • 1
3
post: function ($scope, element, attrs, controller) { 
  element.on("click", function(){
    console.log("in element Click event");
    $scope.onElementClick = true;
    $document.on("click", $scope.onClick);
  });

  $scope.onClick = function (event) {
    if($scope.onElementClick && $scope.open)
    {
      $scope.onElementClick = false;
      return;
    }
    $scope.open = false;
    $scope.$apply(attrs.clickAnywhereButHere)
    $document.off("click", $scope.onClick);
  };
}
Krishna
  • 179
  • 3
  • 17
3

A bit simpler version than most upvoted answer, for me it's more clear and works just fine!

app.directive('clickAnywhereButHere', function() {
        return {
            restrict : 'A',
            link: { 
                post: function(scope, element, attrs) {
                    element.on("click", function(event) {
                        scope.elementClicked = event.target;
                        $(document).on("click", onDocumentClick);
                    });

                    var onDocumentClick = function (event) {
                        if(scope.elementClicked === event.target) {
                            return;
                        }
                        scope.$apply(attrs.clickAnywhereButHere);
                        $(document).off("click", onDocumentClick);
                    };
                }
            }
        };
    });
tytyryty
  • 741
  • 7
  • 17
  • 1
    This doesn't work if they click on another 'clickAnywhereButHere' directive. – Jordash Sep 27 '19 at 20:55
  • @Jordash, you can play with this code a bit if your requirements is to have more than one such directive on the page. For example instead of saving state to one property, you can create some map of elements based on their ID as a key and save their 'clicked state' as a value. – tytyryty Oct 04 '19 at 08:21
2

Here is a solution I am using (possible a bit late answer, but hopefully helpful for others who come through this)

 link: function (scope, element, attr) {

        var clickedOutsite = false;
        var clickedElement = false;

        $(document).mouseup(function (e) {
            clickedElement = false;
            clickedOutsite = false;
        });

        element.on("mousedown", function (e) {

                clickedElement = true;
                if (!clickedOutsite && clickedElement) {
                    scope.$apply(function () {
                    //user clicked the element
                    scope.codeCtrl.elementClicked = true;
                    });
                }

        });

        $(document).mousedown(function (e) {
            clickedOutsite = true;
            if (clickedOutsite && !clickedElement) {
                scope.$apply(function () {
                    //user clicked outsite the element 
                    scope.codeCtrl.elementClicked = false;
                });
            }
        });
    }
Mert
  • 1,333
  • 1
  • 12
  • 15
1

Here is a solution I used that only needs the click's event (available as $event in the ngClick directive). I wanted a menu with items that, when clicked would:

  • toggle a submenu's display
  • hide any other submenu if it were displayed
  • hide the submenu if a click occurred outside.

This code sets class 'active' on the menu item so that can be used to show or hide it's submenu

// this could also be inside a directive's link function.
// each menu element will contain data-ng-click="onMenuItemClick($event)".
// $event is the javascript event object made available by ng-click.
$scope.onMenuItemClick = function(menuElementEvent) {
    var menuElement = menuElementEvent.currentTarget,
        clickedElement = menuElementEvent.target,
        offRootElementClick; // where we will save angular's event unbinding function

    if (menuElement !== clickedElement) {
        return;
    }

    if (menuElement.classList.contains('active')) {
        menuElement.classList.remove('active');
        // if we were listening for outside clicks, stop
        offRootElementClick && offRootElementClick();
        offRootElementClick = undefined;
    } else {
        menuElement.classList.add('active');
        // listen for any click inside rootElement.
        // angular's bind returns a function that can be used to stop listening
        // I used $rootElement, but use $document if your angular app is nested in the document
        offRootElementClick = $rootElement.bind('click', function(rootElementEvent) {
            var anyClickedElement = rootElementEvent.target;
            // if it's not a child of the menuElement, close the submenu
            if(!menuElement.contains(anyClickedElement)) {
                menuElement.classList.remove('active');
                // and stop outside listenting
                offRootElementClick && offRootElementClick();
                offOutsideClick = undefined;
            }
        });
    }
}
Jason S.
  • 485
  • 2
  • 10
1

@lex82 answer is good, and forms the basis of this answer but mine differs in a few ways:

  1. Its in TypeScript
  2. It removes the click binding when the scope is destroyed meaning you do not have to manage the click binding separately with a property
  3. The timeout ensures that if the object with click-out on is created via a mouse event, that the very same mouse event doesn't actually inadvertently trigger the close mechanism

    export interface IClickOutDirectiveScope extends angular.IScope {
    
        clickOut: Function;
    }
    
    export class ClickOutDirective implements angular.IDirective {
    
        public restrict = "A";
        public scope = {
            clickOut: "&"
        }
    
        public link: ($scope: IClickOutDirectiveScope, element: angular.IAugmentedJQuery, attrs: angular.IAttributes) => void;
    
        constructor($timeout: angular.ITimeoutService, $document: angular.IDocumentService) {
    
            ClickOutDirective.prototype.link = ($scope: IClickOutDirectiveScope, $element: angular.IAugmentedJQuery, attrs: ng.IAttributes) => {
    
                var onClick = (event: JQueryEventObject) => {
                    var isChild = $element[0].contains(event.target);
                    var isSelf = $element[0] === event.target;
                    var isInside = isChild || isSelf;
    
                    if (!isInside) {
                        if ($scope.clickOut) {
                            $scope.$apply(() => {
                                $scope.clickOut();
                            });
                        }
                    }
                }
    
                $timeout(() => {
                    $document.bind("click", onClick);
                }, 500);
    
                $scope.$on("$destroy", () => {
                    $document.unbind("click", onClick);
                });
            }
        }
    
        static factory(): ng.IDirectiveFactory {
            const directive = ($timeout: angular.ITimeoutService, $document: angular.IDocumentService) => new ClickOutDirective($timeout, $document);
    
            directive.$inject = ["$timeout", "$document"];
    
            return directive;
        }
    }
    
    angular.module("app.directives")
        .directive("clickOut", ClickOutDirective.factory());
    
Chris
  • 26,744
  • 48
  • 193
  • 345