tohuwabohu In technology, chaos reigns supreme.

Implementing Focus Traps


Everybody loves cookie banners. Personally, I think there is no better experience than reading the first few paragraphs of a page, just to be interrupted by a giant modal window that tells you whom you should sell your soul to. Modal windows in general are problematic when it comes to Web Accessibility, as the accessibility tree stays accessible for keyboard controls, allowing the user to access content in the background, if the developer does not intervene. This intervention is called Focus Trap.

Table of Contents

  1. Common Ground
  2. Cookie Banner
  3. Managing multiple dialogs

Common Ground

Usually modal windows require user interaction and should not be skippable. There are a few use cases aside from cookie banners, but ultimately you will have something that matches the following accessibility tree.

On the left side you see a <dialog> element - a <div> with aria-role=dialog - that contains a heading, some text and controls. This could be a minimalistic cookie popup with ‘accept’ and ‘decline’ or a confirmation dialog with ‘yes’ and ‘no’ buttons. Put some additional controls - <input> elements - into it and, you have a password prompt. You get the idea.

Next to it, you find the main element. Because a modal window is always displayed on top of your page, it should be the first element in your body. Sometimes you see them defined at the bottom of the markup. This is semantically incorrect, because here the DOM position does not necessarily correlate to the window’s position on the screen. A modal window has the highest priority when it comes to user interaction.

When navigating by keyboard alone, the modal window’s interactive elements should always be selected first and the selection should not spill into the <main> element. Let’s have a look on that.

The European Union General Data Protection Regulation requires owners of websites to inform visitors about any tracking that might create a digital fingerprint from them and get their consent. The full statement is cited below.

Natural persons may be associated with online identifiers provided by their devices, applications, tools and protocols, such as internet protocol addresses, cookie identifiers or other identifiers such as radio frequency identification tags. This may leave traces which, in particular when combined with unique identifiers and other information received by the servers, may be used to create profiles of the natural persons and identify them.

https://gdpr.eu/cookies/

Over the last few years this totally went out of hand. Websites competed for the biggest, most obnoxious cookie banners. Some require the visitor to manually uncheck a box for each description there is; some hide that option behind an accordion. Some use a color scheme to deceive the user into consenting by suggesting the ‘accept’ button means ‘decline’ and vice versa. On top of that, absolutely no effort went into making these monstrosities WCAG2.1 compliant.

The GDPR never intended it to be implemented this way. From an accessibility standpoint, it’s a nightmare. As a front-end dev who always wants to provide the best user experience possible, we know better.

Let’s start with a simplified banner that allows the user to choose between levels of consent. Create a new HTML file and put a simple <div> structure in the <body> like shown below.

Please refer to my code on my GitHub page or JsFiddle as template and have a look at the cookie banner defined in the markup.

<dialog id="cookie-banner" class="popup-overlay modal" aria-modal="true">
    <div class="popup">
        <div>
            <h2>Cookies 🍪</h2>
            <p>
                Very important information in regard
                to whom you will sell your soul to.
            </p>
            <p>
                Allow or Prohibit:
            </p>
            <div class="popup-controls popup-controls-checkboxes">
                <input id="technical" type="checkbox" checked>
                <label for="technical">Technical Cookies</label>
                <input id="analytical" type="checkbox" checked>
                <label for="analytical">Analytical Cookies</label>
                <input id="thirdParty" type="checkbox" checked>
                <label for="thirdParty">3rd Party Cookies</label>
            </div>
        </div>
        <div class="popup-button-container">
            <button class="accept" onclick="hideOverlay(true)">
                Accept
            </button>
            <button onclick="hideOverlay(true)">
                Decline
            </button>
        </div>
    </div>
</dialog>

This will render a rudimentary window that calls a hideOverlay function on button press. The ‘accept’ and ‘decline’ buttons are distinguishable by different colors as defined in the CSS. I won’t go into detail on all that fancy flex stuff.

Open the HTML file with the browser of your choice. You should see a page similar as below.

Try navigating by pressing the Tab key. Because the cookie banner is the first thing on the body, this window’s controls will be selected first. Pay attention to what happens after you pass the decline button.

The link in the background gets selected, and you can cycle through all available controls because the focus trap has yet to be implemented.

You can do so with some JavaScript coding. The modal CSS class indicates that a modal window is present. So, if the Tab key has been pressed, we could look into the DOM to check if this class is assigned to any element and only allow cycling through child nodes.

Start with adding an event listener that triggers when the DOM has been loaded and define a list of elements that you want to enable receiving focus. You can use the containers’ CSS classes for that like shown below.

document.addEventListener('DOMContentLoaded', () => {
    const focusableElements = Array.from(document
        .querySelectorAll('div[class~=popup-controls] > input, div[class=popup-button-container] > button'));

    // ...
});

Side note: The class~=popup-controls CSS selector returns all elements that possess the popup-control class non-exclusively, whereas class=popup-button-container will strictly check for the popup-button-container class and exclude elements that have more classes assigned.

Now that the list is defined, a Tab keydown event listener is needed. Any time the event is targeted on an element that is not part of the cookie popup, either the first or the last element in the popup should be selected, depending on whether the Shift key also has been pressed or not. Shift + Tab cycles through the elements in reverse order.

Get the first and last element from the previously defined array.

const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];

Add a trap convenience function that prevents the event’s default behaviour and focuses an element.

const trap = (e, element) => {
    e.preventDefault();
    element.focus();
}

Call trap for the respective event properties.

document.addEventListener('keydown', (e) => {
    const overlay = document.querySelector('dialog.popup-overlay.modal');

    if (overlay && e.key === 'Tab') {
        if (!focusableElements.includes(e.target)) {
            trap(e, first);
        } else if (e.target === last && !e.shiftKey) {
            trap(e, first);
        } else if (e.target === first && e.shiftKey) {
            trap(e, last);
        }
    }
});

And that’s it. You can check out the working example on my GitHub page or on JsFiddle and play around with your keyboard.

Managing multiple dialogs

Sometimes your page has multiple modal windows that are yet invisible to the user. Theoretically, they could be dynamically injected into the DOM or exist all at once with their visibility properties set to hidden. Think about not only a cookie banner, but also confirmation windows, password prompts and so on.

Let’s create an example with multiple <dialog> elements ready in the DOM that somewhat represent the accessibility tree shown at the top. The <dialog> elements should recycle existing CSS classes to allow re-using most of the existing solution.

To make hideOverlay reusable, add a parameter called id and use that parameter to identify the window to be closed.

const hideOverlay = (hide, id) => {
    const overlay = document.querySelector(`dialog[id=${id}]`);

    if (hide === true) {
        overlay.setAttribute('aria-hidden', 'true');

        overlay.classList.remove('modal');
    } else {
        overlay.setAttribute('aria-hidden', 'false');

        overlay.classList.add('modal');
    }
}

Here’s how a confirmation dialog could look like:

<dialog id="confirmation-dialog" class="popup-overlay" 
        aria-modal="true" aria-hidden="true">
    <div class="popup">
        <div>
            <h2>Confirmation</h2>
            <p>Are you sure?</p>
        </div>
        <div class="popup-button-container">
            <button class="accept" 
                    onclick="hideOverlay(true, 'confirmation-dialog')">
                Yes
            </button>
            <button onclick="hideOverlay(true, 'confirmation-dialog')">
                No
            </button>
        </div>
    </div>
</dialog>

And the password prompt. There’s nothing really fancy needed.

<dialog id="password-prompt" class="popup-overlay" 
        aria-modal="true" aria-hidden="true">
    <div class="popup">
        <div>
            <h2>Login</h2>
            <p>Please provide your credentials.</p>
        </div>
        <div class="popup-controls">
            <label for="username">Username:</label>
            <input id="username" type="text">
            <label for="password">Password:</label>
            <input id="password" type="password">
        </div>
        <div class="popup-button-container">
            <button class="accept" 
                    onclick="hideOverlay(true, 'password-prompt')">
                Login
            </button>
            <button onclick="hideOverlay(true, 'password-prompt')">
                Cancel
            </button>
        </div>
    </div>
</dialog>

Revisit the selector for focusableElements. There are no changes necessary to the selector, but what happens afterwards.

const focusableElements = Array.from(document
   .querySelectorAll('div[class~=popup-controls] > input, div[class=popup-button-container] > button'));

This selector will return all controls that exist within the different windows, independent of their visibility.

Obviously, we can’t blindly cycle through that NodeList.

Instead of re-executing the query selector on each Tab keydown event, I think the appropriate solution is to scrape all those elements and put them into a collection with the <dialog> parent element as key. This way, there won’t be much overhead in the listener as the code organizing the elements and querying the DOM will only be called once. Add a scrape function like shown below.

const scrape = (element, map) => {
    const key = element.closest('dialog');
    let value = map.get(key);

    if (!value) {
        value = [];
    }

    value.push(element);

    map.set(key, value);
}

Then, adapt the DOMContentLoaded event listener to call the function.

document.addEventListener('DOMContentLoaded', () => {
    const focusableElements = Array.from(document
        .querySelectorAll('div[class~=popup-controls] > input, div[class=popup-button-container] > button'));

    const elementsByDialog = new Map();

    focusableElements.forEach(element => {
        scrape(element, elementsByDialog);
    });
    
    // ...
});

When listening on the Tab keydown event, this allows us to get the array of focusable elements by simply querying the DOM for the currently active dialog. After that, we can determine the appropriate first and last element to call the trap function.

document.addEventListener('keydown', (e) => {
    const overlay = document.querySelector('dialog.popup-overlay.modal');

    // focus trap is inactive
    if (!overlay) {
        return;
    }

    const dialogElements = elementsByDialog.get(overlay);

    const first = dialogElements[0];
    const last = dialogElements[dialogElements.length - 1];

    if (overlay && e.key === 'Tab') {
        if (!dialogElements.includes(e.target)) {
            trap(e, first);
        } else if (e.target === last && !e.shiftKey) {
            trap(e, first);
        } else if (e.target === first && e.shiftKey) {
            trap(e, last);
        }
    }
});

As a result, we now have a reusable <dialog> component with a focus trap that automatically adapts for the currently visible modal window that does not require any additional intervention, allowing the addition of new controls without further ado.

You can find the working example on my GitHub page and on JsFiddle.

Tagged as: accessibility front-end tutorial