Search code examples
javascriptjquerysettimeoutonbluronfocus

Why does $("a:focus").length evaluate to 0 unless wrapped in setTimeout()?


I'm adding keyboard navigation to the main menu on my site, and I want any open submenus to close when the user tabs outside of the main menu. So I have the following function:

var $nav = $(".navigation nav.main"),
    $navItems = $nav.find("a");

$navItems.on("blur", function() {
    if ($nav.find("a:focus").length === 0) {
        closeMenu();
    }
});

The way that I would expect this to work is like this:

  1. Whenever any link within the menu loses focus...
  2. If there are no links within the menu that have focus, close the menu

But what actually happens is $(nav.find("a:focus").length) always evaluates to 0, even when I can see directly on screen that there is indeed a link with focus within the menu.

However, if I wrap a setTimeout() around the conditional, then it works how I would expect:

var $nav = $(".navigation nav.main"),
    $navItems = $nav.find("a");

$navItems.on("blur", function() {
    setTimeout(function() {
        if ($nav.find("a:focus").length === 0) {
            closeMenu();
        }
    });
});

Now, $nav.find("a:focus").length evalulates to 1 every time I hit Tab, until I tab outside of the menu, at which point it evaluates to 0 and closes it.

This is exactly how I want it to work, but why is the setTimeout() necessary?


Solution

  • Why doesn't it work?

    As epascarello points out in the comments, your blur is happening before the next element has focus.

    Why does setTimeout(), even with a 0ms delay, work?

    I believe this has to do with the rendering engine. When you do a setTimeout(), the function gets "queued" behind the rendering engine's UI update (which focuses the next element). Despite there being no difference in time, the setTimeout() ensures the check happens after the UI update.

    Order of events:

    1. Fire blur event
    2. Update UI
    

    Order of events with a setTimeout():

    1. Fire blur event (queue new function) ──┐
    2. Update UI                              |
    3. Run the queued function    <───────────┘
    

    Is there an alternative?

    The blur event's relatedTarget indicates which element will be receiving focus.

    By knowing the element that's about to receive focus, you could use jQuery's .filter() to determine if it's a nav item or not.

    var $nav = $(".navigation nav.main"),
        $navItems = $nav.find("a");
    
    $navItems.on("blur", function(e) {
      var $focusedElement = $(e.relatedTarget);
      var isNavItem = !!$navItems.filter($focusedElement).length;
      if (!isNavItem)
        console.log("The focused element is not a nav item.");
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <div class="navigation">
      <nav class="main">
        <a href="#">Link 1</a>
        <a href="#">Link 2</a>
      </nav>
    </div>
    
    <a href="#">Non-nav link</a>