What is optimal way to listen to multiple events who call each other?

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....

Testcase: http://stommepoes.nl/megamenu/page.html
this one reliably skips every other menu item, forwards and back, and doesn’t change if you hit Tab while inside a submenu. Which is good, it should help me isolate the skipping problem…

Tabbing already naturally advances the focus by one. So the browser’s advance by one plus the JS’s advance by one equals advance by two.

There are a couple ways you can avoid this.

  1. In tabNext and tabPrev, comment out menuItem.parent().next().children(‘a’).focus();, since the browser will already do that by default.

Or 2) in addition to calls to e.stopPropagation();, also invoke e.preventDefault();. That will block the browser’s default tabbing behavior and leave only your JS to advance the focus.

Also, thanks very much for the slimmed down, reproducible test case. It makes troubleshooting tremendously easier. :slight_smile:

Heh, there’s no way I’d expect someone to troubleshoot my first post alone, though I was also hoping it was something obviously-derpy (which it might be). My actual work page is a ginormous web shop with tons of JS and junk and templating everywhere… needed a testcase just for my own sanity.

  1. In tabNext and tabPrev, comment out menuItem.parent().next().children(‘a’).focus();, since the browser will already do that by default.

It’ll focus by default, but in the wrong place, because the next main-menu item isn’t next in natural tab order. : ) If I don’t say “focus over here” it’ll focus into the submenu, since those come next in the natural tab order.
However…

Or 2) in addition to calls to e.stopPropagation();, also invoke e.preventDefault();. That will block the browser’s default tabbing behavior and leave only your JS to advance the focus.

Duh, while I needed preventDefault() on the down arrows since they’ll shuffle the page for me (not sure if that’s default or a keyboard setting I turned on actually), it never occurred to me that even though I was trapping tab, I wasn’t grabbing the original action but creating a new (second) one. I figured I was having a Derp moment; yes I was.

I also wonder if I can leave out all the :tabbable crap for getting out of the menu; I had also assumed that since I was capturing the Tab, tabbing to the “next item” when there wasn’t any would just fail, but it would probably still then tab to next-in-tabbable-order by itself. Which I like a lot better than calling $(‘:tabbable’) regularly.

Bet this’ll fix it, which makes you a lifesaver, thanks!