In this article I would like to show you how to implement an accessible modal without using the "aria-modal" attribute .
A bit of theory!
"Aria-modal" is an attribute used to tell assistive technologies (such as screen readers) that the web content under the current dialog is not interoperable (inert). In other words, no element below the modal should receive focus on click, TAB / SHIFT + TAB navigation, or swipe on sensor devices.
But why can't we use "aria-modal" for the modal window?
There are several reasons:
- just not supported by screen readers
- ignored by pseudo classes ": before /: after"
Let's move on to the implementation.
Implementation
In order to start development, we need to select the properties that the available modal window should have :
- all interactive elements, outside the modal window, must be blocked for user manipulation: click, focus, and so on ...
- navigation should be available only through the system components of the browser and through the content of the modal itself (all content outside the modal window should be ignored)
Blank
Let's use a template so as not to waste time on a step-by-step description of creating a modal window
HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div>
<button type="button" id="infoBtn" class="btn"> Standart button </button>
<button type="button" id="openBtn"> Open modal window</button>
<div role="button" tabindex="0" id="infoBtn" class="btn"> Custom button </button>
</div>
<div>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Deserunt maxime tenetur sint porro tempore aperiam! Eaque tempore repudiandae culpa omnis placeat, fugit nostrum quisquam in ipsa odit accusamus illum velit?
</div>
<div id="modalWindow" class="modal">
<div>
<button type="button" id="closeBtn" class="btn-close">Close</button>
<h2>Modal window</h2>
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, doloribus.</p>
</div>
</div>
</body>
</html>
Styles:
.modal {
position: fixed;
font-family: Arial, Helvetica, sans-serif;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0,0,0,0.8);
z-index: 99999;
transition: opacity 400ms ease-in;
display: none;
pointer-events: none;
}
.active{
display: block;
pointer-events: auto;
}
.modal > div {
width: 400px;
position: relative;
margin: 10% auto;
padding: 5px 20px 13px 20px;
border-radius: 10px;
background: #fff;
}
.btn-close {
padding: 5px;
position: absolute;
right: 10px;
border: none;
background: red;
color: #fff;
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.btn {
display: inline-block;
border: 1px solid #222;
padding: 3px 10px;
background: #ddd;
box-sizing: border-box;
}
JS:
let modaWindow = document.getElementById('modalWindow');
document.getElementById('openBtn').addEventListener('click', function() {
modaWindow.classList.add('active');
});
document.getElementById('closeBtn').addEventListener('click', function() {
modaWindow.classList.remove('active');
});
If you open the page and try to navigate to the elements behind the modal using the "TAB / SHIFT + TAB" keys, then these elements receive focus, as shown in the attached picture.
To solve this problem, we need to assign all interactive elements the 'tabindex' attribute with a value of minus one.
1. For further work, create a class "modalWindow" with the following properties and methods:
- doc - page document. in which we build a modal window
- modal - the container for the modal window
- interactiveElementsList - an array of interactive elements
- blockElementsList - an array of page block elements
- constructor - the constructor of the class
- create - the method used to create the modal window
- remove - the method used to remove the modal
2. Let's implement the constructor:
constructor(doc, modal) {
this.doc = doc;
this.modal = modal;
this.interactiveElementsList = [];
this.blockElementsList = [];
}
"InteractiveElementsList" and "blockElementsList" are needed to contain the page elements that were changed when the modal was created.
3. Create a constant in which we will store a list of all elements that can have focus:
const INTERECTIVE_SELECTORS = ['a', 'button', 'input', 'textarea', '[tabindex]'];
4. In the 'create' method, select all elements matching our selectors and set all 'tabindex = -1' (ignore elements that already have this value)
let elements = this.doc.querySelectorAll(INTERECTIVE_SELECTORS.toString());
let element;
for (let i = 0; i < elements.length; i++) {
element = elements[i];
if (!this.modal.contains(element)) {
if (element.getAttribute('tabindex') !== '-1') {
element.setAttribute('tabindex', '-1');
this.interactiveElementsList.push(element);
}
}
}
A similar problem arises when we use special keys or gestures (in mobile programs) for navigation, in this case we can navigate not only through interactive elements, but also through text. To fix this, we need to add
5. We don't need to create an array to hold the selectors here, we just take all the children of the 'body' node
let children = this.doc.body.children;
6. The fourth step is similar to step 2, only using 'aria-hidden'
for (let i = 0; i < children.length; i++) {
element = children[i];
if (!this.modal.contains(element)) {
if (element.getAttribute('aria-hidden') !== 'true') {
element.setAttribute('aria-hidden', 'true');
this.blockElementsList.push(element);
}
}
}
Completed "create" method:
create() {
let elements = this.doc.querySelectorAll(INTERECTIVE_SELECTORS.toString());
let element;
for (let i = 0; i < elements.length; i++) {
element = elements[i];
if (!this.modal.contains(element)) {
if (element.getAttribute('tabindex') !== '-1') {
element.setAttribute('tabindex', '-1');
this.interactiveElementsList.push(element);
}
}
}
let children = this.doc.body.children;
for (let i = 0; i < children.length; i++) {
element = children[i];
if (!this.modal.contains(element)) {
if (element.getAttribute('aria-hidden') !== 'true') {
element.setAttribute('aria-hidden', 'true');
this.blockElementsList.push(element);
}
}
}
}
7. At the sixth step, we implement the reverse method 'create':
remove() {
let element;
while(this.interactiveElementsList.length !== 0) {
element = this.interactiveElementsList.pop();
element.setAttribute('tabindex', '0');
}
while(this.interactiveElementsList.length !== 0) {
element = this.interactiveElementsList.pop();
element.setAttribute('aria-gidden', 'false');
}
}
8. For this to work, we need to create an instance of the "modalWindow" class and call the "create" and "remove" methods:
let modaWindow = document.getElementById('modalWindow');
const modal = new modalWindow(document, modaWindow);
document.getElementById('openBtn').addEventListener('click', function() {
modaWindow.classList.add('active');
// modal.create();
});
document.getElementById('closeBtn').addEventListener('click', function() {
modaWindow.classList.remove('active');
// modal.remove();
});
Complete class code:
class modalWindow{
constructor(doc, modal) {
this.doc = doc;
this.modal = modal;
this.interactiveElementsList = [];
this.blockElementsList = [];
}
create() {
let elements = this.doc.querySelectorAll(INTERECTIVE_SELECTORS.toString());
let element;
for (let i = 0; i < elements.length; i++) {
element = elements[i];
if (!this.modal.contains(element)) {
if (element.getAttribute('tabindex') !== '-1') {
element.setAttribute('tabindex', '-1');
this.interactiveElementsList.push(element);
}
}
}
let children = this.doc.body.children;
for (let i = 0; i < children.length; i++) {
element = children[i];
if (!this.modal.contains(element)) {
if (element.getAttribute('aria-hidden') !== 'true') {
element.setAttribute('aria-hidden', 'true');
this.blockElementsList.push(element);
}
}
}
}
remove() {
let element;
while(this.interactiveElementsList.length !== 0) {
element = this.interactiveElementsList.pop();
element.setAttribute('tabindex', '0');
}
while(this.interactiveElementsList.length !== 0) {
element = this.interactiveElementsList.pop();
element.setAttribute('aria-gidden', 'false');
}
}
PS
If problems with navigation on text elements are not solved on mobile devices, then the following selection can be used:
const BLOCKS_SELECTORS = ['div', 'header', 'main', 'section', 'footer'];
let children = this.doc.querySelectorAll(BLOCKS_SELECTORS .toString());