user5453221
user5453221

Reputation: 31

Click outside to hide container with button

I am using the following Angular directive to hide div elements when the user clicks or touches outside.

https://github.com/TheSharpieOne/angular-off-click

It is working as expected but when you click outside of a div on a button that toggles the div the 'angular-off-click' callback is fired to hide the container but then the toggle function attached to the button is called, reopening the div.

The 'off-click-filter' solves this by adding exceptions which use css selectors to check before the hide function is called. However this was removed as we did not want the extra bloat of css class exceptions in the html markup.

The desired function is for the toggle button not to fire its handler when you click outside of the container

Update It is only a problem on touch devices where there is a 300ms delay by default. So this means that the callback is fired to hide the container then the toggle functions runs after 300ms, reopening the container. On desktop, with a mouse click, the toggle function fires first and then the callback

// Angular App Code
var app = angular.module('myApp', ['offClick']);

app.controller('myAppController', ['$scope', '$timeout', function($scope,$timeout) {
  $scope.showContainer = false;
  
  $scope.toggleContainer = function() {
    $timeout(function() {
      $scope.showContainer = !$scope.showContainer;
    }, 300);
    
  };
  
  $scope.hideContainer = function(scope, event, p) {
    $scope.showContainer = false;
    console.log('event: ', event);
    console.log('scope: ', scope);
    console.log(p);
  };
}]);

// Off Click Directive Code
angular.module('offClick', [])
    .directive('offClick', ['$rootScope', '$parse', function ($rootScope, $parse) {
    var id = 0;
    var listeners = {};
    // add variable to detect touch users moving..
    var touchMove = false;

    // Add event listeners to handle various events. Destop will ignore touch events
    document.addEventListener("touchmove", offClickEventHandler, true);
    document.addEventListener("touchend", offClickEventHandler, true);
    document.addEventListener('click', offClickEventHandler, true);

    function targetInFilter(target, elms) {
        if (!target || !elms) return false;
        var elmsLen = elms.length;
        for (var i = 0; i < elmsLen; ++i) {
            var currentElem = elms[i];
            var containsTarget = false;
            try {
                containsTarget = currentElem.contains(target);
            } catch (e) {
                // If the node is not an Element (e.g., an SVGElement) node.contains() throws Exception in IE,
                // see https://connect.microsoft.com/IE/feedback/details/780874/node-contains-is-incorrect
                // In this case we use compareDocumentPosition() instead.
                if (typeof currentElem.compareDocumentPosition !== 'undefined') {
                    containsTarget = currentElem === target || Boolean(currentElem.compareDocumentPosition(target) & 16);
                }
            }

            if (containsTarget) {
                return true;
            }
        }
        return false;
    }

    function offClickEventHandler(event) {
        // If event is a touchmove adjust touchMove state
        if( event.type === 'touchmove' ){
            touchMove = true;
            // And end function
            return false;
        }
        // This will always fire on the touchend after the touchmove runs...
        if( touchMove ){
            // Reset touchmove to false
            touchMove = false;
            // And end function
            return false;
        }
        var target = event.target || event.srcElement;
        angular.forEach(listeners, function (listener, i) {
            if (!(listener.elm.contains(target) || targetInFilter(target, listener.offClickFilter))) {
                $rootScope.$evalAsync(function () {
                    listener.cb(listener.scope, {
                        $event: event
                    });
                });
            }

        });
    }

    return {
        restrict: 'A',
        compile: function ($element, attr) {
            var fn = $parse(attr.offClick);
            return function (scope, element) {
                var elmId = id++;
                var offClickFilter;
                var removeWatcher;

                offClickFilter = document.querySelectorAll(scope.$eval(attr.offClickFilter));

                if (attr.offClickIf) {
                    removeWatcher = $rootScope.$watch(function () {
                        return $parse(attr.offClickIf)(scope);
                    }, function (newVal) {
                        if (newVal) {
                            on();
                        } else if (!newVal) {
                            off();
                        }
                    });
                } else {
                    on();
                }

                attr.$observe('offClickFilter', function (value) {
                    offClickFilter = document.querySelectorAll(scope.$eval(value));
                });

                scope.$on('$destroy', function () {
                    off();
                    if (removeWatcher) {
                        removeWatcher();
                    }
                    element = null;
                });

                function on() {
                    listeners[elmId] = {
                        elm: element[0],
                        cb: fn,
                        scope: scope,
                        offClickFilter: offClickFilter
                    };
                }

                function off() {
                    listeners[elmId] = null;
                    delete listeners[elmId];
                }
            };
        }
    };
}]);
/* Styles go here */

.container {
  background: blue;
  color: #fff;
  height: 300px;
  width: 300px;
}
<!DOCTYPE html>
<html>

  <head>
    <script src="https://code.angularjs.org/1.4.0/angular.js"></script>
    <link rel="stylesheet" href="style.css" />
    
    
  </head>

  <body data-ng-app="myApp">
    <h1>Hello Plunker!</h1>
    <div data-ng-controller="myAppController">
      
      <button data-ng-click="toggleContainer()">Toggle Container</button>
      
      <div class="container" data-ng-show="showContainer" data-off-click="hideContainer()" data-off-click-if="showContainer">
        This is the container
      </div>
    </div>
    
  </body>

</html>

http://jsbin.com/hibovu

Upvotes: 3

Views: 1048

Answers (1)

Mosh Feu
Mosh Feu

Reputation: 29287

The problem is that when you click on the button the both of the functions are firing:

  1. hideContainer from the directive.
  2. toggleContainer from the click event (that showing the div again).

The solution

Add event.stopPropagation(); before you evaluate the hide callback.

How do you do this?

  1. Pass the event to the function data-off-click="hideContainer($event)".
  2. Add the $event param in the definition of the hideContainer function in the $scope like this: $scope.hideContainer = function($event)

And the full code:

// Angular App Code
var app = angular.module('myApp', ['offClick']);

app.controller('myAppController', ['$scope', '$timeout', function($scope,$timeout) {
  $scope.showContainer = false;

  $scope.toggleContainer = function() {
    $timeout(function() {
      $scope.showContainer = !$scope.showContainer;
    }, 300);
  };

  $scope.hideContainer = function($event) {
    $event.stopPropagation();
    $timeout(function(){
      $scope.showContainer = false;  
    });
  };
}]);

// Off Click Directive Code
angular.module('offClick', [])
.directive('offClick', ['$rootScope', '$parse', function ($rootScope, $parse) {
  var id = 0;
  var listeners = {};
  // add variable to detect touch users moving..
  var touchMove = false;

  // Add event listeners to handle various events. Destop will ignore touch events
  document.addEventListener("touchmove", offClickEventHandler, true);
  document.addEventListener("touchend", offClickEventHandler, true);
  document.addEventListener('click', offClickEventHandler, true);

  function targetInFilter(target, elms) {
    if (!target || !elms) return false;
    var elmsLen = elms.length;
    for (var i = 0; i < elmsLen; ++i) {
      var currentElem = elms[i];
      var containsTarget = false;
      try {
        containsTarget = currentElem.contains(target);
      } catch (e) {
        // If the node is not an Element (e.g., an SVGElement) node.contains() throws Exception in IE,
        // see https://connect.microsoft.com/IE/feedback/details/780874/node-contains-is-incorrect
        // In this case we use compareDocumentPosition() instead.
        if (typeof currentElem.compareDocumentPosition !== 'undefined') {
          containsTarget = currentElem === target || Boolean(currentElem.compareDocumentPosition(target) & 16);
        }
      }

      if (containsTarget) {
        return true;
      }
    }
    return false;
  }

  function offClickEventHandler(event) {
    // If event is a touchmove adjust touchMove state
    if( event.type === 'touchmove' ){
      touchMove = true;
      // And end function
      return false;
    }
    // This will always fire on the touchend after the touchmove runs...
    if( touchMove ){
      // Reset touchmove to false
      touchMove = false;
      // And end function
      return false;
    }
    var target = event.target || event.srcElement;
    angular.forEach(listeners, function (listener, i) {
      if (!(listener.elm.contains(target) || targetInFilter(target, listener.offClickFilter))) {
        //$rootScope.$evalAsync(function () {
        listener.cb(listener.scope, {
          $event: event
        });
        //});
      }
    });
  }

  return {
    restrict: 'A',
    compile: function ($element, attr) {
      var fn = $parse(attr.offClick);
      return function (scope, element) {
        var elmId = id++;
        var offClickFilter;
        var removeWatcher;

        offClickFilter = document.querySelectorAll(scope.$eval(attr.offClickFilter));

        if (attr.offClickIf) {
          removeWatcher = $rootScope.$watch(function () {
            return $parse(attr.offClickIf)(scope);
          }, function (newVal) {
            if (newVal) {
              on();
            } else if (!newVal) {
              off();
            }
          });
        } else {
          on();
        }

        attr.$observe('offClickFilter', function (value) {
          offClickFilter = document.querySelectorAll(scope.$eval(value));
        });

        scope.$on('$destroy', function () {
          off();
          if (removeWatcher) {
            removeWatcher();
          }
          element = null;
        });

        function on() {
          listeners[elmId] = {
            elm: element[0],
            cb: fn,
            scope: scope,
            offClickFilter: offClickFilter
          };
        }

        function off() {
          listeners[elmId] = null;
          delete listeners[elmId];
        }
      };
    }
  };
}]);
.container {
  background: blue;
  color: #fff;
  height: 300px;
  width: 300px;
}
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body data-ng-app="myApp">
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.0/angular.min.js"></script>
    <h1>Hello Plunker!</h1>
    <div data-ng-controller="myAppController">
      <button data-ng-click="toggleContainer()">Toggle Container</button>
      <div class="container" data-ng-show="showContainer" data-off-click="hideContainer($event)" data-off-click-if="showContainer">
        This is the container
      </div>
    </div>
  </body>
</html>

http://jsbin.com/hibovu/3/edit?html,css,js

Upvotes: 1

Related Questions