JAWS Converts Keyboard Events into Mouse Events

A person in the distance walks across the coast of Maine, USA. The shore is covered in sharp rocks and boulders, seaweed, and further away, pine and other trees

I discovered a behavior that struck me as odd when testing a website for accessibility recently. A menu with links inside was completely inaccessible when I tried to open it using the JAWS screen reader and my keyboard. When I dug deeper, I found it made a common design pattern completely and mysteriously inaccessible for someone using JAWS. Just testing the functionality of the site with my keyboard did not catch the bug! It has to do specifically with how JAWS works with the keyboard.

The Behavior

Imagine you're building a personal website. At the top of every page you want a navigation bar with links to all the main pages. Some of the links fit into a group, so you create a disclosure component to hold those links. Basically, it's a menu of links that opens when you interact with a button in the nav bar.

You want it to have cool, unique functionality, so you make the disclosure open when the mouse hovers over it and close when the mouse moves away. All elements must be operable by the mouse and the keyboard, so you also make it open and close when the user focuses it and presses the Enter key.

Here's the HTML of just the disclosure:

the following is an HTML code sample

<div id="show-hide-container">
  <button id="show-hide" aria-controls="disclosure" aria-expanded="false">
    Documentation
  </button>
  <div id="disclosure" hidden="true">
    <ul>
      <li><a href="...">My Documentation</a></li>
      <li><a href="...">Mozilla Web Docs</a></li>
      <li><a href="...">Example Website</a></li>
    </ul>
  </div>
</div>

And Here's the Javascript that controls it. Notice which event handlers it's using - "mouseenter", "mouseleave", "keydown", and "focusout":

the following is a JavaScript code sample

let disclosureIsShown = false;
const disclosureContainer = document.getElementById("show-hide-container");
const button = document.getElementById("show-hide");
const disclosure = document.getElementById("disclosure");

const showDisclosure = () => {
  disclosure.removeAttribute("hidden");
  button.setAttribute("aria-expanded", "true");
  disclosureIsShown = true;
};

const hideDisclosure = () => {
  disclosure.setAttribute("hidden", "true");
  button.setAttribute("aria-expanded", "false");
  disclosureIsShown = false;
};

disclosureContainer.addEventListener("mouseenter", () => {
  showDisclosure();
});

disclosureContainer.addEventListener("mouseleave", () => {
  hideDisclosure();
});

disclosureContainer.addEventListener("focusout", (event) => {
  if (!disclosureContainer.contains(event.relatedTarget)) {
    hideDisclosure();
  }
});

button.addEventListener("keydown", (event) => {
  if (event.key === 'Enter' || event.keyCode === 13) {
    if (disclosureIsShown) {
      hideDisclosure();
    } else {
      showDisclosure();
    }
  }
});

You test it out with the mouse and the keyboard, and both work, so you move on. Unfortunately, JAWS users can't access it despite your best efforts!

When they focus the disclosure button JAWS announces it like every other button, but when they press the enter key nothing happens. The disclosure doesn't open and JAWS doesn't say anything at all. From their perspective, they don't know whether anything happened because nothing was announced. They don't know whether the entire page changed or nothing changed. They also don't know what was supposed to happen.

At best, they might guess that this button should have opened a menu of links, but they'll have to carefully examine the rest of the page to figure it out. At worst, they know they're missing something but they don't know what, and they decide not to use your site. Either way, you have given them no way to access a significant portion of your site's content.

The Explanation

The problem is that JAWS converts some keyboard events, like pressing the enter key, into mouse-click events! When a JAWS user focuses the button with the tab key and then presses enter, the enter keypress is converted into a mouse click event. If your button doesn't have an event handler for click events, nothing happens.

If you're trying to practice good accessibility, it's reasonable to assume that making a control operable by at least one mouse event and one keyboard event is enough. It's also reasonable to assume that only mice fire mouse events and only keyboards fire keyboard events. Neither of these assumptions are true.

Device-dependent events

Some events are "device dependent." I'm having a hard time finding a technical definition for this, but it basically means these events are typically fired only by a specific type of device. A keyboard-dependent event will usually only be fired by a keyboard. There are exceptions, of course. Some devices emulate keyboards, and it's possible to use macros to convert something like a mouse event into a keyboard event.

Arizona State University provides a table of event handlers broken down by device dependency. According to that page, "onmouseover", "onmouseout", and "onhover" depend on a mouse, while "onkeyup" and "onkeydown" depend on a keyboard.

Device-independent events

Other events are "device-independent," which means we should expect them to be fired by a keyboard, a mouse, a touch screen, or assistive technologies. According to the Arizona State University page linked above, the following events are device independent:

  • onclick
  • onfocus
  • onblur
  • onchange
  • onselect

Notice that "onclick" is not mouse-dependent despite its name. It's fired by the left mouse button, Enter key, Spacebar, and tapping on a touch screen. It's also fired by assistive technologies, like when JAWS converts an Enter keypress into a click event. According to this article from TGPI, this is the default behavior of the JAWS virtual cursor.

By default, when using a web browers, JAWS is using its Virtual Cursor, which is "a kind of pointer input." The virtual cursor acts like a pointing device allowing the user to explore a web page using their keyboard. It interprets the enter key as the user's intention to activate an interactive element, so it simulates the events that would happen if you clicked on that element with a true pointing device.

The Lessons

First, we should all take this as a lesson that we shouldn't assume anything about the devices our users are using. Many devices and software emulate events fired by other devices.

Second, even if we don't make any assumptions, we have to test our own web interfaces with a wide variety of assistive technologies. I wouldn't have caught this issue if I wasn't using JAWS specifically. Those of us who don't regularly depend on assistive technologies need to have users of those technologies test our apps. Someone who relies on a specific technology will have much better and deeper insights into it than someone who doesn't.

Finally, Web Accessibility In Mind (WebAIM) recommends using device-independent event handlers to avoid exactly the problem I found with JAWS converting keyboard events into mouse events. If you're going to use device-dependent event handlers, they say "Multiple device dependent event handlers can be used together to allow mouse, touch, and keyboard activation of JavaScript, but this requires testing across different browsers and assistive technologies to ensure that accessibility is not limited in any way."

Example with Full Code

Below is code for a minimal example that demonstrates the bug discussed in this article. If you copy it into an HTML file and open it in your browser, you'll be able to open the disclosure with the keyboard unless you have JAWS turned on with the default settings.

the following is a JavaScript code sample

<html lang="en">
<head>
  <title>Disclosure Test</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<header>
  <nav>
    <ul style="display: flex; column-gap: 2rem; list-style-type: none">
      <li><a href="/">Home</a></li>
      <li><a href="/about">About</a></li>
      <li>
        <div id="show-hide-container" style="border: 1px solid black; display: inline-block">
          <button id="show-hide" aria-controls="disclosure" aria-expanded="false">Documentation</button>
          <div id="disclosure" hidden="true" style="width: 200px; padding: 1rem; align-items: center">
            <ul>
              <li><a href="/docs">My Documentation</a></li>
              <li><a href="https://developer.mozilla.org/en-US/">Mozilla Web Docs</a></li>
              <li><a href="https://example.com">Example Website</a></li>
            </ul>
          </div>
        </div>
      </li>
    </ul>
  </nav>
</header>


<script defer>
  let disclosureIsShown = false;
  const disclosureContainer = document.getElementById("show-hide-container");
  const button = document.getElementById("show-hide");
  const disclosure = document.getElementById("disclosure");


  const showDisclosure = () => {
    disclosure.removeAttribute("hidden");
    button.setAttribute("aria-expanded", "true");
    disclosureIsShown = true;
  };


  const hideDisclosure = () => {
    disclosure.setAttribute("hidden", "true");
    button.setAttribute("aria-expanded", "false");
    disclosureIsShown = false;
  };


  disclosureContainer.addEventListener("mouseenter", () => {
    showDisclosure();
  });


  disclosureContainer.addEventListener("mouseleave", () => {
    hideDisclosure();
  });


  disclosureContainer.addEventListener("focusout", (event) => {
    if (!disclosureContainer.contains(event.relatedTarget)) {
      hideDisclosure();
    }
  });


  button.addEventListener("keydown", (event) => {
    if (event.key === "Enter" || event.keyCode === 13) {
      if (disclosureIsShown) {
        hideDisclosure();
      } else {
        showDisclosure();
      }
    }
  });
</script>
</body>
</html>

Back to the home page