For information on how to accessibly implement the components I’m working on, I often refer to WAI-ARIA Authoring Practices specification. One thing this spec sometimes recommends, is to trap focus in an element, for example in a modal dialog while it is open. In this post I will show how to implement this.
Some history
In the early days of the web, web pages used to be very simple: there was lots of text, and there were links to other pages. Easy to navigate with a mouse, easy to navigate with a keyboard.
The modern web is much more interactive. We now have ‘rich internet applications’. Elements appear and disappear on click, scroll or even form validation. Overlays, carousels, AJAX… most of these have ‘custom’ behaviour. Therefore we cannot always rely on the browser’s built-in interactions to ensure user experience. We go beyond default browser behaviour, so the duty to fix any gaps in user experience is on us.
One common method of ‘fixing’ the user’s experience, is by carefully shifting focus around with JavaScript. The browser does this for us in common situations, for example when tabbing between links, when clicking form labels or when following anchor links. In less browser-predictable situations, we will have to do it ourselves.
When to trap focus
Trapping focus is a behaviour we usually want when there is modality in a page. Components that could have been pages of their own, such as overlays and dialog boxes. When such components are active, the rest of the page is usually blurred and the user is only allowed to interact with our component.
Not all users can see the visual website, so we will also need to make it work non-visually. The idea is that if for part of the site we prevent clicks, we should also prevent focus.
Some examples of when to trap focus:
- user opens a modal in which they can pick a seat on their flight, with a semitransparent layer underneath it
- user tries to submit a form that could not be validated, and is shown an error message; they can only choose ‘OK’ and cannot interact with the rest of the page
- user opens a huge navigation menu, the background behind the navigation is blurred (“Categorie kiezen” at Hema.nl)
In these cases we would like to trap focus in the modal, alert or navigation menu, until they are closed (at which point we want to undo the trapping and return focus to the element that instantiated the modal).
Requirements
We need these two things to be the case during our trap:
- When a user presses
TAB
, the next focusable element receives focus. If this element is outside our component, focus should be set to the first focusable element in the component. - When a user presses
SHIFT TAB
, the previous focusable element receives focus. If this element is outside our component, focus should be set to the last focusable element in the component.
Implementation
In order to implement the above behaviour on a given element, we need to get a list of the focusable elements within it, and save the first and last one in variables.
In the following, I assume the element we trap focus in is stored in a variable called element
.
Get focusable elements
In JavaScript we can figure out if elements are focusable, for example by checking if they either are interactive elements or have tabindex
.
This gives a list of common elements that are focusable:
var focusableEls = element.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])');
This is an example list of common elements; there are many more focusable elements. Note that it is useful to exclude disabled elements here.
Save first and last focusable element
This is a way to get the first and last focusable elements within an element
:
var firstFocusableEl = focusableEls[0];
var lastFocusableEl = focusableEls[focusableEls.length - 1];
We can later compare these to document.activeElement
, which contains the element in our page that currently has focus.
Listen to keydown
Next, we can listen to keydown
events happening within the element
, check whether they were TAB
or SHIFT TAB
and then apply logic if the first or last focusable element had focus.
var KEYCODE_TAB = 9;
element.addEventListener('keydown', function(e) {
if (e.key === 'Tab' || e.keyCode === KEYCODE_TAB) {
if ( e.shiftKey ) /* shift + tab */ {
if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
e.preventDefault();
}
} else /* tab */ {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
e.preventDefault();
}
}
}
});
Alternatively, you can add the event listener to the first and last items. I like the above approach, as with one listener, there is only one to undo later.
Putting it all together
With some minor changes, this is my final trapFocus()
function:
function trapFocus(element) {
var focusableEls = element.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])');
var firstFocusableEl = focusableEls[0];
var lastFocusableEl = focusableEls[focusableEls.length - 1];
var KEYCODE_TAB = 9;
element.addEventListener('keydown', function(e) {
var isTabPressed = (e.key === 'Tab' || e.keyCode === KEYCODE_TAB);
if (!isTabPressed) {
return;
}
if ( e.shiftKey ) /* shift + tab */ {
if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
e.preventDefault();
}
} else /* tab */ {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
e.preventDefault();
}
}
});
}
In this function, we have moved the check for tab to its own variable (thanks Job), so that we can stop function execution right there.
Further reading
See also Trapping focus inside the dialog by allyjs, Dialog (non-modal) in WAI-ARIA Authoring Practices and Can a modal dialog be made to work properly for screen-reader users on the web? by Everett Zufelt.
Comments, likes & shares (30)
Florian Geierstanger, Sasha Chudesnov, Tyler Sticka and Manuel Matuzović liked this