There are a few things to consider when making your modal dialog accessible that go beyond just setting focus to your modal or retricting tab order within the modal. You also have to consider that screen readers can still perceive the underlying page elements if they're not hidden from the screen reader using aria-hidden="true", and then you also need to un-hide those elements when the modal is closed and the underlying page is restored.
So, to summarise, what you need to do is:
- Set focus to the first focusable element inside the modal when it appears.
- Ensure that the underlying page elements are hidden from the screen reader.
- Ensure that tab order is restricted inside the modal.
- Ensure that expected keyboard behaviour is implemented, e.g., pressing Escape will close or dismiss the modal dialog.
- Ensure that the underlying page elements are restored when the modal is closed.
- Ensure that the element that previously had focus prior to the modal dialog being opened has focus restored to it.
You also need to ensure that your modal dialog has the ARIA role="dialog" attribute so that screen readers will announce that focus has moved to a dialog, and ideally you should use the aria-labelledby and/or aria-describedby attributes to provide an accessible name and/or description to your modal.
That's quite a list, but it's what is generally recommended for accessible modal dialogs. See the WAI-ARIA Modal Dialog Example.
I've written a solution for your modal, partially based on Hidde de Vries's original code for restricting tab order inside a modal dialog.
The trapFocusInModal function makes a node list of all focusable elements and adds a key listener for Tab and Shift+Tab keys to ensure focus doesn't move beyond the focusable elements in the modal. The key listener also binds to the Escape key to close the modal.
The openModal function displays the modal dialog, hides the underlying page elements, places a class name on the element that last held focus before the modal was opened and sets focus to the first focusable element in the modal.
The closeModal function closes the modal, un-hides the underlying page, and restores focus the element that last held focus before the modal was opened.
The domIsReady function waits for the DOM to be ready and then binds the Enter key and mouse click events to the openModal and closeModal functions.
Codepen: https://codepen.io/gnchapman/pen/JjMQyoP
const KEYCODE_TAB = 9;
const KEYCODE_ESCAPE = 27;
const KEYCODE_ENTER = 13;
// Function to open modal if closed
openModal = function (el) {
// Find the modal, check that it's currently hidden
var modal = document.getElementById("modal");
if (modal.style.display === "") {
// Place class on element that triggered event
// so we know where to restore focus when the modal is closed
el.classList.add("last-focus");
// Hide the background page with ARIA
var all = document.querySelectorAll("button#click-me,input");
for (var i = 0; i < all.length; i++) {
all[i].setAttribute("aria-hidden", "true");
}
// Add the classes and attributes to make the modal visible
modal.style.display = "flex";
modal.setAttribute("aria-modal", "true");
modal.querySelector("button").focus();
}
};
// Function to close modal if open
closeModal = function () {
// Find the modal, check that it's not hidden
var modal = document.getElementById("modal");
if (modal.style.display === "flex") {
modal.style.display = "";
modal.setAttribute("aria-modal", "false")
// Restore the background page by removing ARIA
var all = document.querySelectorAll("button#click-me,input");
for (var i = 0; i < all.length; i++) {
all[i].removeAttribute("aria-hidden");
}
// Restore focus to the last element that had it
if (document.querySelector(".last-focus")) {
var target = document.querySelector(".last-focus");
target.classList.remove("last-focus");
target.focus();
}
}
};
// Function to trap focus inside the modal dialog
// Credit to Hidde de Vries for providing the original code on his website:
// https://hidde.blog/using-javascript-to-trap-focus-in-an-element/
trapFocusInModal = function (el) {
// Gather all focusable elements in a list
var query = "a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type='email']:not([disabled]), input[type='text']:not([disabled]), input[type='radio']:not([disabled]), input[type='checkbox']:not([disabled]), select:not([disabled]), [tabindex='0']"
var focusableEls = el.querySelectorAll(query);
var firstFocusableEl = focusableEls[0];
var lastFocusableEl = focusableEls[focusableEls.length - 1];
// Add the key listener to the modal container to listen for Tab, Enter and Escape
el.addEventListener('keydown', function(e) {
var isTabPressed = (e.key === "Tab" || e.keyCode === KEYCODE_TAB);
var isEscPressed = (e.key === "Escape" || e.keyCode === KEYCODE_ESCAPE);
// Define behaviour for Tab or Shift+Tab
if (isTabPressed) {
// Shift+Tab
if (e.shiftKey) {
if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
e.preventDefault();
}
}
// Tab
else {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
e.preventDefault();
}
}
}
// Define behaviour for Escape
if (isEscPressed) {
el.querySelector("button.close").click();
}
});
};
// Cross-browser 'DOM is ready' function
// https://www.competa.com/blog/cross-browser-document-ready-with-vanilla-javascript/
var domIsReady = (function(domIsReady) {
var isBrowserIeOrNot = function() {
return (!document.attachEvent || typeof document.attachEvent === "undefined" ? 'not-ie' : 'ie');
}
domIsReady = function(callback) {
if(callback && typeof callback === 'function'){
if(isBrowserIeOrNot() !== 'ie') {
document.addEventListener("DOMContentLoaded", function() {
return callback();
});
} else {
document.attachEvent("onreadystatechange", function() {
if(document.readyState === "complete") {
return callback();
}
});
}
} else {
console.error('The callback is not a function!');
}
}
return domIsReady;
})(domIsReady || {});
(function(document, window, domIsReady, undefined) {
// Check if DOM is ready
domIsReady(function() {
// Write something to the console
console.log("DOM ready...");
// Attach event listener on button elements to open modal
if (document.getElementById("click-me")) {
// Add click listener
document.getElementById("click-me").addEventListener("click", function(event) {
// If the clicked element doesn't have the right selector, bail
if (!event.target.matches('#click-me')) return;
event.preventDefault();
// Run the openModal() function
openModal(event.target);
}, false);
// Add key listener
document.getElementById("click-me").addEventListener('keydown', function(event) {
if (event.code === "Enter" || event.keyCode === KEYCODE_ENTER) {
// If the clicked element doesn't have the right selector, bail
if (!event.target.matches('#click-me')) return;
event.preventDefault();
// Run the openModal() function
openModal(event.target);
}
});
}
// Attach event listener on button elements to close modal
if (document.querySelector("button.close")) {
// Add click listener
document.querySelector("button.close").addEventListener("click", function(event) {
// If the clicked element doesn't have the right selector, bail
if (!event.target.matches('button.close')) return;
event.preventDefault();
// Run the closeModal() function
closeModal(event.target);
}, false);
// Add key listener
document.querySelector("button.close").addEventListener('keydown', function(event) {
if (event.code === "Enter" || event.keyCode === KEYCODE_ENTER) {
// If the clicked element doesn't have the right selector, bail
if (!event.target.matches('button.close')) return;
event.preventDefault();
// Run the closeModal() function
closeModal(event.target);
}
});
}
// Trap tab order within modal
if (document.getElementById("modal")) {
var modal = document.getElementById("modal");
trapFocusInModal(modal);
}
});
})(document, window, domIsReady);
<button id="click-me">Click Me</button>
<form action="">
<input placeholder="An Input" type="text"> <input placeholder="An Input" type="text"> <input placeholder="An Input" type="text"> <input placeholder="An Input" type="text">
</form>
<div class="modal" id="modal" role="dialog">
<button class="close">Close x</button> <button>More Buttons</button> <button>More Buttons</button>
</div>
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
input, button {
margin: 1rem;
padding: .5rem;
}
.click-me {
display: block;
}
.modal {
display: none;
flex-direction: column;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
background: grey;
position: absolute;
}
form {
display: flex;
}