A Few More Words on Accessible Dialogs/Modals

by Accessibility JavaScript

Hey there…

This post is 1187 days old. It was written on 01.07.2013. Please make sure to be careful with the information provided and check a more recent source on this topic.

tl;dr: It’s necessary to keep the focus within a dialog while tabbing through it. The function below is the easiest way to implement this behavior in JavaScript.


When I created CSS Modal I wanted it to be accessible for screen readers and people that only use the keyboard for navigation. Nicholas Zakas has a great post that helped me a lot with making CSS Modal accessible without too much work.

This posts explains a bit about aria roles, how to use tabindex and how you set focus to the element initially and bringing it back where it was before when the modal gets closed.

But there is one thing it misses and which was raised as an issue against CSS Modal: It should be possible to tab through the modal and keep the focus within the modal while it is active. A circular tabbing through a particular area of a page so to speak.

After some research I came up with a straightforward function that does exactly this. Let’s look at how this function is build and how you can use it.

I’ve created a Gist that you can use if you don’t want the walk-through.

Circular Focus

First of all we need to know what the first and the last element of out particular area is that might receive focus while tabbing through. We could give them an ID and find them with document.getElementById.
Let’s assume we don’t know what the content of that area is and we do not have any influence on the given IDs and stuff. This means we need to filter the first and the last tabbable element. There are some elements that are tabbable especially those which are links, user-input fields or have a tabindex attribute on them (from this answer on StackOverflow).

Here is a full list of CSS selectors:

a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex], *[contenteditable]

This is the list of selectors we need to use to find the first and last tabbable element. So let’s put them into a variable we can use in our function.

var tabbableElements = 'a[href], area[href], input:not([disabled]),' +
	'select:not([disabled]), textarea:not([disabled]),' +
	'button:not([disabled]), iframe, object, embed, *[tabindex],' +
	'*[contenteditable]';

And here is how we find the elements in a given context context:

// First, let's find all tabbable elemets
var allTabbableElements = context.querySelectorAll(tabbableElements);
 
// The first of this list
var firstTabbableElement = allTabbableElements[0];
 
// And the last one
var lastTabbableElement = allTabbableElements[allTabbableElements.length - 1];

A keydown Listener

What we need next is a listener on the last element that will fire when we hit the TAB key on it.
Note: Please remember that IE8 does not support addEventListener, so please use attachEvent where needed.

lastTabbableElement.addEventListener('keydown', function () { /* More magic */ }, false);

The handler for the event listener might look something like that:

var keyCode = event.which || event.keyCode; // Get the current keycode
 
// If it is TAB
if (keyCode === 9) {
 
	// Prevent the default behavior
	if (event.preventDefault) {
		event.preventDefault();
	} else {
		event.returnValue = false;
	}
 
	// Move the focus to the first element that can be tabbed
	firstTabbableElement.focus();
}

So if we reach the end of the element we want to circle in, we just go back to the first tabbable element and focus this. We need to prevent the default behavior – which would be to jump the the next link, out of the element – though.

A Reusable Function

Let’s throw this all together and we get a neat little function that we can call on all elements where we want the focus to stay in.

var tabbableElements = 'a[href], area[href], input:not([disabled]),' +
	'select:not([disabled]), textarea:not([disabled]),' +
	'button:not([disabled]), iframe, object, embed, *[tabindex],' +
	'*[contenteditable]';
 
var keepFocus = function (context) {
	var allTabbableElements = context.querySelectorAll(tabbableElements);
	var firstTabbableElement = allTabbableElements[0];
	var lastTabbableElement = allTabbableElements[allTabbableElements.length - 1];
 
	var keyListener = function (event) {
		var keyCode = event.which || event.keyCode; // Get the current keycode
 
		// If it is TAB
		if (keyCode === 9) {
 
			// Prevent the default behavior
			if (event.preventDefault) {
				event.preventDefault();
			} else {
				event.returnValue = false;
			}
 
			// Move the focus to the first element that can be tabbed
			firstTabbableElement.focus();
		}
	};
 
	lastTabbableElement.addEventListener('keydown', keyListener, false);
};

Here is an example call:

var modal = document.querySelector('.modal');
keepFocus(modal);

This only works for single elements and needs to be called when setting the focus of the element initially.

You can find the complete function as a Gist and use it from there if this serves you better.

Since we are the cool kids, we don’t even need jQuery to help us with any of that. The DOM’s ya friend, y’all!

Conclusion

As we can see it is not very hard to make a dialog accessible by using JavaScript. Nicholas Zakas’ blog post shows some pretty easy methods to create an accessible modal and this circular tabbing method helps to keep focus within the dialog.

If you ask yourself why you would want to do this, I recommend you watch one of Marco Zehe’s talks and read his blog posts on accessibility.

As always there are a lot of smart people who documented this before I did and had better ideas. If you know of any please tell me via Twitter what’s wrong with my snippet and let me make it better. Also I’m not really into accessibility which is why this all might be nonsense.

Update – 01. July 2013

Jörn Zaefferer pointed out that it’s not only Tab that needs to be observed but also Shift + Tab. Totally true since users might want to go backwards through the selected area of the page.

I’ve now updated the function to reflect this behavior. But let’s do a quick walk through the changes.

First I wrote a small polyfill for event.preventDefault(); so we can easily reuse the function throughout the event handler:

// Polyfill to prevent the default behavior of events
event.preventDefault = event.preventDefault || function () {
	event.returnValue = false;
};

Another thing that has changed is the fact that we will now use event delegation instead of a single event on the lastTabbableElement since we need to listen to the first element too. This was suggested by Rodney Rehm too.

One part of this is the listener that changes:

context.addEventListener('keydown', keyListener, false);

The other part is the handler function. We need to match event.target – the currently selected element – against the first and last tabbable element while we have to watch out for the shift key to be pressed. Luckily event.shiftKey tells us that. This code is inspired by the code from jQuery UI’s Dialog component.

// Move focus to first element that can be tabbed if Shift isn't used
if (event.target === lastTabbableElement && !event.shiftKey) {
	event.preventDefault();
	firstTabbableElement.focus();
 
// Move focus to last element that can be tabbed if Shift is used
} else if (event.target === firstTabbableElement && event.shiftKey) {
	event.preventDefault();
	lastTabbableElement.focus();
}

If we put this together, we get the desired behavior: Whether you use Tab or Shift + Tab in the selected part of the page the focus stays in it.
The Gist is available with the updated function.