Suckerfish (and derivative techniques) is pretty much the defacto standard for accessible pull-down menus. As a CSS-only technique (OK, with a little Javascript shim for Internet Explorer) it is cross-browser, uses clean HTML markup that works well with screen readers. However, it has two weaknesses that might not readily be apparent. Firstly, if you go to a site using Suckerfish menus and use the tab key to cycle focus through the menu items you will notice that the pull-downs don't appear on the screen, even though the menu items receive keyboard focus while they are displayed off-screen. Secondly, if you try to use Suckerfish menus on an iPad or iPhone you will most often find that tapping on the root item for a menu will trigger the link, rather than open the menu. In this article I will concentrate on the keyboard accessibility issue. In part two I will show how we extend this solution to fix the touch interface issues.
Let's start with a basic suckerfish shim implementated as a MooTools class:
var FakoliMenu = new Class({
root: Class.Empty,
initialize: function(elt)
{
this.root = document.id(elt);
$$("#" + this.root.id + " > ul > li").each(function (elt)
{
var ul = elt.getElement('ul');
if (ul)
{
elt.addEvents(
{
'mouseover': function() { elt.addClass("sfhover"); },
'mouseout': function() { elt.removeClass("sfhover");}
});
}
});
}
});
This implementation is pretty much just a MooTools recast of the standard Suckerfish code.
The fundamental building blocks for supporting keyboard traversal are the browser events focus and blur. What if we just modify our class to handle focus events and force the submenus to display? This is easy enough to implement: As it turns out, these standard browser focus events can be difficult to manage. When focus changes, first the element that is losing focus fires a blur event, then the element gaining focus fires a focus event. Furthermore the events are separate, and neither event includes a reference to the other element in the exchange. To help with this we use a simple MooTools class that we call FocusWatcher that combines the blur and focus events into a single focusChanged event:
var FocusWatcher = new Class(
{
Implements : [Options, Events],
options:
{
elementTypes : ['textarea','input','select','button','a'],
onFocusChanged: $empty
},
focus: null,
blur: null,
elements: [],
initialize: function(options)
{
var watch = this;
this.setOptions(options);
this.options.elementTypes.each(function(type) { this.elements.combine(($$(type))); }.bind(this));
this.elements.each(function(elt)
{
elt.addEvents({'focus': function(e)
{
this.focus = elt;
this.fireEvent('focusChanged');
}.bind(watch),
'blur': function(e) { this.blur = elt; }.bind(watch)});
});
}
});
window.addEvent('domready', function() { document.focusWatcher = new FocusWatcher(); });
The FocusWatcher is attached to document after the DOM has loaded, giving us a single point where we can monitor changes in focus across the page. This simple but important piece of plumbing provides us with a very useful facility - with one event handler we can tell whether the item receiving keyboard focus is inside one of our drop-down menus.
Now we have our FocusWatcher, adding the code to watch the focusChanged events is pretty straightforward:
var FakoliMenu = new Class({
root: Class.Empty,
initialize: function(elt)
{
this.root = $(elt);
var menu = this;
document.focusWatcher.addEvent('focusChanged', function()
{
this.updateFocus(document.focusWatcher.focus);
}.bind(menu));
$$("#" + this.root.id + " > ul > li").each(function (elt)
{
var ul = elt.getElement('ul');
if (ul)
{
elt.addEvents(
{
'mouseover': function() { menu.showMenu(elt); },
'mouseout': function() { menu.hideMenu(elt); }
});
}
});
},
showMenu: function(elt)
{
elt.addClass("sfhover");
},
hideMenu: function(elt)
{
elt.removeClass("sfhover");
},
updateFocus: function(elt)
{
this.clearFocus();
if (!this.root.contains(elt)) return;
this.showMenu(elt.getParent());
},
clearFocus: function()
{
this.root.getElements("ul > li").each(function(elt) { if (!elt.contains(document.focusWatcher.focus)) this.hideMenu(elt); }.bind(this));
}
});
I factored out the show and hide logic into member functions to promote consistency, and also to provide hooks where I can provide fancy transitions or additional logic. The clearFocus method simply runs through the menu elements and hides any menus that do not contain the currently focused item. The updateFocus method builds on this to ensure that the menus get shown when focus is first gained by a menu item.
And that is all there is to it for keyboard traversal. In the next article I will explain how we can expand our accessibility to provide better support for touch devices, such as the iPad.