Tuesday, July 23, 2013

Detecting and Binding to Click Events Outside an Element

Binding to events that occur on a target element is one thing. Binding to the same event when it occurs anywhere but a target element is another. There's plenty of reasons to need to know when, say, a click happens somewhere other than a target element. A concrete example is a drop-down menu. You generally want to hide it if the user clicks somewhere other than the menu. Typically, you might do something like this:


   $('#menu').on('click', function( e ) {
      // process selection
   });
   
   $(document).on('click', function( e ) {
      // hide the menu
   ));


A click in the menu will result in changing the selection and then bubble up to the document to hide the menu. If the click happens outside the menu, only the document click handler executes. Now, let's say the menu has sub menus that only open when you click on its parent. You don't want to hide the menu at that point. In this case, you might just cancel bubbling to prevent the menu from closing:


   $('#menu').on('click', function( e ) {
      // process selection

      // don't bubble to document
      // it should not close yet.
      return false;
   });
   
   $(document).on('click', function( e ) {
      // hide the menu
   ));


This simple example quickly breaks down if you have multiple things that depend on click detection outside of the element. Consider adding several boxes and tracking whether a click occurred inside the box, outside of the box, or in another target box. You could bind to the click event on the document for each box and then check if the box was the target. However, a better approach might be to bind to the click event once and then inside that handler you could inspect whether the event target matched one of the boxes (meaning a click was inside one of them) or outside the boxes of interest and take appropriate action. In this case, the boxes change their message and grow:


   var $boxes = $('#ex1 .boxes');

   $boxes.on('click', function() {

      var $el = $(this);

      $el
         .html('Yay! You did it!')
         .animate({ height: '50px', width: '100px' })
         .addClass('resetting');

      setTimeout(function() {
         $el.html('Click me again').removeClass('resetting');
      }, 2000);

   });

   $(document).on('click', function( e ) {

      $.each( $boxes, function() {

         var $el = $(this);
         
         // outside will be if the element is not the 
         // target nor contains the target
         // also, we want to ignore boxes that are 
         // resetting or another box
         if (  this !== e.target &&
               !$el.has(e.target).length &&
               !$el.is('.resetting') &&
               !$(e.target).is('.boxes') ) {

            $el
               .html('You missed!')
               .animate({ height: '+=20px', width: '+=20px' });
         }
      });

   });



Click Me
I do nothing
Click Me
Click Me
I do nothing


I added a extra timeout to reset the box after its clicked and ensured outside clicks did not affect it while it was in this state. There's no real value in this example other than playing with the idea. You can see that in the document click handler, I have to iterate over all the boxes and determine if the click's target matched the element. Once I find a match, I have my scope and can target the correct element. This seemed like something that could be abstracted and generalized, but before writing anything of my own, I looked at several solutions and found Ben Alman's jQuery outside events plugin. By far the best feature of this extension is the fact that it only binds to the document once per event type (click, dblclick, etc) which was the direction I was trying to take. As an added bonus, it supports other events and handles generalizing all the registration of events and iterating over the elements that want to be alerted when an outside event occurs. Now, I can bind handlers directly to the an element to ensure that the "this" scope is the element bound to the event and event.target will point to the element that initiated the event:

   var $boxes = $('#ex2 .boxes');

   $boxes
      .on({
         'click': function() {

            var $el = $(this);

            $el
               .html('Yay! You did it!')
               .animate({ height: '50px', width: '100px' })
               .addClass('resetting');

            setTimeout(function() {
               $el.html('Click me again').removeClass('resetting');
            }, 2000);

         },
         'clickoutside': function( e ) {
            
            // the scope is now the element that bound to the 
            // event.  e.target is the element that was actually
            // clicked.
            var $el = $(this);

            if (  !$el.is('.resetting') &&
                  !$(e.target).is('.boxes') ) {

               $el
                  .html('You missed!')
                  .animate({ height: '+=20px', width: '+=20px' });
            }
         }
   });



I do nothing
Click Me
Click Me
I do nothing
Click Me


Now inside and outside can be bound to the target element and I can clearly see which element(s) are interested in the event. The library hides the details of binding to the document to capture the bubbling event and determining whether a given element is interested in knowing that the event occurred outside of its bounds. This functionality becomes really useful in a Backbone view or a jQuery UI widget where you'd like to setup all your UI events in one place without having to bind to something outside of the part of the DOM you're managing and worrying about what else might interfere with your bindings.