Thanks for answering the questions, hopefully the following explanation will highlight why I was asking them (and then I will offer some solutions).
Why can’t we just intercept the tab key?
Screen reader users don’t navigate using only the tab key. Depending on the screen reader they use they use different shortcuts to navigate via headings, links, forms etc.
This causes accessibility issues with popups as people only tend to capture the tab key. Then if a user uses a shortcut, e.g. 2 in NVDA to jump through heading level 2s on the page they can end up outside of the modal without knowing it exists, often without any way to get back into the modal without tabbing around for ages.
So the solution is obvious, make sure nothing else on the page is accessible (not just not focusable).
However you need to have your DOM structure well ordered / organised to make this manageable.
Problems to solve
- Screen reader users can access non-focusable elements
- They could change their shortcut keys so we can’t rely on intercepting key presses to try and fix the problem.
- We want to maintain the same visual design (i.e. we can’t just use
display:none
on all other elements). - We want a pattern we can repeat so we can’t just individually hide elements on the page.
- We want to manage focus correctly so that when the modal is closed it reverts focus back to the previous item (in your circumstances).
- We want to loop back to the first item in the modal upon reaching the last item (we can do this intercepting the tab key as we can’t cover all scenarios, neither do we want to as that would cause more accessibility issues.)
Solution
problems 1, 2, 3 and 4
As we cannot intercept key presses to manage focus within the modal we have to make every other element (other than those in the modal) completely inaccessible while the modal is active.
aria-hidden="true"
is effectively display: none
for screen readers. Support for aria-hidden
is good at around 90% to 95% for all screen reader / browser combos.
To make the content outside of the modal inaccessible we need to add aria-hidden="true"
to every element outside of the modal as well as tabindex="-1"
to ensure that nothing can be focused outside of the modal using the tab key.
I asked about your document structure as the easiest way to implement this is on regions / main landmarks.
So when the modal is active we need to add aria-hidden="true"
and tabindex="-1"
to the <head>
, <main>
, <footer>
etc. By doing it at the landmark level and by putting the modal outside of the main document flow this becomes easy to manage and maintain while preserving semantic HTML markup. The opposite is true of the modal (so hide it using the same technique when it isn’t active.)
Before modal open
<head aria-hidden="false"></head>
<main aria-hidden="false"></main>
<footer aria-hidden="false"></footer>
<div class="modal" aria-hidden="true" tabindex="-1"></div>
Modal open
<head aria-hidden="true" tabindex="-1"></head>
<main aria-hidden="true" tabindex="-1"></main>
<footer aria-hidden="true" tabindex="-1"></footer>
<div class="modal" aria-hidden="false"></div>
Note how I have aria-hidden
always added as some screen readers do not react well to dynamic addition of aria
(they react fine to changing properties though).
Points 5 and 6
For this I think it will be easiest to share the code I use to trap focus within a modal.
The purpose of the below function is to focus the first focusable item within a modal when it opens, store a reference to the element that activated the modal (as we want to return the user there when the modal closes) and to manage focus.
Please note that I use a micro library to enable jQuery style selectors so you may need to tweak things for your use.
Managing focus explanation and code
The item
variable is the referring button that was pressed before opening the modal (so we can return focus there after closing the modal).
The className
variable is the class name of the modal so you can target different modals.
kluio.helpers
is just an array of functions I use across the site so can be omitted.
kluio.globalVars
is an array of global variables so could be substituted for returning the results from the function.
I have added comments to each part to explain what it does.
The setFocus
function is called when the modal is opened passing in the element that was pressed to activate it and the modal’s className
(works for our use case better, you could use an ID instead).
var kluio = {};
kluio.helpers = {};
kluio.globalVars = {};
kluio.helpers.setFocus = function (item, className) { //we pass in the button that activated the modal and the className of the modal, your modal must have a unique className for this to work.
className = className || "content"; //defaults to class 'content' in case of error ("content" being the class on the <main> element.)
kluio.globalVars.beforeOpen = item; //we store the button that was pressed before the modal opened in a global variable so we can return focus to it on modal close.
var focusableItems = ['a[href]', 'area[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', 'button:not([disabled])', '[tabindex="0"]']; //a list of items that should be focusable.
var findItems = [];
for (i = 0, len = focusableItems.length; i < len; i++) {
findItems.push('.' + className + " " + focusableItems[i]); //add every focusable item to an array.
}
var findString = findItems.join(", ");
kluio.globalVars.canFocus = Array.prototype.slice.call($('body').find(findString)); //please note we use a custom replacement for jQuery, pretty sure .find() behaves identically but just check it yourself.
if (kluio.globalVars.canFocus.length > 0) {
setTimeout(function () { //set timeout not needed most of the time, we have a modal that is off-screen and slides in, setting focus too early results in the page jumping so we added a delay, you may be able to omit this.
kluio.globalVars.canFocus[0].focus(); //***set the focus to the first focusable element within the modal
kluio.globalVars.lastItem = kluio.globalVars.canFocus[kluio.globalVars.canFocus.length - 1]; //we also store the last focusable item within the modal so we can keep focus within the modal.
}, 600);
}
}
We then intercept the keydown
event with the following function to manage focus.
document.onkeydown = function (evt) {
evt = evt || window.event;
if (evt.keyCode == 27) {
closeAllModals(); //a function that will close any open modal with the Escape key
}
if (kluio.globalVars.modalOpen && evt.keyCode == 9) { //global variable to check any modal is open and key is the tab key
if (evt.shiftKey) { //also pressing shift key
if (document.activeElement == kluio.globalVars.canFocus[0]) { //the current element is the same as the first focusable element
evt.preventDefault();
kluio.globalVars.lastItem.focus(); //we focus the last focusable element as we are reverse tabbing through the items.
}
} else {
if (document.activeElement == kluio.globalVars.lastItem) { //when tabbing forward we look for the last tabbable element
evt.preventDefault();
kluio.globalVars.canFocus[0].focus(); //move the focus to the first tabbable element.
}
}
}
};
Finally in your version of the closeAllModals function you need to return focus to the referring element / button.
if (kluio.globalVars.beforeOpen) {
kluio.globalVars.beforeOpen.focus();
}
The line kluio.globalVars.canFocus[0].focus();
is called to set focus to the first focusable item within the modal once it is activated, you shouldn’t need to tab into the first element when it opens it should be automatically focused.
Point 5 is covered by the line kluio.globalVars.beforeOpen = item;
to set a reference to the item that opened the modal and kluio.globalVars.beforeOpen.focus();
within the close function to return focus to that item.
Point 6 is covered within the document.onkeydown
function starting at if (kluio.globalVars.modalOpen && evt.keyCode == 9) {
.
I hope all of the above is clear, any questions just ask, if I have time later I will turn it all into a fiddle.