Links, buttons, roles and behaviours

We often see links which have been styled to look like buttons. There is some debate among the accessibility community as to whether it is worthwhile adding a role="button" to these elements so the semantics match the visuals.

I'm not going to get into that, but what I'm going to tackle is the approach which is taken when that role is added.

Normally when we add a role="button" to a link we add some simple js handling for the spacebar to allow users to activate the control in the same way they might activate a native button. Without this a user hitting the spacebar on a link which looks like a button would end up scrolling the page.

So we end up with something like this:

[].slice.call(document.querySelectorAll('a[role="button"]')).forEach(function (el) {
  el.addEventListener('keypress', function (e) {
    if (e.keyCode === 32) {
      e.preventDefault();
      el.click();
    }
  })
});

But that misses some of the nuances of the native control - something which is often missed when trying to replicate a native element. Native HTML buttons also have the feature where a user can cancel a spacebar trigger (just like you might cancel a mouse-click by pulling your mouse away before releasing it), by hitting the tab key whilst the spacebar is still pressed.

What we actually need to do is listen for two events.

First we need to listen for the keypress event on the spacebar as above, as this is what allows us to cancel that native scroll which would happen otherwise.

Then we need to listen for the keyup event on the spacebar. This means that if a user decides to cancel a “click” on our link-styled-as-a-button by using the tab key, we don't trigger it by mistake.

This ends up looking like this:

[].slice.call(document.querySelectorAll('a[role="button"]')).forEach(function (el) {
  el.addEventListener('keypress', function (e) {
    if (e.keyCode === 32) {
      e.preventDefault();
    }
  })
  el.addEventListener('keyup', function (e) {
    if (e.keyCode === 32) {
      e.preventDefault();
      el.click();
    }
  })
});

Results

See below for the actual tests and detailed breakdown.

So we expected the first set of tests to be equal across the board and that is what we saw. Both handlers displayed the same characteristics as the native button. This proves we have not broken anything with the new handler.

However what we were really testing was if the native tab key interrupt was carried through with the handlers.

Benefits for keyboard-only users and some screen-reader users

It's pretty clear that the single event handler doesn't match the native button behaviour for keyboard-only users, whilst the double handler does a much better job of this.

It's also intersting to see that JAWS and NVDA don't allow for this interrupt but Voiceover does.

In conclusion it seems that a double handler like this provides a better solution for providing the correct behaviour for links with a button role.

The test methods and detailed breakdown

This is to test the standard practice of adding a single event listener (for the spacebar) against a proposed new double handler which prevents the standard scroll but also allows for the tab-interrupt.

Test 1 - standard spacebar activation

Move to the control with the Tab key and then activate the control with the spacebar.

This is a bit of a control test to make sure that the double handler works in the same way as the single handler and they both match the native expected outcome.

As expected both the single and double handlers perform the same with the standard test of triggering the control.

Outcomes from test 1: trigger control with spacebar
Browser / screenreader Native button behaviour Does single handler match native? Does double handler match native?
Win Firefox increments counter yes yes
Win Chrome increments counter yes yes
Win Edge increments counter yes yes
MacOS Firefox increments counter yes yes
MacOS Chrome increments counter yes yes
MacOS Edge increments counter yes yes
MacOS Safari increments counter yes yes
NVDA Win Firefox increments counter yes yes
NVDA Win Chrome increments counter yes yes
JAWS Win Chrome increments counter yes yes
JAWS Win Edge increments counter yes yes
Voiceover MacOS Safari increments counter yes yes

Test 2 - activation interruption

The second test is to move to the link again but after pressing the spacebar, instead of releasing it, hold it down whilst pressing the Tab key.

This is the main test. We know native buttons have the option of cancelling a spacebar activation by using the tab key. This test checks how our different handlers cope with this and how different browsers and screen-readers handle it.

Outcomes from test 2: interrupt triggering of control with spacebar by using the tab key
Browser / screenreader Native button behaviour Does single handler match native? Does double handler match native?
Win Firefox stops button triggering no yes
Win Chrome stops button triggering no yes
Win Edge stops button triggering no yes
MacOS Firefox stops button triggering no yes
MacOS Chrome stops button triggering no yes
MacOS Edge stops button triggering no yes
MacOS Safari stops button triggering no yes
NVDA Win Firefox increments counter yes yes
NVDA Win Chrome increments counter yes yes
JAWS Win Chrome increments counter yes yes
JAWS Win Edge increments counter yes yes
Voiceover MacOS Safari stops button triggering no yes

The tests

Successful activations will increment the counter on the control.

The tab stop link between examples are just to separate the tests.

Control

Test 1 expected: button is activated.

Test 2 expected: tab key prevents spacebar from actioning the button.

Tab stop (ignore, just for testing)

Standard link just with added role

Test 1 expected: link is not activated, page is scrolled.

Test 2 expected: link is not activated, page scrolls and focus moves to next element.

Link with button classes (no js). 0

Tab stop (ignore, just for testing)

Single event

Test 1 expected: page is not scrolled, link is activated.

Test 2 expected: link is activated (multiple times) and focus moves to next element.

Link with keypress handler. 0

Tab stop (ignore, just for testing)

Double event

Test 1 expected: page is not scrolled, link is activated.

Test 2 expected: link is not activated, focus moves to next element.

Link with double handler. 0

Tab stop (ignore, just for testing)