Stop propagation of underlying ng-click inside a jQuery click event
Asked Answered
B

3

17

A Twitter Bootstrap dropdown is nested inside a tr. The tr is clickable through ng-click. Clicking anywhere on the page will collapse the dropdown menu. That behavior is defined in a directive through $document.bind('click', closeMenu).

So, when menu is opened, and the user click on a row, I want the menu to close (as it does) AND I want to prevent the click event on the row.

JSFiddle : http://jsfiddle.net/LMc2f/1/
JSFiddle + directive inline : http://jsfiddle.net/9DM8U/1/

Relevant code from ui-bootstrap-tpls-0.10.0.js :

angular.module('ui.bootstrap.dropdownToggle', []).directive('dropdownToggle', ['$document', '$location', function ($document, $location) {
  var openElement = null,
      closeMenu   = angular.noop;
  return {
    restrict: 'CA',
    link: function(scope, element, attrs) {
      scope.$watch('$location.path', function() { closeMenu(); });
      element.parent().bind('click', function() { closeMenu(); });
      element.bind('click', function (event) {

        var elementWasOpen = (element === openElement);

        event.preventDefault();
        event.stopPropagation();

        if (!!openElement) {
          closeMenu();
        }

        if (!elementWasOpen && !element.hasClass('disabled') && !element.prop('disabled')) {
          element.parent().addClass('open');
          openElement = element;
          closeMenu = function (event) {
            if (event) {
              event.preventDefault();
              event.stopPropagation();
            }
            $document.unbind('click', closeMenu);
            element.parent().removeClass('open');
            closeMenu = angular.noop;
            openElement = null;
          };
          $document.bind('click', closeMenu);
        }
      });
    }
  };
}]);

I can't figure out how to stop the underlying ng-click event inside closeMenu.

NOTE : I can't find a way to access $event so I haven't been able to try $event.stopPropagation().

Bayou answered 31/1, 2014 at 17:3 Comment(7)
possible duplicate of What's the best way to cancel event propagation between nested ng-click calls?Goatee
@Goatee I understand that I can use ng-click="$event.stopPropagation()" on each a but it won't solve the click-anywhere-to-collapse-the-dropdown-menu scenario where a ng-click call is fired after the jQuery click event bound to $document is completed.Bayou
@Goatee can't you manage to solve my problem using the provided JSFiddle?Bayou
So, when menu is opened, and you click on row, you want the menu to close (as it does) AND you want to prevent the click event on the row?Goatee
@Goatee yes. Exactly. However, inside the function closeMenu, event.preventDefault() and event.stopPropagation() won't cancel the underlying ng-click. I assume that this is due to jQuery and Angular not sharing the same call stack.Bayou
@Goatee I understand that $document is a jQuery wrapper for window.document and that the $event variable is not accessible in the context of closeMenu. Does that make any sense?Bayou
@Goatee see the second JSFiddle provided where you can play with the directive itself.Bayou
G
16

The dropdown directive binds the click event on the document, but when you click on the row, the event starts propagating from the target element down to the root document node (td -> tr -> table -> document).

So that's why your ng-click handler, that you have on your row, always gets called, even though the directive is "stopping" the bubble on document click.

Solution is to use the useCapture flag when adding the click handler for the document.

After initiating capture, all events of the specified type will be dispatched to the registered listener before being dispatched to any EventTarget beneath it in the DOM tree. mdn

Now, to instruct the dropdown directive to use your own handler, you need to change the source of the directive. But it's a third party directive, and you probably don't want to do that, for maintability reasons.

This is where the powerful angular $decorator kicks in. You may use the $decorator to change the source of the third-party module on-the-fly, without actually touching the actual source files.

So, with decorator in place, and with custom event handler on the document node, this is how you can make that dropdown behave:

FIDDLE

var myApp = angular.module('myApp', []);

/**
 * Original dropdownToggle directive from ui-bootstrap.
 * Nothing changed here.
 */
myApp.directive('dropdownToggle', ['$document', '$location', function ($document, $location) {
  var openElement = null,
      closeMenu   = angular.noop;
  return {
    restrict: 'CA',
    link: function(scope, element, attrs) {
      scope.$watch('$location.path', function() { closeMenu(); });
      element.parent().bind('click', function() { closeMenu(); });
      element.bind('click', function (event) {

        var elementWasOpen = (element === openElement);

        event.preventDefault();
        event.stopPropagation();

        if (!!openElement) {
          closeMenu();
        }

        if (!elementWasOpen && !element.hasClass('disabled') && !element.prop('disabled')) {
          element.parent().addClass('open');
          openElement = element;
          closeMenu = function (event) {
            if (event) {
              event.preventDefault();
              event.stopPropagation();
            }
            $document.unbind('click', closeMenu);
            element.parent().removeClass('open');
            closeMenu = angular.noop;
            openElement = null;
          };
          $document.bind('click', closeMenu); /* <--- CAUSE OF ALL PROBLEMS ----- */
        }
      });
    }
  };
}]);


/**
 * This is were we decorate the dropdownToggle directive
 * in order to change the way the document click handler works
 */
myApp.config(function($provide){
  'use strict';

  $provide.decorator('dropdownToggleDirective', [
      '$delegate',
      '$document',
      function ($delegate, $document) {

        var directive = $delegate[0];
        var openElement = null;
        var closeMenu = angular.noop;

        function handler(e){
            var elm = angular.element(e.target);
          if(!elm.parents('.dropdown-menu').length){
            e.stopPropagation();
            e.preventDefault();
          }
          closeMenu();
          // After closing the menu, we remove the all-seeing handler
          // to allow the application click events to work nnormally
          $document[0].removeEventListener('click', handler, true);
        }

        directive.compile = function(){
          return function(scope, element) {
            scope.$watch('$location.path', closeMenu);
            element.parent().bind('click', closeMenu);
            element.bind('click', function (event) {

              var elementWasOpen = (element === openElement);

              event.preventDefault();
              event.stopPropagation();

              if (!!openElement) {
                closeMenu();
              }

              if (!elementWasOpen && !element.hasClass('disabled') && !element.prop('disabled')) {
                element.parent().addClass('open');
                openElement = element;
                closeMenu = function (event) {
                  if (event) {
                    event.preventDefault();
                    event.stopPropagation();
                  }
                  $document.unbind('click', closeMenu);
                  element.parent().removeClass('open');
                  closeMenu = angular.noop;
                  openElement = null;
                };


                // We attach the click handler by specifying the third "useCapture" parameter as true
                $document[0].addEventListener('click', handler, true);
              }
            });
          };
        };

        return $delegate;
      }
  ]);

});

UPDATE:

Note that the updated custom handler will prevent bubbling unless the target element is the actual drop-down option. This will solve the problem where click event was being prevented even when clicking on the drop-down options.

This still won't prevent the event to bubble down to row (from drop-down option), but this is something that's not in any way related to the drop-down directive. Anyway, to prevent such bubbling you can pass the $event object to ng-click expression function and use that object to stop the even to bubble down to the table row:

<div ng-controller="DropdownCtrl">
  <table>
    <tr ng-click="clicked('row')">
      <td>

        <div class="btn-group">
          <button type="button" class="btn btn-default dropdown-toggle">
            Action <span class="caret"></span>
          </button>
          <ul class="dropdown-menu" role="menu">
            <li ng-repeat="choice in items">
              <a ng-click="clicked('link element', $event)">{{choice}}</a>
            </li>
          </ul>
        </div>

      </td>
    </tr>
  </table>
</div>
function DropdownCtrl($scope) {
  $scope.items = [
    "Action",
    "Another action",
    "Something else here"
  ];

  $scope.clicked = function(what, event) {
    alert(what + ' clicked');
    if(event){
      event.stopPropagation();
      event.preventDefault();
    }
  }

}
Goatee answered 31/1, 2014 at 22:44 Comment(2)
I have troubles with your JSFiddle. I'm trying your solution meanwhile.Bayou
Ok, one problem solved. Except now, I can't select the option. That click gets prevented too. Any idea?Bayou
C
20

I would lean towards just calling $event.stopPropagation() from within the template itself. Logic related to events most likely belongs there. Should make unit testing easier as well. Someone looking at the template would also know that the event didn't bubble up without looking at the underlying controller.

<div ng-click="parentHandler()">
    <div ng-click="childHandler(); $event.stopPropagation()"></div>
</div>
Colleencollege answered 3/10, 2014 at 20:1 Comment(2)
This is a really clean solution.Bucaramanga
This solution works for Angular 2+ (version 6.1 at time of writing). In my case, the Angular Material menu trigger directive on the table cell is handling the click event, so I just needed to stop it from bubbling up to the row. [matMenuTriggerFor]="moreActions" (click)="$event.stopPropagation()"Homelike
G
16

The dropdown directive binds the click event on the document, but when you click on the row, the event starts propagating from the target element down to the root document node (td -> tr -> table -> document).

So that's why your ng-click handler, that you have on your row, always gets called, even though the directive is "stopping" the bubble on document click.

Solution is to use the useCapture flag when adding the click handler for the document.

After initiating capture, all events of the specified type will be dispatched to the registered listener before being dispatched to any EventTarget beneath it in the DOM tree. mdn

Now, to instruct the dropdown directive to use your own handler, you need to change the source of the directive. But it's a third party directive, and you probably don't want to do that, for maintability reasons.

This is where the powerful angular $decorator kicks in. You may use the $decorator to change the source of the third-party module on-the-fly, without actually touching the actual source files.

So, with decorator in place, and with custom event handler on the document node, this is how you can make that dropdown behave:

FIDDLE

var myApp = angular.module('myApp', []);

/**
 * Original dropdownToggle directive from ui-bootstrap.
 * Nothing changed here.
 */
myApp.directive('dropdownToggle', ['$document', '$location', function ($document, $location) {
  var openElement = null,
      closeMenu   = angular.noop;
  return {
    restrict: 'CA',
    link: function(scope, element, attrs) {
      scope.$watch('$location.path', function() { closeMenu(); });
      element.parent().bind('click', function() { closeMenu(); });
      element.bind('click', function (event) {

        var elementWasOpen = (element === openElement);

        event.preventDefault();
        event.stopPropagation();

        if (!!openElement) {
          closeMenu();
        }

        if (!elementWasOpen && !element.hasClass('disabled') && !element.prop('disabled')) {
          element.parent().addClass('open');
          openElement = element;
          closeMenu = function (event) {
            if (event) {
              event.preventDefault();
              event.stopPropagation();
            }
            $document.unbind('click', closeMenu);
            element.parent().removeClass('open');
            closeMenu = angular.noop;
            openElement = null;
          };
          $document.bind('click', closeMenu); /* <--- CAUSE OF ALL PROBLEMS ----- */
        }
      });
    }
  };
}]);


/**
 * This is were we decorate the dropdownToggle directive
 * in order to change the way the document click handler works
 */
myApp.config(function($provide){
  'use strict';

  $provide.decorator('dropdownToggleDirective', [
      '$delegate',
      '$document',
      function ($delegate, $document) {

        var directive = $delegate[0];
        var openElement = null;
        var closeMenu = angular.noop;

        function handler(e){
            var elm = angular.element(e.target);
          if(!elm.parents('.dropdown-menu').length){
            e.stopPropagation();
            e.preventDefault();
          }
          closeMenu();
          // After closing the menu, we remove the all-seeing handler
          // to allow the application click events to work nnormally
          $document[0].removeEventListener('click', handler, true);
        }

        directive.compile = function(){
          return function(scope, element) {
            scope.$watch('$location.path', closeMenu);
            element.parent().bind('click', closeMenu);
            element.bind('click', function (event) {

              var elementWasOpen = (element === openElement);

              event.preventDefault();
              event.stopPropagation();

              if (!!openElement) {
                closeMenu();
              }

              if (!elementWasOpen && !element.hasClass('disabled') && !element.prop('disabled')) {
                element.parent().addClass('open');
                openElement = element;
                closeMenu = function (event) {
                  if (event) {
                    event.preventDefault();
                    event.stopPropagation();
                  }
                  $document.unbind('click', closeMenu);
                  element.parent().removeClass('open');
                  closeMenu = angular.noop;
                  openElement = null;
                };


                // We attach the click handler by specifying the third "useCapture" parameter as true
                $document[0].addEventListener('click', handler, true);
              }
            });
          };
        };

        return $delegate;
      }
  ]);

});

UPDATE:

Note that the updated custom handler will prevent bubbling unless the target element is the actual drop-down option. This will solve the problem where click event was being prevented even when clicking on the drop-down options.

This still won't prevent the event to bubble down to row (from drop-down option), but this is something that's not in any way related to the drop-down directive. Anyway, to prevent such bubbling you can pass the $event object to ng-click expression function and use that object to stop the even to bubble down to the table row:

<div ng-controller="DropdownCtrl">
  <table>
    <tr ng-click="clicked('row')">
      <td>

        <div class="btn-group">
          <button type="button" class="btn btn-default dropdown-toggle">
            Action <span class="caret"></span>
          </button>
          <ul class="dropdown-menu" role="menu">
            <li ng-repeat="choice in items">
              <a ng-click="clicked('link element', $event)">{{choice}}</a>
            </li>
          </ul>
        </div>

      </td>
    </tr>
  </table>
</div>
function DropdownCtrl($scope) {
  $scope.items = [
    "Action",
    "Another action",
    "Something else here"
  ];

  $scope.clicked = function(what, event) {
    alert(what + ' clicked');
    if(event){
      event.stopPropagation();
      event.preventDefault();
    }
  }

}
Goatee answered 31/1, 2014 at 22:44 Comment(2)
I have troubles with your JSFiddle. I'm trying your solution meanwhile.Bayou
Ok, one problem solved. Except now, I can't select the option. That click gets prevented too. Any idea?Bayou
E
12

you need to pass the event of ng-click on line 2 of your fiddle, and then do preventDefault and stopPropigation on that object within your method do

<tr ng-click="do($event)">
Ex answered 31/1, 2014 at 18:50 Comment(6)
This event is last on the click chain and should not be fired at all if a the dropdown menu is opened. I tried your suggestion and I still get an alert message when I click on the row to close the dropdown menu.Bayou
closeMenu() has an event as an argument. You probably need to pass the click event argument to closeMenu like so: element.parent().bind('click', function(event) { closeMenu(event); });Ex
If you look closely at the code the event argument is passed to closeMenu via $document.bind('click', closeMenu). event.preventDefault() and event.stopPropagation() are called with no effect on the underlying ng-click. My assumption is that the jQuery event object is operating on a different stack than $event.Bayou
Are you sure that the event object on the parent and it's child are the same? It also looks like closeMenu is being called twice when there is a parent click, once with event of child, and once from parent, without eventEx
There is no click event on the parent. The parent is just a div that holds the open CSS class. The event that bothers me is the on fired by ng-click on the table row itself. Notice, within the Directive, that a jQuery click event is bound and unbound to $document (the whole page) to allow the user to click anywhere on the page and close the dropdown menu. I can't find a way to cancel the AngularJS event to follow INSIDE that jQuery even.Bayou
have you looked at this? #17624561Ex

© 2022 - 2024 — McMap. All rights reserved.