Adapting drop-down submenus for touch screens

The thought for this approach came to me from this article.

Horizontal navigation menus with drop-down submenus are a common design pattern.
Usually they are implemented in CSS with the :hover pseudo-class, but this is problematic for touch screens (See: trentwalton.com/2010/07/05/non-hover/).

It is difficult to reliably detect touch devices at the moment. As good a way as any, though still fallible, is Modernizr’s touch test.

What we will do is implement a click-based drop-down method for devices that pass the Modernizr touch test, leaving the :hover drop-downs for those that don’t.

Even though the test won’t always work, a false “touch” result will just require an extra click to reveal the menu. A false “no-touch” result is no worse than the common practice, and if we already (1) make sure that top-level pages contain prominent links to all of their sub-pages and (2) responsively adapt our navigation for smaller screens, we can minimize any negative impact of those :hover rules.

Enable the CSS & Javascript to hide / display the submenus

The procedure is covered in this post: Collapsing Site Menu (with Sub-menus) on Small Screens in WordPress).

The only real difference is in extending it to larger screens, so once the small-screen submenu toggle is working, most of the code is already in place.

The following css reveals the submenus when a class of .active is applied, and hides them when the class is removed:

.js .menu-header > ul,
.js .menu-footer > ul,
.js .menu-header > ul ul,
.js .menu-footer > ul ul {
  overflow: hidden;
  max-height: 0;
}

.menu-header > ul.active,
.menu-footer > ul.active,
.menu-header > ul ul.active,
.menu-footer > ul ul.active {
  max-height: 10em;
}

At desktop widths, the max-height business is removed:

@media only screen and (min-width: 48em) {
  .js .menu-header > ul,
  .js .menu-footer > ul,
  .js .menu-header > ul ul,
  .js .menu-footer > ul ul {
    max-height: none;
    overflow: visible;
  }
}

So all we really need to do is reinstate our max-width trick at desktop widths when modernizr says that touch is available. Since Modernizr adds all of its test classes to the html element, “.js.touch” (no space) applies when both classes are present.

@media only screen and (min-width: 48em) {
  .js.touch .menu-header > ul,
  .js.touch .menu-footer > ul,
  .js.touch .menu-header > ul ul,
  .js.touch .menu-footer > ul ul {
    overflow: hidden;
    max-height: 0;
  }

  .touch .menu-header > ul.active,
  .touch .menu-footer > ul.active,
  .touch .menu-header > ul ul.active,
  .touch .menu-footer > ul ul.active {
    max-height: 10em;
  }
}

Of course, you may decide that the toggle-dropdowns are the way to go for everybody, and not only touch screens! If that’s the case, all of these desktop-width css rules can be removed, and the small-screen toggle takes over.

Tweak the styles until everything looks good, and we’re all done, EXCEPT for one thing:

Once a submenu appears, it stays visible until we click its parent link again. Most menu interfaces will remove a dropdown if your next click is somewhere off the menu, but this one stubbornly stays put, junking up the screen until we click that tiny link again.

Let’s fix that.

Removing submenus when you click ANYWHERE else!

On this Stack Overflow question, I found some nice instructions for this.

Here’s the Javascript (jQuery, really) that handles the submenu toggle on small screens:

var $menuTrigger = $('.menu-link, .has-subnav > a');

$menuTrigger.click(function(e) {
  if( document.documentElement.clientWidth < 570 ) {
    e.preventDefault();
  }
  var $this = $(this);
  $this.toggleClass('active').next('ul').toggleClass('active');
});

To include larger touch screens, we will include Modernizr’s touch test in our “preventDefault” statement:

  if( document.documentElement.clientWidth < 570 || $('.touch').length > 0 ) {
    e.preventDefault();
  }

Or, if we decided to use this behavior on all devices, we just remove the size and touch test that is wrapping “e.preventDefault();” and let it apply to everyone.

First off, at desktop widths (or whatever size you determine qualifies as “desktop width”) we make a click function for the whole document, which finds everything with the .active class, and removes that class, thus hiding any visible submenus:

if( document.documentElement.clientWidth > 570 ) {
  $(document).click(function() {
    $('.active').removeClass('active');
  });
}

This works great, except it also prevents any of our submenus from being shown in the first place, since it immediately removes the .active class right after it gets added.

So we then add “e.stopPropagation();” to our other click function to stop that click before it reaches the document function we just added:

$menuTrigger.click(function(e) {
  if( document.documentElement.clientWidth < 570 || $('.touch').length > 0 ) {
    e.preventDefault();
  }
  e.stopPropagation();
  var $this = $(this);
  $this.toggleClass('active').next('ul').toggleClass('active');
});

But what if we click another submenu link? It’s also protected from the document-level click, so all of a sudden we have two visible menus!

To take care of this issue, we’ll add this wacky looking line to the original click function above:

$('.active').not(this).not($(this).next('ul')).removeClass('active');

This finds anything with the class of .active that is NOT the link we clicked, OR the submenu following it, and removes the .active class. That way, when we click a new submenu link, it closes any open submenus at the same time it’s opening the new one.

Here’s our click function now:

var $menuTrigger = $('.menu-link, .has-subnav > a');
 
$menuTrigger.click(function(e) {
  if( document.documentElement.clientWidth < 570 || $('.touch').length > 0 ) {
    e.preventDefault();
  }
  e.stopPropagation();
  var $this = $(this);
  $('.active').not(this).not($(this).next('ul')).removeClass('active');
  $this.toggleClass('active').next('ul').toggleClass('active');
});

Lastly, we’ll add one more stopPropagation command to our desktop-width section allowing us to click within any active menu without forcing it to close, leaving us with the following finished code:

var $menuTrigger = $('.menu-link, .has-subnav > a');
 
$menuTrigger.click(function(e) {
  if( document.documentElement.clientWidth < 570 || $('.touch').length > 0 ) {
    e.preventDefault();
  }
  e.stopPropagation();
  var $this = $(this);
  $('.active').not(this).not($(this).next('ul')).removeClass('active');
  $this.toggleClass('active').next('ul').toggleClass('active');
});

if( document.documentElement.clientWidth > 570 ) {
		
  $(document).click(function() {
    $('.active').removeClass('active');
  });
		
  $('ul.active').click(function(e) {
    e.stopPropagation();
  });
		
}

I hope that’s not too much of a confusing mess for anyone reading it. As in all of these posts, I mainly wrote it to remind myself how to do this next time it comes up.