在元素外单击时触发事件的指令

56

我知道有很多类似的问题,但没有一个真正解决我的问题。

我正在尝试构建一个指令,当鼠标点击当前元素外部时执行一个表达式。

为什么需要这个功能呢?我正在构建一个应用程序,在这个应用程序中有3个下拉菜单,5个下拉列表(类似于chosen)。所有这些都是Angular指令。假设所有这些指令都不同。所以我们有8个指令。并且所有这些指令都需要一个相同的功能: 当点击元素外部时,需要隐藏下拉菜单。

我有两种解决方案,但都有问题:

方案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);
      })
    }
  }
})

这是解决方案A的示例:点击此处

当您单击第一个下拉菜单,然后进行操作,再单击第二个输入框时,第一个应该隐藏但未隐藏。

解决方案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
}]);

以下是解决方案B的示例:点击此处

如果页面中只有一个指令,则解决方案A可以正常工作,但在我的应用程序中无法正常工作。因为它会防止事件冒泡,因此当我单击dropdown1时,首先显示dropdown1,然后单击dropdown2,单击事件被阻止,因此dropdown1仍然显示在那里,即使我在dropdown1外部单击。

解决方案B在我现在使用的应用程序中有效。但问题是它会导致性能问题。每次在应用程序的任何位置单击时,都会处理过多的单击事件。在我的当前情况下,有8个单击事件绑定到文档上,因此每次单击都会执行8个函数。这导致我的应用程序非常慢,特别是在IE8中。

所以,有没有更好的解决方案?谢谢


3
如果您提供Plunker/Fiddle将会很棒。 - Maxim Shoustin
2
在演示中提供一些基本示例。这似乎比它应该的更复杂。 - charlietfl
1
解决方案B正在污染全局范围。如果这是有意为之的,那么这是非常糟糕的做法。如果不是,它可能是你问题的一部分来源。 - Yoshi
你正在使用哪个版本的angularJS?如果我正确理解了您的需求,我已经提供了可能有效的解决方案。 - Rishabh Singhal
大家好,示例已添加。 - Zhe
显示剩余2条评论
8个回答

33

在这种情况下,我不建议使用 event.stopPropagation(),因为它会导致解决方案A中出现的问题。如果可能的话,我也会使用模糊和聚焦事件。当您的下拉菜单附加到输入时,可以在输入失去焦点时关闭它。

但是,在文档上处理单击事件也不会太糟糕,因此,如果您想要避免多次处理相同的单击事件,只需在不再需要时从文档中取消绑定即可。除了在单击下拉列表以外的表达式被评估之外,指令还需要知道它是否处于活动状态:

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);
                }
            });
        }
    };
}]);
使用该指令时,只需提供另一个类似于以下的表达式:
<your-dropdown click-anywhere-but-here="close()" is-active="isDropdownOpen()"></your-dropdown>

我没有测试过你的onClick函数。我假设它按预期工作。希望这可以帮到你。


非常好的方法,虽然值得一提的是,我不得不在作用域上设置onClick函数,才能使其在两个指令实例中运行。 - Steven Ryssaert
17
不错的解决方案,我只需要去掉 jQuery 的依赖,替换掉原来的代码: var isChild = $(element).has(event.target).length > 0; 你可以采用如下代码: var isChild = element[0].contains(event.target); 这样做可以实现相同的功能。 - Michal Moravcik

9

您应该使用ngBlurngFocus来显示或隐藏下拉菜单。当有人单击它时,它会获得焦点,否则它会失去焦点。

此外,请参考这个问题如何在AngularJS中设置输入字段的焦点?来设置焦点。

编辑: 对于每个指令(下拉菜单或列表,我们称之为Y),您需要在单击元素(我们称之为X)时显示它,并在单击Y之外的任何地方(显然不包括X)时隐藏它。 Y具有属性isYvisisble。 因此,当有人单击X(ng-click)时,将“isYvisible”设置为true,并在Y上设置焦点。 当有人在Y之外单击(ng-blur)时,您将“isYvisible”设置为false,它会被隐藏。 您需要在两个不同的元素/指令之间共享一个变量(“isYvisible”),您可以使用控制器或服务的作用域来实现。还有其他替代方法,但这超出了问题的范围。


1
当下拉触发器是输入框或具有焦点事件的其他元素时,这是一个很好的解决方案。但我尝试构建一个独立指令,因此不能假设始终存在焦点事件。不确定构建独立指令的想法是好是坏。也许我的起点就错了。 - Zhe
优秀的优雅解决方案 :) - J_A_X

4
您的解决方案A是最正确的,但您需要为指令添加另一个参数以跟踪它是否打开:
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);
  })
}

3
一个比大多数得到点赞的答案更简单的版本,对我来说更加清晰,并且完全可行!
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);
                    };
                }
            }
        };
    });

1
如果他们点击另一个“clickAnywhereButHere”指令,这将无效。 - Jordash
@Jordash,如果你的要求是页面上有多个这样的指令,那么你可以稍微调整一下这段代码。例如,你可以创建一些基于其ID作为键的元素映射,并将它们的“单击状态”保存为值,而不是将状态保存到一个属性中。 - tytyryty

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);
  };
}

2

这是我正在使用的解决方案(可能有点晚了,但希望能对其他遇到这个问题的人有所帮助)

 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;
                });
            }
        });
    }

1

这是我使用的一个解决方案,它只需要点击事件(在ngClick指令中作为$event可用)。我想要一个菜单,其中的项目被点击后将会:

  • 切换子菜单的显示
  • 如果其他子菜单正在显示,则隐藏它们
  • 如果发生了单击,则隐藏子菜单。

此代码在菜单项上设置'class active',以便可以使用它来显示或隐藏其子菜单。

// 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;
            }
        });
    }
}

1

@lex82的答案很好,也是这个答案的基础,但我的答案有一些不同之处:

  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());
    

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接