Douglas Silva
Douglas Silva

Reputation: 345

jQuery keydown() doesn't fire when a descendant interactive element is focused

To make an application compliant to the W3C feed pattern, I must create keyboard commands that help screen reader users browse content loaded via infinite scrolling. See working example here.

On the example page, focus on one of the items in the list, then press PAGE_DOWN/PAGE_UP. See? That lets you navigate the list items, while skipping each item's contents.

If you focus on a button within one of the items and try to navigate from there, it will still navigate correctly from article to article. That's how I want my application to behave, but it doesn't.

My code is essentially the same as in the example. Multiple <article> elements inside a <section role="feed">. Using jQuery, I attach the 'keydown' event to that <section>, referred to as #product-grid.

$('#product-grid').keydown(function(event) {
  // Detect which key was pressed and execute appropriate action
});

The HTML structure:

<section id="product-grid" role="feed">
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>

  (...) many other <article> elements

</section>

Inside my articles, there are anchors. If you focus on them and press a key, the 'keydown' event will not fire. If you focus anywhere else on the article, it does.

It is my understanding that when a descendant item has focus, the parent (#product-grid in this case) is also in focus. Am I correct?

Things I've tried:


This is a fiddle in which you can reproduce the problem: https://jsfiddle.net/fgzom4kw/

To reproduce the problem:

  1. Click on the gray background (not the anchor) of any item. A red outline will indicate it is focused.
  2. Use PAGE UP and PAGE DOWN to navigate through the items.
  3. Press TAB, which will set focus on one of the anchors.
  4. With the anchor in focus, try to navigate again using the PAGE UP and PAGE DOWN keys.

Compare this behavior with the behavior of the W3C example.


Problem solved. There are two solutions:

My way: https://stackoverflow.com/a/59449938/9811172

<matrixRef> or the highway! </matrixRef>

Twisty's way: https://stackoverflow.com/a/59448891/9811172

Check the comments for any caveats with them.

Upvotes: 1

Views: 981

Answers (2)

Douglas Silva
Douglas Silva

Reputation: 345

I figured it out. And so did Twisty.

The answer is in the JavaScript code of the W3C example, right here: https://www.w3.org/TR/wai-aria-practices-1.1/examples/feed/js/feed.js

var focusedArticle =
  aria.Utils.matches(event.target, '[role="article"]') ?
    event.target :
    aria.Utils.getAncestorBySelector(event.target, '[role="article"]');

It tries to find out what element has focus at the time of the keypress.

  1. If the event.target is an <article> or (as in the example) <div role="article">, it will use that element.
  2. If the event.target is not an article, it will attempt to retrieve the "closest" article it can find.

This means that, if an interactive widget within an article has focus at the time we press one of our keyboard commands, the article that contains that widget will be used instead. The article elements are special because they contain metadata (aria-posinset, aria-setsize) used in my event handler (and by screen readers). The trick is in that getAncestorBySelector method.

This is how it works now:

$('#product-grid').keydown(function(event) {
  var $focusedItem = $(event.target);

  // if focused element is not <article>, find the closest <article>
  if ($focusedItem.is(':not(article)')) {
    $focusedItem = $focusedItem.closest('article');
  }

  var itemIndex = $focusedItem.attr('aria-posinset');
  var itemCount = $focusedItem.attr('aria-setsize');

  (...)

..and problem solved :D


Fiddle with my solution: https://jsfiddle.net/5sta3o82/

Upvotes: 1

Twisty
Twisty

Reputation: 30883

I was unable to replicate the issue as you stated it. That said, you need a more widespread selector. Basically, if the User has focus on any items that are descendants of the selector, you want to bind the keydown callback. Consider the following.

$(function() {
  $("#product-grid").children().keydown(function(e) {
    var el = $(e.target);
    var ch = e.which;
    console.log("Event:", e.type, "HTML Target:", el.prop("nodeName"), "Char:", ch);
  });
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<section id="product-grid" role="feed">
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
  <article tabindex="-1">
    <a href="#"> having focus on this element prevents the 'keydown' event from firing
      <img src="..."/>
      <p>some text</p>
    </a>
    <div>
      if you click on this non-interactive section instead, the event fires correctly
    </div>
  </article>
</section>

Using $("#product-grid").children() as the selector will grab all child elements. You can then bind the callback as needed.

Update

The root issue was the current focus when using a tabbable element. Take a look at new example: https://jsfiddle.net/Twisty/m1w2b7rv/39/

JavaScript

$(function() {
  function setFocus(i) {
    $("[aria-posinset='" + i + "']").focus();
  }

  function navFocus(c, i, a) {
    switch (c) {
      case 33: // PAGE_UP
        if (i > 1) {
          setFocus(--i);
        }
        break;
      case 34: // PAGE_DOWN
        if (i < a) {
          setFocus(++i);
        }
        break;
      case 35: // END
        if (event.ctrlKey) {
          if (i !== a) {
            setFocus(a);
          }
        }
        break;
      case 36: // HOME
        if (event.ctrlKey) {
          if (i !== 1) {
            setFocus(1);
          }
        }
        break;
    }
  }

  $("#product-grid").on("keydown", function(event) {
    var el = $(event.target);
    if (el.prop("nodeName") == "A") {
      el = el.closest("article");
      el.focus();
    }
    var itemIndex = el.attr('aria-posinset');
    var itemCount = el.attr('aria-setsize');
    navFocus(event.which, itemIndex, itemCount);
  });
});

The keydown event is bubbling up and is being triggered, yet the current article didn't have focus, so there was not a good way to get the proper index of the article. So if the focus was on the link, we had to reset the focus back to the article element first.

Upvotes: 2

Related Questions