Keeping drop down menu sub navigation transition consistent

I’ve created a drop down menu where each main link has a mouseenter and mouseleave event attached that first of all checks if there is a sub nav list associated with the link and then slides the menu into visibility. This works fine if you enter link 1 and leave the dropdown slides in then slides out but if you enter link 1 and then hover over link 2 the slideUp() consistency has gone, ideally i would like to make sure the active menu has closed before the new mouseenter code is run again. Can anyone suggest how I can achieve this?

Link to fiddle: http://jsfiddle.net/f3ZpN/1/

JS

var mainNav = $('.main-nav'),
    topLevelLinks = mainNav.children('li'),
    subNav = $('.sub-nav-panel'),
    subNavPanel,
    isActive = false,
    inner,
    innerLink;

topLevelLinks.on({
    mouseenter: function () {
        var el = $(this);

        if (el.children('.sub-nav').length > 0) {
            inner = el.children('.sub-nav');

            subNav.stop(true, true).slideDown(function () {
                inner.fadeIn();
            });
        }
    },
    mouseleave: function () {
        //make sure this finishes before mouseenter is run again?
        inner.fadeOut(function() {
            subNav.slideUp();
        });
    }
});

Hi there,

I’ve been puzzling over this for a couple of days now.
Basically the problem you are facing is that on mouseenter and mouseleave, you need to have some kind of idea about what your expandable bar is doing.
It can have four different states, namely: fully expanded, fully contracted, expanding out or contacting.
And depending on the state, you need to do different things. For example, if the bar is already contracting when you mouse over it (your mouse having just left a different expandable menu point), you need to catch that, stop the animation, then re-expand the bar.

Also, you could do with a short delay, before contracting the bar, to make sure you haven’t left one expandable point, only to hover over another (this would otherwise result in the bar contracting, then expanding again when you move your mouse from point one to point two).

Then there’s the matter of the second tier of animation, i.e. fading in the sub-menu points.
This starts getting tricky, as you have different animations which need to wait for each other to finish, before running themselves.

I did manage to get all of this working.

Demo 1
Demo 2

Demo 1 uses fadeIn() to animate the sub-menu points, demo 2 just uses show()
The disadvantage of demo 1, is that if the user moves their mouse very quickly over the top-level menu points, it is possible to “confuse” the bar and end up hovering over a top-level expandable menu point, without seeing the assosciated sub navigation.
I don’t know how many users would do this in the real world, but it bugged me.
In demo 2 I was not able to reproduce this.

To be complete, here’s the code.
It has lots of duplication and I hate it. I thought I’d leave a possible refactoring to you.

<!DOCTYPE HTML>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Expandable menu bar</title>
    <script src="http://code.jquery.com/jquery-latest.min.js" type="text/javascript"></script>
    <style>
      * {
        @include box-sizing(border-box);
      }
      body {
        background: silver;
        color: white;
        font-family: Arial;
        padding: 20px 0;
      }
      a {
        text-decoration: none;
        color: white;
        height: 100%;
        display: block;
      }
      .header {
        cursor: pointer;
      }
      .nav-ctn {
        background: black;
        position: relative;
      }
      .main-nav {
        position: relative;
        width: 960px;
        margin: auto;
      }
      .main-nav li {
        display: inline-block;
        border-right: 1px solid silver;
        height: 50px;
        line-height: 50px;
        margin-right: -4px;
        //position: relative;
      }
      .main-nav li a {
        display: block;
        padding: 0 30px;
      }
      .main-nav li ul {
        position: absolute;
        top: 100%;
        left: 0;
        width: 960px;
        z-index: 10;
      }
      .inner > li {
        //border-bottom: 1px solid silver;
        //text-indent: 30px;
      }
      .level2.sub-nav {
        //background: darkGrey;
        display: none;
      }
      .sub-nav-panel {
        height: 50px;
        background: #454545;
        position: absolute;
        top: 100%;
        left: 0;
        width: 100%;
        z-index: 9;
        display: none;
      }
    </style>
  </head>
  
  <body>
    <div class="nav-ctn">
      <ul class="main-nav">
        <li class="expandable">
          <a href="">Link 1</a>
          <ul class="level2 sub-nav">
            <li><a href="">Link 1</a></li>
            <li><a href="">Link 2</a></li>
            <li><a href="">Link 3</a></li>
            <li><a href="">Link 4</a></li>
            <li><a href="">Link 5</a></li>
          </ul>
        </li>
        <li class="expandable">
          <a href="">Link 2</a>
          <ul class="level2 sub-nav">
            <li><a href="">This is the new link 1</a></li>
            <li><a href="">This is the new link 2</a></li>
            <li><a href="">This is the new link 3</a></li>
            <li><a href="">This is the new link 4</a></li>
          </ul>
        </li>
        <li><a href="">Link 3</a></li>
        <li><a href="">Link 4</a></li>
      </ul>
      <div class="sub-nav-panel"></div>
    </div>

    <script>
      var mainNav = $('.main-nav'),
          topLevelLinks = mainNav.children('li'),
          subNav = $('.sub-nav-panel'),
          isAnimating = false,
          isAnimatingUp = false,
          isAnimatingDown = false,
          t;
      
      $(".expandable").on("mouseenter", function(){
        inner = $(this).children('.sub-nav');
        clearTimeout(t);

        if (isAnimating == false){
          isAnimating = true;
          isAnimatingDown = true;
          subNav.slideDown(function(){
            isAnimating = false;
            isAnimatingDown = false;
            inner.show();
          });
        } else {
          if (isAnimatingUp){
            subNav.stop(true, true);
            isAnimatingUp = false;
            isAnimatingDown = true;
            subNav.slideDown(function(){
              isAnimating = false;
              isAnimatingDown = false;
              inner.show();
            });
          } 
        }
      });
      
      $(".expandable").on("mouseleave", function(){
        inner = $(this).children('.sub-nav');
        
        if (isAnimating == false){
          t = setTimeout(function(){
            isAnimating = true;
            isAnimatingUp = true;
            subNav.slideUp(function(){
              isAnimating = false;
              isAnimatingUp = false;
            });
          }, 250);
        } else {
          if (isAnimatingDown){
            subNav.stop(true, true);
            isAnimatingDown = false;
            isAnimatingUp = true;
            subNav.slideUp(function(){
              isAnimating = false;
              isAnimatingUp = false;
            });
          }
        }
        inner.hide();
      });
    </script>
  </body>
</html>

If anyone has an idea for a different approach to tackle this, I’d sure be glad to hear it.

Hi again,

The code I posted above was bugging me, so I went ahead and refactored it for you.
Here you go, no more nasty globals and considerably less duplication:

<!DOCTYPE HTML>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>Expandable menu bar</title>
    <script src="http://code.jquery.com/jquery-latest.min.js" type="text/javascript"></script>
    <style>
      * {
        @include box-sizing(border-box);
      }
      body {
        background: silver;
        color: white;
        font-family: Arial;
        padding: 20px 0;
      }
      a {
        text-decoration: none;
        color: white;
        height: 100%;
        display: block;
      }
      .header {
        cursor: pointer;
      }
      .nav-ctn {
        background: black;
        position: relative;
      }
      .main-nav {
        position: relative;
        width: 960px;
        margin: auto;
      }
      .main-nav li {
        display: inline-block;
        border-right: 1px solid silver;
        height: 50px;
        line-height: 50px;
        margin-right: -4px;
        //position: relative;
      }
      .main-nav li a {
        display: block;
        padding: 0 30px;
      }
      .main-nav li ul {
        position: absolute;
        top: 100%;
        left: 0;
        width: 960px;
        z-index: 10;
      }
      .inner > li {
        //border-bottom: 1px solid silver;
        //text-indent: 30px;
      }
      .level2.sub-nav {
        //background: darkGrey;
        display: none;
      }
      .sub-nav-panel {
        height: 50px;
        background: #454545;
        position: absolute;
        top: 100%;
        left: 0;
        width: 100%;
        z-index: 9;
        display: none;
      }
    </style>
  </head>
  
  <body>
    <div class="nav-ctn">
      <ul class="main-nav">
        <li class="expandable">
          <a href="">Link 1</a>
          <ul class="level2 sub-nav">
            <li><a href="">Link 1</a></li>
            <li><a href="">Link 2</a></li>
            <li><a href="">Link 3</a></li>
            <li><a href="">Link 4</a></li>
            <li><a href="">Link 5</a></li>
          </ul>
        </li>
        <li class="expandable">
          <a href="">Link 2</a>
          <ul class="level2 sub-nav">
            <li><a href="">This is the new link 1</a></li>
            <li><a href="">This is the new link 2</a></li>
            <li><a href="">This is the new link 3</a></li>
            <li><a href="">This is the new link 4</a></li>
          </ul>
        </li>
        <li><a href="">Link 3</a></li>
        <li><a href="">Link 4</a></li>
      </ul>
      <div class="sub-nav-panel"></div>
    </div>

    <script>
      var subNav = $('.sub-nav-panel'),
          timeouts = [];
          
      function clearTimeouts(){
        for (var i = 0; i < timeouts.length; i++) {
          clearTimeout(timeouts[i]);
        }
        timeouts = [];
      }
      
      function appendTimeout(func, t){
        timeouts.push(setTimeout(func, t));
      }
      
      var Navbar =
      {
        init: function(){
          $(".expandable").on("mouseenter mouseleave", function(e){
            Navbar.inner = $(this).children('.sub-nav');
            Navbar.inner.hide();
            Navbar.animate(e);
          });
        },
        
        animate: function(e){
          clearTimeouts();
          if(e.type == "mouseenter"){
              if (subNav.is(':animated')){
                if(Navbar.direction == "up"){
                  appendTimeout(function(){subNav.slideDown();}, 150);
                  appendTimeout(function(){Navbar.inner.fadeIn();}, 600);
                } else {
                  appendTimeout(function(){subNav.slideDown();}, 300);
                  appendTimeout(function(){Navbar.inner.fadeIn();}, 400);
                }
              } else {
                subNav.slideDown();
                appendTimeout(function(){Navbar.inner.fadeIn();}, 150);
              }
              Navbar.direction = 'down';
          } else {
            appendTimeout(function(){
              Navbar.direction = 'up';
              subNav.slideUp();
            }, 250);
          }
        }
      };
      
      Navbar.init();
    </script>
  </body>
</html>

It also uses the fade effect you wanted and regardless of what the user does with their mouse, the bar no longer gets “confused” (at least I wasn’t able to make it show anything unexpected).
Updated demo.

Still testing.
Just noticed that in the above code, all of the instances of:

Navbar.inner.fadeIn();

should be replaced with:

Navbar.inner.filter(':not(:animated)').fadeIn();

I updated my last demo.