We want the Escape key to close the modal only if the modal is opened. We can check for the Escape key with event.key and we can check whether the modal is open with classList.contains.
It is important to recognize the difference between a dialog and a modal. According to Nielsen Norman Group (paraphrased):
A dialog is a window above the main content. Users can still interact with the main content. We also call them non-modal dialogs.
A modal is also a window above the main content. But users can only interact with the content in the modal. We also call them modal dialogs.
For simplicity, I’m calling them dialog and modal respectively. In this case, we’re building a modal.
When the modal is open, we want users to interact only with things in the modal. We don’t want them to interact with anything else.
We already prevented mouse users from interacting with the main content by using a modal overlay. We also need to prevent keyboard users from interacting with the main content. We can do this by trapping focus within the modal.
This means users will not be able to Tab or Shift + Tab out of the modal:
If the user hits Tab on the last focusable element, we bring focus to the first focusable element
If the user hits Shift and Tab on the first focusable element, we bring the user to the last focusable element
We will trap focus when the modal is open
const openModal = _ => {
// ...
// Trap focus
}
To trap focus, we need to know what elements are focusable. In this case, focusable elements are <input>s and <button>s
When users hits the Tab key (without Shift), we want to check if they’re on the last focusable element. We do this with document.activeElement. If the user is on the last focusable element, we return focus to the first focusable element.
If the user hits Shift and Tab when they’re on the first focusable element, we direct focus back to the last focusable element. The steps are the same:
const openModal = _ => {
// ...
document.addEventListener('keydown', event => {
// Directs to first focusable
if (document.activeElement === lastFocusable && event.key === 'Tab' && !event.shiftKey) {
event.preventDefault()
firstFocusable.focus()
}
// Directs to last focusable
if (document.activeElement === firstFocusable && event.key === 'Tab' && event.shiftKey) {
event.preventDefault()
lastFocusable.focus()
}
})
}
Removing the focus trap
If a user closes the modal, they need to be able to focus on other elements. Right now, they can’t. If they somehow Tab into the modal (which shouldn’t happen, and we’re going to fix this later), their focus remains trapped in it.
You can see this is happening by looking at how document.activeElement cycles through the four focusable elements in the modal.
To remove the focus trap, we need to remove the keydown event listener we used for the trap.
// Remove this event listener
document.addEventListener('keydown', event => {
// Directs to first focusable
if (document.activeElement === lastFocusable && event.key === 'Tab' && !event.shiftKey) {
event.preventDefault()
firstFocusable.focus()
}
// Directs to last focusable
if (document.activeElement === firstFocusable && event.key === 'Tab' && event.shiftKey) {
event.preventDefault()
lastFocusable.focus()
}
})
To remove an event listener, we need to pass the same callback reference to both addEventListener and removeEventListener. This means we can’t use an anonymous arrow function as the callback. We need to use a named function.
// Callback for the event listeners
const trapFocus = event => {
// ...
}
// Adding an event listener
document.addEventListener('keydown', trapFocus)
// Removing an event listener
document.removeEventListener('keydown', trapFocus)
From the code above, we know trapFocus needs to know the first and last focusable elements. We need to find the focusable elements within trapFocus.