Making Mapbox Popups Accessible

Accessibility is no longer "nice to have" in web design. It must be a core part of your development. Here's how we made Mapbox accessible.

We're working on a project right now that locates farmers and farmer's markets within a certain distance of the user. We've long been fans of Mapbox, so it seemed a natural fit for this project. Unfortunately, its map markers and popups aren't keyboard or screen reader accessible. Luckily, the tool itself is highly extensible, so we were able to do a lot to remedy the issue.

Our goal is to make it so users can navigate the map markers with their keyboards, open the associated popups, navigate through those, and then close the popups and be returned to the map. Let's get started.

Background

Getting Mapbox up and running isn't hard, but it is complicated. Because our specific use case runs in Meteor, it's doubly complex. Rather than walk you through a thousand or so lines of code, we're going to show the specific accessibility improvements we made within the code. In order for this to make sense, please check out the following tutorials:

Keyboarding Surfing

The first issue we had was that a keyboard user simply tabs straight past a default mapbox setup, never focusing the markers. Luckily, this is an easy fix: all we need to do is ad the tabindex=0 property to each marker (you can learn more about tabindex at the excellent webaim.org).

That's pretty easy to do in Mapbox. Following the tutorial for adding markers, we just need to add two lines of code, setting tabindex to 0 (for tabbing about) and adding the aria role link (for screen readers):

el.setAttribute('tabindex',0);

We'd also like to make it so screen readers read out the name of the element, so let's add a title based on the data we used to generate the markers:

el.setAttribute('title',provider.name);  
el.setAttribute('role','link');  

With that in place, a user can tab into the map and focus each marker, as well as use their screen reader to navigate. Of course, that doesn't do us much good without being able to actually open the popup. We need to make the enter key open these popups in addition to clicking on them. This is...a bit more complicated.

Enter the Dragon Popup

The first challenge is the event handlers. Because we're attaching keypress to the markers and we've attached click to the map, we need a popup opening function that's available to both. To accomplish that, we created a function available to both: openPin(target).

This function exactly mimics the function laid out in the popup tutorial. Because we need to access the map object in this function from outside of setup function, we declare map at the global scope. Now instead of opening the popup in the map setup function, we use openPin and send it the target based on the click location. But what do we do about clicking enter? Well, it's pretty similar, honestly—we just add the following at the end of the function that creates our popups:

// Open popups with enter key
$('.mapboxgl-marker').keypress(function markerKeypress(event) {
  const keycode = event.which;
  const marker = $(this)
  if (keycode === 13 /* enter key */) {
    openPin(this);
  }
});

That works pretty well! However, we've just created an element on the page that's approximately 65,535 tab-keys away. What we'd really like to do is focus this popup. To accomplish this, our popup has a div in it called .info to which we'll add tabindex=0. Then we focus that element.

While we're at it, there's another small issue, which is that we really only want one popup open at a time. So we'll remove all the other ones when we open this one.

Here's what the code looks like with that update:

// Open popups with enter key
$('.mapboxgl-marker').keypress(function markerKeypress(event) {
  const keycode = event.which;
  const marker = $(this)
  if ($('.mapboxgl-popup').length !== 0) {
    $('.mapboxgl-popup').remove();
  }
  if (keycode === 13 /* enter key */) {
    openPin(this);
  }
  $('.info').focus()
});

Ok, so now we successfully open a popup with the enter key and focus it when it opens. Let's talk about the popup itself!

Popup n' Lock

Now that we've got the popup focused, we want to make sure users don't accidentally tab off of it and find themselves way far away from the map. To accomplish that, we're going to add a div at the end of the popup that looks like this:

<div class="tab_loop_end" tabindex="0"></div>  

Next we're going to add the tab_loop_start class to the .info element from earlier. We hide the tab_loop_end div using CSS so that it's only relevant to keyboard users. Here's the CSS we use to hide elements visually:

.hidden {
    position: absolute;
    left: -10000px;
    top: auto;
    width: 1px;
    height: 1px;
    overflow: hidden;
}

Now we're going to use some javascript to loop when that element gets focus. We'll insert this Javascript in the openPin() function.

$(.tab_loop_end).focus(function() {
  $(this).closest('.tab_loop_start').focus();
});

This keeps the user inside the element until they close it. As it stands, we're looping them such that they never focus the close icon. That means the user is trapped forever, which isn't going to make us any friends. Let's fix that.

Closing Time

We're going to give the user two ways to close the popup: by hitting the escape key, and by clicking a new, hidden close link. To capture escape keys, we're going to add the following to the openPin() function:

$('body').keydown(function escapeBody(event) {
  const keycode = event.which;
  if (keycode === 27 && $('.mapboxgl-popup').length !== 0) {
    // $('.mapboxgl-popup-close-button').click();
    popup.remove();
    $('body').off('keydown');
    console.log('popup removed');
  }
});

Note that we use keydown here because keypress won't catch the escape key. We can get away with $('body').off('keydown'); here because this is the only keydown event listener we put on the body, but you might need to be a little more careful depending on your site, application, framework, or what have you.

As for the hidden close link, it's pretty easy to do.

Here's the HTML we use:

<a id="hidden_close_link" href="#" title="close popup">close</a>  

And here's the CSS for the element:

#hidden_close_link {
  opacity: 0;
  position: absolute;
  right: .1rem;
  bottom: .1rem;
} 

#hidden_close_link:focus {
  opacity: 1
}

As for the javascript:

$('#hidden_close_link').focus(function() {    
  event.preventDefault();
  const keycode = event.which;
  if (keycode === 13 /* enter key */) {
    $('.mapboxgl-popup').remove();
  }
}

There! Now people can close the popup with their keyboard. But what happens when they do? Their keyboard focus ends up...somewhere. We'd like to make sure they end up back where they started.

Closer

We're lucky here that Mapbox provides close as an event on popups. What we need to do is make a function that figures out what element we should focus. In our case, we have a data-id property on the popups and the markers (derived from the id property of the element), so we use that to make the determination. Here's what we added to the popup creation in openPin() (right before .addTo()):

.on('close', function(){ 
  if ($('.mapboxgl-popup').length === 0) {
    $(`.mapboxgl-marker[data-id=${id}]`).focus();
  }
})

With that in place, users will be focused back to the pin they had open previously. It happens for mouse users as well, which we think doesn't hurt anything.

That's All, Folks

With all of that done, we've got the map in a great place: keyboard navigation, voiceover, and intuitive focusing. It was a lot of work, but definitely worth it, given how nice of a tool Mapbox is. Hopefully future versions will have accessibility baked in, but for now, this does the trick! Here's the app in action:
gif showing the how the accessibility updates work