Expanding menus with jQuery

May 13, 2008

I was recently asked by a client to do a menu which would expand to show pages when the visitor moved their mouse over the menu heading to show sub pages of that section. Their second requirement was that the menu would stay expanded if the visitor clicked on one of those sub pages.

The code for the menu was a simple list with nested lists inside for the sub pages. For example:

<ul id="nav">
	<li>Heading 1
		<ul>
			<li>Sub page A</li>
			<li>Sub page B</li>
			<li>Sub page C</li>
		</ul>
	</li>
	<li>Heading 2
		<ul>
			<li>Sub page D</li>
			<li>Sub page E</li>
			<li>Sub page F</li>
		</ul>
	</li>
	<li>Heading 3
		<ul>
			<li>Sub page G</li>
			<li>Sub page H</li>
			<li>Sub page I</li>
		</ul>
	</li>
</ul>

Please note that I have removed the "a" tags for ease of reading.

This presented a few problems; the first was how the collapse the menu for visitors who have JavaScript (and stay expanded for visitors without for accessibility reasons). This is simple with jQuery:

$(function(){
	$('#nav>li>ul').hide();
});

Note the use of the ">" to make sure that the function is only called on direct descendants of the id of nav.

The second problem was to make the menu expand when the visitor pointed at a heading.

$(function(){
	$('#nav>li>ul').hide();
	$('#nav>li').mouseover(function(){
		// open current menu if it's closed
		$(this).find('ul:hidden').slideDown(500);
	})
});

So far so good. Now to get the menu to collapse when the visitor moves their mouse over another heading.

$(function(){
	$('#nav>li>ul').hide();
	$('#nav>li').mouseover(function(){
		// close open sub menus
		$(this).siblings().find('ul:visible').slideUp(500);
		// open current menu if it's closed
		$(this).find('ul:hidden').slideDown(500);
	})
});

This works but can make you feel a bit sea-sick so we can improve on it by waiting for the submenus to close before opening the one we want by using the callback functionality of the slideUp method.

$(function(){
	$('#nav>li>ul').hide();
	$('#nav>li').mouseover(function(){
		// create a reference to the active element (this)
		// so we don't have to keep creating a jQuery object
		$heading = $(this);
		// close open sub menus
		$heading.siblings().find('ul:visible').slideUp(500, function(){
			// open current menu if it's closed
			$heading.find('ul:hidden').slideDown(500);
		});
	})
});

The eagle eyed will spot that this won't work. Why? Because if none of the submenus are expanded then then callback will never be fired as no menus will slide up. To get round this we need to do this:

$(function(){
	$('#nav>li>ul').hide();
	$('#nav>li').mouseover(function(){
		// create a reference to the active element (this)
		// so we don't have to keep creating a jQuery object
		$heading = $(this);
		// check to see if any sub menus are open
		if ($heading.siblings().find('ul:visible').size()!=0) {
			// close open sub menus
			$heading.siblings().find('ul:visible').slideUp(500, function(){
				// open current menu if it's closed
				$heading.find('ul:hidden').slideDown(500);
			});
		}
		else {
			// open current menu if it's closed
			$heading.find('ul:hidden').slideDown(500);
		}
	})
});

This all works nicely except when you open the sub menu of Heading 1, then try to open the Heading 2 submenu. The problem is that when the menu contracts, it moves the headings under the mouse firing the mouseover event. So if you try and point at Heading 2, the chances are that you'll end up with Heading 3 expanded.

To stop this happening, jQuery has a handy filter called "animated" which checks to see if an element is currently being animated. We can use this to disable the mouseover event. Our final code looks like this.

The JavaScript file (to include in your HTML head tag)

$(function(){
	$('#nav>li>ul').hide();
	$('#nav>li').mouseover(function(){
		// check that the menu is not currently animated
		if ($('#nav ul:animated').size() == 0) {
			// create a reference to the active element (this)
			// so we don't have to keep creating a jQuery object
			$heading = $(this);
			// create a reference to visible sibling elements
			// so we don't have to keep creating a jQuery object
			$expandedSiblings = $heading.siblings().find('ul:visible');
			if ($expandedSiblings.size() > 0) {
				$expandedSiblings.slideUp(500, function(){
					$heading.find('ul').slideDown(500);
				});
			}
			else {
				$heading.find('ul').slideDown(1000);
			}
		}
	});
});

The HTML menu

<ul id="nav">
	<li>Heading 1
		<ul>
			<li>Sub page A</li>
			<li>Sub page B</li>
			<li>Sub page C</li>
		</ul>
	</li>
	<li>Heading 2
		<ul>
			<li>Sub page D</li>
			<li>Sub page E</li>
			<li>Sub page F</li>
		</ul>
	</li>
	<li>Heading 3
		<ul>
			<li>Sub page G</li>
			<li>Sub page H</li>
			<li>Sub page I</li>
		</ul>
	</li>
</ul>

5 comments

  1. When I use this menu example on a stand alone page the menu is displayed unexpanded when the page loads.

    When I include it in a source page in a cflayoutarea the menu opens expanded.

    Can you think of what I am doing wrong? I am using the same code except that I am placing the scripts in a cfsavecontent block and then returning that value in a cfhtmlhead.

    Comment by Michael Brennan-white – May 13, 2008
  2. Coldfusion 8 ships with the Yahoo! User Interface Library (YUI) and the EXT2 library so if you are using any of the AJAX tags or CFGRID then it may cause a conflict. I'd try running jQuery in the "jQuery.noConflict();" mode. Let me know if that works!

    Comment by John Whish – May 13, 2008
  3. John,

    That worked in Firefox very well but the slideup method in IE7 is very jerky even when I run the code on a page outside the RIA.

    I personally like the idea of punishing IE users but this is not a way to win over Hearts and minds.

    Comment by Michael Brennan-white – May 13, 2008
  4. I've just tried it in IE7 and it's quite smooth for me although I do get a slight flickering effect, just before the menu slides down. You may want to try using the hide and show effects (which had the same problem for me):

    if ($expandedSiblings.size() > 0) {
      $expandedSiblings.hide(500, function(){
        $heading.find('ul').show(500);
      });
    }
    else {
      $heading.find('ul').show(1000);
    }

    or the fade method which works well

    if ($expandedSiblings.size() > 0) {
      $expandedSiblings.fadeOut(500, function(){
        $heading.find('ul').fadeIn(500);
      });
    }
    else {
      $heading.find('ul').fadeIn(1000);
    }

    Comment by John Whish – May 13, 2008
  5. Hi,

    have you tried out one of the CSS menu's by CSS Play (http://cssplay.co.uk)? They're excellent, free and webstandard ;-)

    Comment by Sebastiaan – May 19, 2008

Leave a comment

If you found this post useful, interesting or just plain wrong, let me know - I like feedback :)

Please note: If you haven't commented before, then your comments will be moderated before they are displayed.