Evolution of Drop Down Menus and Exiting Them

This has been a huge problem with drop down exit implementation strategy. Let me explain what I am (or Bootstrap is) trying to solve here. Let us say, we have a drop down menu in the navigation bar or somewhere and it is activated by a click event. Now, if we make something as simple as like this:

See the Pen Basic Drop Down - Toggle by Praveen Kumar (@praveenscience) on CodePen.

The above code works as expected. Clicking on the first time, opens it up. Clicking it subsequent times, it toggles based on the state. Until implementing this, it is fine. What if we have more drop downs like this:

See the Pen Basic Drop Down - Multiple by Praveen Kumar (@praveenscience) on CodePen.

Works fine. But, in many cases, it would look like you don't need multiple drop downs to be open at a single point of time. We should have only one! Okay, so to handle that scenario, we need to change the logic this way:

click() {  
  if (other_dropdowns_open) {
    close_others;
    open_current;
  } else {
    if (current_dropdown_open)
      close_current;
    else
      open_current;
  }
}

This may or may not work based on the use case. So implementing something like that needs better logic. So, I change the code this way:

$(function () {
  $(".dd-trigger").click(function (e) {
    e.preventDefault();
    if ($(".open").length) {
      $(".open").removeClass("open");
      $(this).closest(".dd").toggleClass("open");
    } else {
      $(this).closest(".dd").toggleClass("open");
    }
  });
});

Unfortunately the above code doesn't work as expected. We need to swap the if and else part or we need to add another condition and make it complicated like this:

$(function () {
  $(".dd-trigger").click(function (e) {
    e.preventDefault();
    if ($(".open").length && !$(this).closest(".dd").hasClass("open")) {
      $(".open").removeClass("open");
      $(this).closest(".dd").addClass("open");
    } else {
      $(this).closest(".dd").toggleClass("open");
    }
  });
});

Ha! The above code finally works. Alright! Have a look at the working code here:

See the Pen Basic Drop Down - Multiple Single by Praveen Kumar (@praveenscience) on CodePen.

Real Problem

Till now, every thing works alright. But what if, someone comes up and says like:

When I click outside, I want all the drop downs to go away.

Now comes the real serious problem. The event bubbling might screw up the whole thing if we are using drop downs and we want it to go away. The initial thought for a developer would be obviously clearing all open drop downs, if they are visible on the click event associating with the body. The big problem with this is, click events propagate!

Let's implement this first and be happy!

$("body").click(function () {
  $(".open").removeClass("open");
});

The above doesn't work at all. See? The click event propagates. We should somehow stop it.

$(".dd-trigger").click(function (e) {
  e.preventDefault();
  e.stopPropagation();
  // Rest of the code. //
});

This kind of worked alright. Considering a real use case, where the drop down opens with something that you can interact with. A search input! When a drop down has a form (well, now everything has a form every where), this method will not work out. The reason being, any interaction done on the form, e.g., our very own click event, will propagate up to the body and screw us up.

Let's add more of e.stopPropagation();.

Oh yeah, that idea would be stupid as hell. You cannot keep on adding so many event listeners. I am not adding it. So to see the havoc caused, you can find the below snippet:

See the Pen Basic Drop Down - Outside Click by Praveen Kumar (@praveenscience) on CodePen.

But not only that problem. Like you see the above snippet? The body is not high enough to handle the click function. Also, adding click events on html is also stupid. Let's stick with the right things. The above implementation doesn't work! We need totally a different way, and also, reduce the mess we have caused! See the resulting JavaScript till now:

$(function () {
  $(".dd-trigger").click(function (e) {
    e.preventDefault();
    e.stopPropagation();
    if ($(".open").length && !$(this).closest(".dd").hasClass("open")) {
      $(".open").removeClass("open");
      $(this).closest(".dd").addClass("open");
    } else {
      $(this).closest(".dd").toggleClass("open");
    }
  });
  $("body").click(function (e) {
    $(".open").removeClass("open");
  });
});

Make a note of the above. We will be comparing it with the one that we have it when we complete this article. The above doesn't work. Period. Reasons are as follows:

  1. When the body is small, the click event will not be handled.
  2. When there are elements of interaction inside the drop down, like nested menus or forms, this will fling you out of the interactive element, leaving you in blank.
  3. Any blank space in the menu, like padding or buttons that can be clicked, will throw you out of the menu (makes the menu disappear).
  4. This particular implementation might mess up with other positioned elements.

Solution

The only way to handle this is using a nice different method. We need to have an invisible mask layer and on top of that we need place our interactive element. My logic (and Bootstrap's) is as follows:

  1. When you click on the drop down menu trigger, the invisible mask element will be revealed and above that we have our interactive element.
  2. The invisible mask is independent of the menu. So, when you do any interaction on the menu, the events do not propagate to the body or the invisible element, thereby avoiding the "fling you out" state.
  3. Anything outside the menu element will be covered with the invisible mask element.
  4. Clicking on the invisible mask element will make all (only one) the open menus to disappear.

The above logic is simple to implement. For example, let's take the same snippet and add some interactive elements like a search form.

See the Pen Basic Drop Down - Final by Praveen Kumar (@praveenscience) on CodePen.

The resultant jQuery code is:

$(function () {
  $(".dd-trigger").click(function (e) {
    e.preventDefault();
    $("body").addClass("dd-open");
    $(this).closest(".dd").toggleClass("open");
  });
  $(".dd-mask").click(function () {
    $("body").removeClass("dd-open");
    $(".open").removeClass("open");
  });
});

Comparing our current code with the previously achieved one, it is less complex, fixes all the issues and also easy to implement.



comments powered by Disqus