Recently I was working on making a modal more accessibile. When I opened the modal and tried to use the keyboard to navigate, the focus would end up on various elements in the background like navigation links.
See it in action:
In order to fix this, I made two changes:
- When the modal opens, set focus on the first focusable element.
- When the user tabs away from the last focusable element, put the focus back on the first focusable element.
To achieve this, I created an element modifier called trap-focus
and placed it on the form element:
<ModalDialog @translucentOverlay=>
<form >
...
</form>
</ModalDialog>
1. Setting Focus on the First Focusable Element
To set the focus on the first focusable element in the trap-focus
modifier, I did the following:
import { modifier } from 'ember-modifier';
export default modifier(function trapFocus(element) {
const [firstFocusableElement] = findFocusableElements(element);
firstFocusableElement.focus();
});
function findFocusableElements(element) {
return element.querySelectorAll(`
a[href],
input:not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
button:not([disabled]),
[tabindex="0"],
.ember-power-select-trigger
`);
}
To make this modifier a bit more reusable, I added selectors for the different types of focusable elements that could exist in the first position.
2. Trapping Focus in the Modal
Next, I wanted to trap the focus in the form and not have background elements like navigation links receive the focus while the modal is open. The user should be able to navigate to the next focusable element by pressing tab
or to the previous focusable element by pressing shift + tab
.
Here is the implementation for that, which was mostly taken from a blog post by Ire Aderinokun called Creating An Accessible Modal Dialog:
import { modifier } from "ember-modifier";
export default modifier(function trapFocus(element) {
const [firstFocusableElement] = findFocusableElements(element);
firstFocusableElement.focus();
function handleKeyDown(event) {
const TAB_KEY = 9;
const focusableElements = findFocusableElements(element);
const [firstFocusableElement] = focusableElements;
const lastFocusableElement =
focusableElements[focusableElements.length - 1];
if (event.keyCode !== TAB_KEY) {
return;
}
function handleBackwardTab() {
if (document.activeElement === firstFocusableElement) {
event.preventDefault();
lastFocusableElement.focus();
}
}
function handleForwardTab() {
if (document.activeElement === lastFocusableElement) {
event.preventDefault();
firstFocusableElement.focus();
}
}
if (event.shiftKey) {
handleBackwardTab();
} else {
handleForwardTab();
}
}
element.addEventListener("keydown", handleKeyDown);
return () => {
element.removeEventListener("keydown", handleKeyDown);
};
});
function findFocusableElements(element) {
return element.querySelectorAll(`
a[href],
input:not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
button:not([disabled]),
[tabindex="0"],
.ember-power-select-trigger
`);
}
Here it is in action:
I am still learning how to create accessible JavaScript applications, so if there is anything in this post that is incorrect or a bad practice, please let me know by reaching out to me on Twitter @iamdtang and I will update this post.