Two-tier navigation with some JQuery

This looks like a really old post. Stay for the retro vibes but be aware any information in this post is likely way out of date.

I came across an interesting job at work recently with a new navigation menu. The project's main navigation bar had changed from a single horizontal bar to one requiring a sub-menu. Before we get started, have a quick look at the finished menu.

No drop-downs

The first decision I made was that I didn't want to use vertical drop-downs for the sub-menus. When a tab was selected I wanted the sub-menu to be fixed in place, allowing quick movement between sections. So these sub-menus would be horizontal, sitting under the main bar. Of course being a good semantic junkie the menu would be marked up as nested lists:

<div class="simplenav fix">
    <ul class="fix">
        <li><a href="/" title="Return to the the frontpage">Home<br /><span>hello</span></a></li>
        <li><a href="">Projects <span>client work</span></a>
            <ul>
                <li><a href="">By area</a></li>
                <li><a href="">Case studies</a></li>
                <li><a href="">Can we help you?</a></li>
            </ul>
        </li>
        <li><a href="">Products <span>buy me</span></a>
            <ul>
                <li><a href="">Desktop apps</a></li>
                <li><a href="">Web apps</a></li>
                <li><a href="">Mobile apps</a></li>
            </ul>
        </li>
        <li><a href="">Support <span>faq &amp; forums</span></a>
            <ul>
                <li><a href="">FAQ</a></li>
                <li><a href="">Knowledge base</a></li>
                <li><a href="">Tutorials</a></li>
                <li><a href="">Forum</a></li>
            </ul>
        </li>
        <li><a href="">Blog <span>(almost) daily</span></a>
            <ul>
                <li><a href="">Archives</a></li>
                <li><a href="">Blog roll</a></li>
            </ul>
        </li>
        <li><a href="">About <span>our team</span></a></li>
        <li><a href="">Contact <span>get in touch</span></a></li>
        <li class="skip"><a href="/logout/" title="Log Out">Log Out <span>Bye</span></a></li>
    </ul>
</div>

The basic stuff

With a fixed sub-menu you need to have a way of showing the sub-menu for the given tab. As I was already using server-side code to assign a css class to the active tab, it was an easy addition to hide the unwanted sub-menus with a css rule:

/*show or hide the submenu*/
.on {position: absolute; bottom: 0; left: 0; width: 100%;}
.off {top: -9999999px; position: absolute; opacity: 0;}

/*highlight the active tab*/
.here>a {background: url(sub.jpg) top left repeat-x;}

and the accompanying HTML:

<li class="here"><a href="">Support <span>faq &amp; forums</span></a>
    <ul class="off">
        <li><a href="">FAQ</a></li>
        <li><a href="">Knowledge base</a></li>
        <li><a href="">Tutorials</a></li>
        <li><a href="">Forum</a></li>
    </ul>
</li>
<li><a href="">Blog <span>(almost) daily</span></a>
    <ul class="on">
        <li><a href="">Archives</a></li>
        <li><a href="">Blog roll</a></li>
    </ul>
</li>

So far so good.

Hover goodness

Ok, so this is what we want from our hover interaction :

  1. a tab to have a 'selected' state when you are on a page within that section (done with server-side class assignment)
  2. all tabs (except the 'selected' one) to have a 'hover' state (done with basic css)
  3. the 'selected' tab's submenu should be visible by default (the resting state is done as part of 1. but we need to do a bit of work to 'reset' it)
  4. as you hover over the other tabs, their submenus should replace that of the 'selected' submenu
  5. as you move into a submenu, its parent tab should retain its appropriate colour ('selected' or 'hover')

We'll accomplish items 3 - 5 with some jquery.

$(document).ready(function(){
    $(".simplenav>ul>li>a").bind("mouseenter",function(){
        hideAllNav(); //as the cursor moves onto a tab, hide all the submenus ...
        showChildNav(this); // ... before showing the submenu of the hover tab
    });

    $(".simplenav").bind("mouseleave",function(){
        //this function is required as the mouse can exit off the bottom of a submenu or a tab, otherwise it'd just 'stick' until you moused over another tab.
        hideAllNav(); //as the cursor moves out of the menu area hide all submenus ...
        showCurrentNav(); // ... before showing the currently active tab (the default setting)
    });
});

function hideAllNav(menu){
    $(".simplenav ul ul").removeClass("on fix"); // take the 'on' class off
    $(".simplenav ul ul").addClass("off"); // apply 'off' class to all submenus
}

function showChildNav(actOnMe){
    //this function ensures the associated tab for the submenu stays 'lit' when you leave the tab and move into the submenu
    $(".simplenav li").removeClass("MenuVisible"); //remove any existing highlight for the tabs
    $(actOnMe).parent("li").find("ul").removeClass("off");
    $(actOnMe).parent("li").find("ul").addClass("on fix");
    //here we just ensure we aren't assigning the 'hover' colour to the page tab
    $(actOnMe).parent("li").not($("li.here")).find("ul").bind("mouseenter",function(){
        $(this).parent("li").addClass("MenuVisible");
    }).bind("mouseleave",function(){
        $(this).parent("li").removeClass("MenuVisible"); //as the mouse leaves the submenu, put everything back
    });
}

function showCurrentNav(){
    //only do this if it is currently hidden
    if($(".simplenav li.here ul").hasClass("off")){
        $(".simplenav li.here ul").removeClass("off");
        $(".simplenav li.here ul").addClass("on fix");
    }
}

So, here's what we have so far. Now you'll see we have a bit of an issue, especially with the Support and Blog submenus. The position of the tabs to the submenu items means it can be tough to make a selection without catching one of the neighbouring tabs.

The path a user's mouse would take from a tab to the sub-menu showing how it interacts with adjacent tabs

This isn't ideal and will be frustrating for users, what we want is the submenu to be centred under the relative tab. Now for most websites this would just be a matter of setting a left margin to the left-most submenu item and tada you're done. However that assumes the submenu won't change. Also for my particular application (an extranet), the submenu and top menu both change depending upon what your permissions are on the site, so I can never fix this by hard-coding. Luckily we have already opened our jquery toolbox and it is a pretty easy fix. This is what we're after:

The sub-menu shown below the parent tab. With the sub-menu placed below the active tab the user's mouse has much less chance of unwanted interactions with other parts of the navigation

What we want to do is measure the distance from the left of the menu to the middle of the current tab (hover or selected), then measure the width of the related submenu divided by two, then take the latter value from the former and assign that as a left-margin. Phew! Here's a diagram which will explain it better:

Letter A showing the distance from the left edge of the menu. Letter B indicates the with of the submenu. The distance the submenu needs to be moved is shown as A minus half B.

JQuery has the useful outerWidth method which gives you a numeric value of the full width of an element (as opposed to the width method which doesn't include padding).

With this and a bit of traversing, you can get the measurements you need:

function initialiseNav(navitem){
    //centre of this button
    var widthone = 0;
    widthone = $(navitem).outerWidth();
    widthone = widthone/2;

    $(navitem).prevUntil('ul').each(function() {
        widthone = widthone + ($(this).outerWidth());
    });

    //width of subnav
    var widthtwo = 0;
    $(navitem).find("li").each(function() {
        widthtwo = widthtwo + ($(this).outerWidth());
    });
    widthtwo = widthtwo/2;

    //calculate margin
    var marginvalue = 0;
    marginvalue = widthone - widthtwo;

    if(marginvalue>0){
        //set left margin of first subnav item only if it isn't negative
        $(navitem).children("ul").find("li").first().css("margin-left", marginvalue);
    }
}

This gives us our finished menu.