Developing a menu with jQuery

Last updated .

Previous chapter

First chapter

This chapter is supposed to build on and showcase the lessons learned from the previous chapters. I am going to bring jQuery to life by developing a simple sample - a customizable menu system that one can plug in to a web page. You can view a live version of the completed menu here.

A good way to get started on such a project is to think of the requirements, so that these are crystal clear from the outset. That can save a lot of time and minimize frustrations in the process. So, let us first try and get some requirements down.

Requirements

I am going to build a pluggable menu that works like the standard menu in common Windows desktop applications. The most basic feature of such a menu is the top bar - which is normally always visible. One could make it collapsible, and it could be oriented vertically rathed than horizontally, but let me stick with the tradional top-level menu that rolls down submenus, at least for a start. A submenu

  1. should be hidden by default.
  2. It should become visible when a top-level menu item is selected.
  3. It should disappear when a submenu item is selected (clicked on).
  4. It should also disappear if some other element on the page, apart from the submenu or its child elements, receives focus.
  5. The submenu should be on top (in the z-index sense) of whatever content is already there.
  6. At least two levels of submenu should be available.
  7. An item in a submenu allows for the display of an image and some text.
  8. It must be possible to disable a menu item by "graying out" the menu item text.
  9. The menu should be usable for users that don't use a mouse. So, it must be possible to
  10. If a submenu has a submenu, it should be indicated by a right-pointing arrow icon.

The structure

In order to get started, let us define a test menu hierarchy with two levels of submenu:

Immediately, I convert this to html that can serve as the starting point for the menu system:

<nav id="menu"> <ul> <li> <button>Top menu 1</button> <ul> <li><button><img src="clock-24.png"></img>menu 1.1</button></li> </ul> </li> <li> <button data-key="u">Top men<span>u</span> 2</button> <ul> <li><button><img src="checked-24.png"></img>menu 2.1 loooooooooooong text</button></li> <li><button disabled="disabled"><img src="document-24.png"></img>menu 2.2</button></li> <li> <button><img src="search-24.png"></img>menu 2.3<img src="arrow3.png" class="arrowimage"></img></button> <ul> <li><button><img src="clock-24.png"></img>Submenu 2.3.1</button></li> <li><button><img src="clock-24.png"></img>Submenu 2.3.2 XYZ xyz</button></li> </ul> </li> <li><button><img src="calendar-24.png"></img>menu 2.4</button></li> </ul> </li> <li> <button data-key="t"><span>T</span>op menu 3 looooooong text</button> <ul> <li><button data-key="i"><img src="clock-24.png"></img>menu<span>i</span>tem 3.1</button></li> <li><button><img src="clock-24.png"></img>menu 3.2</button></li> </ul> </li> <li> <button>Top menu 4</button> <ul> <li><button><img src="clock-24.png"></img>menu 4.1</button></li> </ul> </li> </ul> </nav>

It seems natural to use the ul and li tags in this context, because they naturally support the hierarchical nature of a menu. But let's see if it bears all the way. The menu defined with this html fragment incorporates the various requirements listed above. I have added four menu items on the top-level, all with a submenu. One of the submenu items has its own "flyout" submenu, indicated visually with an arrow icon. Menu items are represented by button elements containing some text and an image. Some of the menu items have a key character in a "data-key" attribute. And one menu item is disabled by having a "disabled" attribute.

The next obvious step is to get some styling down to make the menu look usable and nice. Since this is not a lecture on css styling and html, I provide a working stylesheet, which I am not going to delve into. Anyway, this is what it looks like with a submenu all expanded:

menu with submenus
A menu with a submenu, which in turn has a 3rd level "flyout" menu.

Since the menu needs to support navigation with the arrow keys, it must be possible to set the keyboard focus on individual menu items. It turns out that elements such as ul cannot receive focus, but button elements can. So, I decided to represent each menu item with a button element.

The top-level menu

In accordance with common practice, the menu remains collapsed until a top-level menu item is clicked or otherwise activated. So, there is need for a handler for the click on a top-level item to make the submenu visible. I first added a call to the toggle method, but it quickly became clear that I need to know when I am hiding the submenu and when I am showing it. In other words, we need to use the show method and the hide method. The block of code below shows the handler for the mousedown event on a top-level button. I encapsulated a good deal of the code in reusable functions, which I am going to describe first. To digest the jQuery selection statements, one has to refer to the html structure, as shown above.

It turned out that a function to get the currently displayed submenu would come in handy, and here is that one-liner, which makes of the jQuery :visible selector:

// Id of NAV node in html menu structure const thisMenuId = "menu"; // Returns any displayed submenu (may be empty) // returns: jQuery set with ul element function getDisplayedSubmenu() { return jQuery("#" + thisMenuId + ">ul>li>ul:visible"); }

When the mouse pointer moves over a top-level menu item, it should be indicated visually by highlighting. The same thing should apply when the menu is navigated with the keyboard. I defined a couple of css classes for the graphics and three functions to update the display, using the jQuery parent, not, addClass and removeClass methods.

// Update top-level button UI when hovering over it function hoverTopButton(button) { jQuery(button).addClass("topButtonHover").parent().addClass("topListitemHover"); unhoverTopButtonsExcept(button); } // Revert top-level button UI when not hovering over it function unhoverTopButton(button) { jQuery(button).removeClass("topButtonHover").parent().removeClass("topListitemHover"); } // Revert top-level button UI when not hovering // param button: reference to button element function unhoverTopButtonsExcept(button) { jQuery("#" + thisMenuId + ">ul>li>button").not(button).removeClass("topButtonHover").parent().removeClass("topListitemHover"); }

Next are a few functions to hide and show a submenu. When a submenu is displayed, the keyboard focus is set on the first non-disabled menu item. And that is indicated visually with a highlight color, which is accomplished by setting a css class on the button element. Here, I use the jQuery hide, show, children and get methods.

// Hides a submenu and updates UI of top-level buttons // param $submenu: menu to hide // param topButton: top-level button of the submenu - may be null; function hideSubmenu($submenu, topButton) { $submenu.hide(); if (topButton) unhoverTopButton(topButton); } // Shows a submenu and updates UI of top-level buttons // param $submenu: menu to show // param topButton: top-level button of the submenu function showSubmenu($submenu, topButton, setFocus) { $submenu.show(); if (null != topButton) hoverTopButton(topButton); // set focus on submenu item if (setFocus) { var menuButtons = $submenu.children("li").children("button[disabled!='disabled']"); if (0 != menuButtons.length) setFocusOnButton(menuButtons.get(0)); } } // Sets focus on submenu item and updates button UI accordingly // param button: submenu button function setFocusOnButton(button) { if (null != button) { // remove class for all buttons and their parent var $buttons = jQuery(button).parent().parent().find("button"); $buttons.removeClass("subButtonHover").parent().removeClass("subButtonHover"); // add class for the button and its parent var $button = jQuery(button); $button.addClass("subButtonHover").parent().addClass("subButtonHover"); button.focus(); } }

Having the above helper functions in place, the handler for the mousedown event on the top-level menu can be created. When a top-level menu item is clicked, its submenu should be shown. Then, if the same menu item is clicked again, the submenu should go away. It should also disappear, of course, when a different top-level menu item is clicked. These requirements are taken care of by the mousedown handler, which makes use of the jQuery mousedown and next methods:

// Button elements in top-level menu items: var $topButtons = jQuery("#" + thisMenuId + ">ul>li>button" // MOUSEDOWN on top-level button $topButtons.mousedown(function (event) { event.stopPropagation(); var $visibleSubmenu = getDisplayedSubmenu(); var $thisSubmenu = jQuery(this).next(); // if current menu is this menu if ($thisSubmenu.is($visibleSubmenu)) hideSubmenu($visibleSubmenu, this); else showSubmenu($thisSubmenu, this, true); });

When a top-level menu item is active it should be indicated with a highlight background color. A menu item is active when the mouse pointer hovers over it or when one navigates to it using the keyboard. Of course, the color should be reverted to normal when the menu item is no longer the active one.

menu
The second top-level menu item is in the "hover" state.

This hovering business is handled in the mouseenter and mouseleave handlers for the button elements at top-level, again making use of the functions described above and the jQuery mouseenter and mouseleave methods:

// MOUSEENTER on top-level button $topButtons.mouseenter(function (event) { event.stopPropagation(); hoverTopButton(this); var $visibleSubmenu = getDisplayedSubmenu(); var $thisSubmenu = jQuery(this).next(); // if current menu is not the menu belonging to this button or no menu is shown at all if (!$thisSubmenu.is($visibleSubmenu)) { // Hide the submenu currently displayed hideSubmenu($visibleSubmenu, null); // Instead, if a menu WAS being displayed, // show submenu belonging to the button that the pointer is over: if ($visibleSubmenu.length != 0) showSubmenu($thisSubmenu, this, true); } }); // MOUSELEAVE on top-level button $topButtons.mouseleave(function (event) { event.stopPropagation(); var $visibleSubmenu = getDisplayedSubmenu(); var $thisSubmenu = jQuery(this).next(); // unhover button if its menu is not in drop down state if (!$thisSubmenu.is($visibleSubmenu)) unhoverTopButton(this); });

Now, the top-level menu can only be navigated using the mouse, so it's time to add support for navigation with the arrow keys. Pressing left arrow should move the active menu one step to the left, and vice versa for the right arrow. The keydown handler achieves this by triggering mouseleave and mouseenter events on the appropiate buttons. In addition, when the ESC key is pressed, the handler responds by hiding any currently displayed submenu:

const keyEnter = 13; const keyEsc = 27; const keyLeft = 37; const keyUp = 38; const keyRight = 39; const keyDown = 40; // KEYDOWN on top-level button $topButtons.keydown(function (event) { event.stopPropagation(); if (event.which == keyRight) shiftTopMenuRight(this); else if (event.which == keyLeft) shiftTopMenuLeft(this); else if (event.which == keyEsc) hideSubmenu(getDisplayedSubmenu(), this); }); // Shifts the active top-level menu one step to the left // param currentTopButton: The currently active top-level button function shiftTopMenuLeft(currentTopButton) { if (getDisplayedSubmenu().length != 0) { // get button to the left of this one var $prev = jQuery(currentTopButton).parent().prev(); if ($prev.length != 0) { var $button = $prev.children().first(); // Trigger mouseleave and mouseenter events jQuery(currentTopButton).mouseleave(); $button.mouseenter(); } } } // Shifts the active top-level menu one step to the right // param currentTopButton: The currently active top-level button function shiftTopMenuRight(currentTopButton) { if (getDisplayedSubmenu().length != 0) { // get button to the right of this one var $next = jQuery(currentTopButton).parent().next(); if ($next.length != 0) { var $button = $next.children().first(); // Trigger mouseleave and mouseenter events jQuery(currentTopButton).mouseleave(); $button.mouseenter(); } } }

One of the requirements is that the user should be able to activate a menu by pressing the alt key plus a key character. Each menu item has an associated key character, which is indicated in the UI by underlining it in the menu caption. It is implemented in the html by having a custom data-key attribute on the button element (see above). Actually, the alt key is sort of reserved by the browser for displaying its menu, so I opted for the the alt and ctrl keys as the key combination to use. Any element in the body may have the keyboard focus when the user presses the alt-ctrl-key combination, so it is necessary to handle the keypress event on the top-level element, the body element. The event handler uses the jQuery each, data and mousedown methods.

// KEYPRESS on body // Causes a submenu to be shown, when the user presses a key character // together with the alt and ctrl keys jQuery(document.body).keypress(function (event) { if (event.ctrlKey && event.altKey) { var char = String.fromCharCode(event.which); var $topButtons = jQuery("#" + thisMenuId + ">ul>li>button[data-key]"); $topButtons.each(function () { if (jQuery(this).data("key").toLowerCase() == char.toLowerCase()) { jQuery(this).mousedown(); return; } }); } });

This concludes the code for the top-level menu. So far, a submenu can be displayed, but there is no code yet to handle navigating a submenu or to respond to events.

Submenus

If a submenu is currently displayed and some other element on the page, say, a button, is activated, the submenu should go away, but of course it doesn't. It seems there is need for a blur handler somewhere on the submenu. But, in order for a blur event to be fired, an element in the submenu needs to get focus in the first place. The code listed above takes care of setting the focus on the first item in a submenu as it is being shown, but there is still no functionality to set focus on a button when the user navigates to it with the keyboard or when the mouse hovers over it. Unfortunately, when the focus switches between two menu items (button) elements, the blur event is fired before the focus event, so in responding to the blur event it is impossible to know if a submenu should be hidden or not. Because, the focus may simply be about to switch to another menu item, and it would be detrimental to hide the submenu in that situation. After a good deal of experimenting, I got the idea of postponing the handling of the blur event by a few milliseconds and then check to see which is the active element. Only if the active element is then unrelated to the menu should it be closed. This is handled in the blur event handler, using the jQuery blur, not and parents methods:

// Button elements in sub-level menu items: var $subButtons = jQuery("#" + thisMenuId + " button").not($topButtons); // BLUR on submenu button $subButtons.blur(function (event) { setTimeout(hideOnBlur, 30); }); // BLUR on top-level button $topButtons.blur(function (event) { setTimeout(hideOnBlur, 30); }); // Hides any displayed menu if the element with focus is unrelated to this menu function hideOnBlur() { var thisMenuHasFocus = jQuery(document.activeElement).parents("nav").length == 1; if (!thisMenuHasFocus) { var $visibleSubmenus = jQuery("#" + thisMenuId + " ul ul:visible"); // if a submenu is displayed, hide it and update button UI if ($visibleSubmenus.length != 0) { unhoverTopButtonsExcept(null); hideSubmenu($visibleSubmenus, null); } } }

When the mouse hovers over a submenu item, it means setting a different background color and setting focus on it. This is implemented in the below handlers for the mouseenter and mouseleave events, making use of the jQuery hover, parent, find, siblings and removeClass methods.

// MOUSEENTER and MOUSELEAVE on submenu button $subButtons.hover( function (event) { setFocusOnButton(this); // hide any 3rd level menus var $allSubMenus = jQuery(this).parent().parent().find("li ul"); hideSubmenu($allSubMenus, null); // show 3rd level child menu var $subMenu = jQuery(this).siblings("ul"); if ($subMenu.length != 0) showSubmenu($subMenu, null, false); }, function (event) { jQuery(this).removeClass("subButtonHover").parent().removeClass("subButtonHover"); } );

The requirements stipulate that the user be able to operate the menu using only the keyboard. When a submenu item receives a keystroke, several things may happen, depending on which key was pressed:

The keydown handler shown below manages all this, using jQuery keydown, parent, children, get, siblings, click, data and each methods.

// KEYDOWN on submenu button $subButtons.keydown(function (event) { event.preventDefault(); event.stopPropagation(); if (event.which == keyDown) setFocusOnButton(getNextButton(this)); else if (event.which == keyUp) setFocusOnButton(getPreviousButton(this)); else if (event.which == keyEnter) jQuery(this).click(); else if (event.which == keyLeft) { var $parentLI = jQuery(this).parent().parent().parent(); var $nav = $parentLI.parent().parent("nav"); if ($nav.length == 1) { // this is a 2nd level menu - move one step left on top menu var topmenuButton = $parentLI.children().get(0); shiftTopMenuLeft(topmenuButton); } else { // this is a 3rd level menu - hide it var $subMenu = $parentLI.children("ul"); hideSubmenu($subMenu, null); setFocusOnButton($parentLI.children().get(0)); } } else if (event.which == keyRight) { var $subMenu = jQuery(this).siblings("ul"); if ($subMenu.length == 0) { // No sublevel menu - move one step right on top menu var $parentLI = jQuery(this).parent().parent().parent(); var $nav = $parentLI.parent().parent("nav"); var topmenuButton = ($nav.length == 1) ? $parentLI.children().get(0) : $parentLI.parent().parent().children().get(0); shiftTopMenuRight(topmenuButton); hide3rdLevelMenus(); } else { // sublevel menu exists - show it showSubmenu($subMenu, null, true); } } else if (event.which == keyEsc) { var $visibleSubmenu = getDisplayedSubmenu(); // collapse current menu and update UI on top-level buttons hideSubmenu($visibleSubmenu, null); unhoverTopButtonsExcept(null); } else { // activate menu item via key char var char = String.fromCharCode(event.which).toLowerCase(); var $allButtons = getButtonsOnSameLevel(this); $allButtons.each(function () { var key = jQuery(this).data("key"); if (key && char == key.toLowerCase()) { jQuery(this).click(); return; } }); } }); // Returns all enabled buttons on the same level in the menu hierarchy as the button param // param button: a button function getButtonsOnSameLevel(button) { return jQuery(button).parent().parent().children("li").children("button[disabled!='disabled']"); } // Hides all 3rd level menus function hide3rdLevelMenus() { var $subMenus = jQuery("#" + thisMenuId + " ul ul ul"); hideSubmenu($subMenus, null); }

The last thing to fix is important: something needs to happen when the user clicks on a menu item. This is implemented in the handler for the click event, using the jQuery click method:

// CLICK for submenu button $subButtons.click(function (event) { console.log("menuitem click: ", this.outerHTML); var $visibleSubmenu = getDisplayedSubmenu(); unhoverTopButtonsExcept(null); hideSubmenu($visibleSubmenu, null); this.blur(); });

The only action taken when a menu item is clicked is to log a bit to the console. In the real world, the user would expect something more appropiate to happen.

As an aside, one might wish to add some animation to the submenus as they are hidden and shown. I have not done this in the downloadable code, but one type of animation that seems to function well is to fade in and fade out a submenu rather than simply showing or hiding it. The code changes to accomplish that are simple and involve only two lines. The showSubmenu and hideSubmenu methods would have to be changed to this:

// Hides a submenu and updates UI of top-level buttons // param $submenu: menu to hide // param topButton: top-level button of the submenu - may be null; function hideSubmenu($submenu, topButton) { $submenu.fadeOut(250); if (topButton) unhoverTopButton(topButton); } // Shows a submenu and updates UI of top-level buttons // param $submenu: menu to show // param topButton: top-level button of the submenu function showSubmenu($submenu, topButton, setFocus) { $submenu.fadeIn(250); if (null != topButton) hoverTopButton(topButton); // set focus on submenu item if (setFocus) { var menuButtons = $submenu.children("li").children("button[disabled!='disabled']"); if (0 != menuButtons.length) setFocusOnButton(menuButtons.get(0)); } }

completed menu
The completed menu.

This concludes the menu show case.

You can view a live version and download the source code HERE.

Next chapter