I have some retarded jQuery code trying to do what should be a simple thing: make a menu with submenus keyboardable.
However my retarded code is calling the events multiple times instead of the expected once. I may have a recusive loop I don’t see or I’m being dumb with my events.
I’m still finishing up an isolated test case but so far it’s looking like it’s doing the same thing. I hope someone can point out the worst to me so I can fix it. I’m going to post a link when I get the testcase done and on my server.
This is the behaviour I want:
The main menu items should be tabbable, which is what they’ve been so far. Keyboard users have been out of luck and have had to click a menu item to get to a page with submenus.
I want that Tab and Shift Tab continue to only focus() over the main menu items (and display the submenus), and the arrow keys should allow users to move into the focusable items of the submenus. Down and left move forward; up and right go back; in endless loops. Hitting Tab or Shift Tab while in the submenu should move the user to the next or previous main-menu item, or out of the menu entirely so they can continue tabbing through the page.
The behaviour I’m getting:
Tabbing moves not to the next main menu item, but one further. So I tab (and shift-tab) to every other menu item instead of every menu item. Sometimes while in the submenu, hitting shift-tab in this case is unpredictable: sometimes it first focusses on submenu’s main menu item, and then next shift-tab goes back two, or more often the page sits as 30+ tabs run and the focus gets shoved to the last focusable item on the page (so far I’m not getting this on my testcase but I was rewriting everything so may not have the code to do this anymore).
Chrome does this behaviour consistently. Firefox is squirrelly: for me it’s not always showing the submenu by not always adding the class of ‘active’. Opera I’m fighting the default keyboarding setup so I’m not sure if it’s the same problem.
My HTML is this sort of setup:
<ul class="nav">
<li>
<a href="somewhere"><span>Main menu item</span></a>//gets a class of 'active' on hover/focus
<ul>//goes from display:none to display:block if prev sibling has class of 'active'
<li>
<h2><a href="somewhere">Submenu topic</a></h2>
<ul>
<li><a href="somewhere">items under subtopic</a></li>
...
</ul>
</li>
...
</ul>
</li>
...
</ul>
The submenus (.nav li a+ul) are display:none and if the a gets a class of ‘active’ they become display:block. I couldn’t pull these offscreen because the default browser behaviour needs to be ‘no submenus’; the clients put in tens to hundreds of links and people should never have to tab through that every page (and in-page skip links still broken on my versions of Chromium even though I know someone has fixed this in trunk).
The client currently has
jQuery 1.7.1
jQuery UI 1.8.11
hoverIntent is involved for the menu’s mouseovers.
Here’s the after-lots-of-rewriting code, which is even worse than the original code:
var menuItems = $('.nav>li>a');
//set stuff on events focus and keydown
//main items listen for focus and keydown. Subs listen only for keydown
[b]menuItems.focus(function(e)[/b] {
e.stopPropagation(); //??
var menuItem = $(this),
ul = $('+ul', menuItem),
subMenuItems = ul.find('a');
subMenuItems.on('keydown', function(e) {
var current = subMenuItems.index(this),
nested = true;
keystrokeListen(e, menuItem, menuItems, subMenuItems, current, nested);
});
menuItems.removeClass('active');
menuItem.addClass('active');
});
//honestly this one only cares about tabs
// and used to be part of the original focus function
// also had bind() and chaining
[b] menuItems.keydown(function(e) {[/b]
e.stopPropagation(); //??
//repeating myself but need the context
var menuItem = $(this),
ul = $('+ul', menuItem),
subMenuItems = ul.find('a');
[b]keystrokeListen(e, menuItem, menuItems, subMenuItems);[/b]
});
}); //end anon onload func
var dispatch = {
37: function(e, subMenuItems, current, nested) {
console.log('left arrow'); //tests tests
//tested e here
focusPrev(subMenuItems, current, nested);
},
38: function(e, subMenuItems, current, nested) {
console.log('up arrow');
focusPrev(subMenuItems, current, nested);
},
39: function(e, subMenuItems, current, nested) {
console.log('right arrow');
focusNext(subMenuItems, current, nested);
},
40: function(e, subMenuItems, current, nested) {
console.log('down arrow');
e.preventDefault();
focusNext(subMenuItems, current, nested);
},
9: function(e, menuItem, menuItems) {
if (e.shiftKey) {
console.log('shift tab key');
tabPrev(menuItem);
}
else {
console.log('tab key');
tabNext(menuItem);
}
menuItems.removeClass('active');
}
};
function keystrokeListen(e, menuItem, menuItems, subMenuItems, current, nested) {
var code = e.which;
if (code in dispatch) {
if (code != 9) {
[noparse]dispatch
[/noparse](e, subMenuItems, current, nested);
}
else {
[noparse]dispatch
[/noparse](e, menuItem, menuItems, menuItem);
}
}
}
//cycle forward and back within the submenus
//if you're not already in the submenu, go to first item
function focusNext(subMenuItems, current, nested) {
if (nested === true) {
var next = subMenuItems.eq(current+1).length ? subMenuItems.eq(current+1) : subMenuItems.eq(0);
next.focus();
}
else {
subMenuItems.eq(0).focus();
}
}
//cycle back
function focusPrev(subMenuItems, current, nested) {
var length = subMenuItems.length;
if (nested === true) {
var prev = current==0 ? subMenuItems.eq(length-1) : subMenuItems.eq(current-1);
prev.focus();
}
else {
subMenuItems.eq(length-1).focus();
}
}
//tabs
function tabNext(menuItem) {
if (menuItem.parent().next().length) {
menuItem.parent().next().children('a').[b]focus();[/b]
}
else {
// no way to give control back to the browser? move to next focusable thingie on page
var tabbables = $(':tabbable'),
current = tabbables.index(menuItem);
tabbables.eq(current+1).[b]focus();[/b]
}
}
function tabPrev(menuItem) {
if (menuItem.parent().prev().length) {
menuItem.parent().prev().children('a').[b]focus();[/b]
}
else {
//control back to brower; move to prev focusable thingie
var tabbables = $(':tabbable'),
current = tabbables.index(menuItem);
tabbables.eq(current-1).[b]focus();[/b]
}
}
Basically I thought I was putting two event listeners on the main menu items: listen for focus and Do Stuff, and listen for keydowns and Do Stuff. But with the tab key being hit, Do Stuff ends up focussing on a main menu item. Sounds like a loop but I can’t see who’s triggering the second keydown event, or why only one more time. Debugging jQuery is a pain since you step hundreds of times through a minified line of code and when I’m in the debugger it actually never stops, but keeps making new events being triggered. While tabbing through the site, the event gets triggered only twice.
Often in other settings while debugging, I had it so that tab moved the focus to the next menu item but then continued on to focus onto the first link of the submenu (the a within the h2). Other times it would go there and then continue on to the next main menu item. I think this is what’s happening now at high speed when tabbing skips every other menu item, but console-logging event.target never shows another anchor.
I want to know what the best way is to get behaviour I want, how to avoid loops and get rid of some crap in the code. My jQuery knowledge is pretty much typing the vanilla JS function or event I think I want into a search engine and reading the jQuery version of it. I’ve looked at namespaces with bind() and selective calling with trigger but I’m not sure those would solve the issue here. I started throwing stopPropagation everywhere just to see where it might help, since I wonder if the event is hitting two things as it bubbles or something, but I can’t see it.
Ha, my [noparse]dispatch
[/noparse] lines messed with the code rendering....