Trapping Focus in a Modal in Ember

Last reviewed on January 15, 2021

Recently I was working on making a modal more accessibile. When I opened the modal and tried to use the keyboard to navigate, the focus would end up on various elements in the background like navigation links.

See it in action:

animated gif of not trapping focus

In order to fix this, I made two changes:

  1. When the modal opens, set focus on the first focusable element.
  2. When the user tabs away from the last focusable element, put the focus back on the first focusable element.

To achieve this, I created an element modifier called trap-focus and placed it on the form element:

<ModalDialog @translucentOverlay={{true}}>
  <form {{trap-focus}}>
    ...
  </form>
</ModalDialog>

1. Setting Focus on the First Focusable Element

To set the focus on the first focusable element in the trap-focus modifier, I did the following:

// app/modifiers/trap-focus.js
import { modifier } from 'ember-modifier';

export default modifier(function trapFocus(element) {
  const [firstFocusableElement] = findFocusableElements(element);

  firstFocusableElement.focus();
});

function findFocusableElements(element) {
  return element.querySelectorAll(`
    a[href],
    input:not([disabled]),
    select:not([disabled]),
    textarea:not([disabled]),
    button:not([disabled]),
    [tabindex="0"],
    .ember-power-select-trigger
  `);
}

To make this modifier a bit more reusable, I added selectors for the different types of focusable elements that could exist in the first position.

2. Trapping Focus in the Modal

Next, I wanted to trap the focus in the form and not have background elements like navigation links receive the focus while the modal is open. The user should be able to navigate to the next focusable element by pressing tab or to the previous focusable element by pressing shift + tab.

Here is the implementation for that, which was mostly taken from a blog post by Ire Aderinokun called Creating An Accessible Modal Dialog:

import { modifier } from 'ember-modifier';

export default modifier(function trapFocus(element) {
  const [firstFocusableElement] = findFocusableElements(element);

  firstFocusableElement.focus();

  function handleKeyDown(event) {
    const TAB_KEY = 9;
    const focusableElements = findFocusableElements(element);
    const [firstFocusableElement] = focusableElements;
    const lastFocusableElement =
      focusableElements[focusableElements.length - 1];

    if (event.keyCode !== TAB_KEY) {
      return;
    }

    function handleBackwardTab() {
      if (document.activeElement === firstFocusableElement) {
        event.preventDefault();
        lastFocusableElement.focus();
      }
    }

    function handleForwardTab() {
      if (document.activeElement === lastFocusableElement) {
        event.preventDefault();
        firstFocusableElement.focus();
      }
    }

    if (event.shiftKey) {
      handleBackwardTab();
    } else {
      handleForwardTab();
    }
  }

  element.addEventListener('keydown', handleKeyDown);

  return () => {
    element.removeEventListener('keydown', handleKeyDown);
  };
});

function findFocusableElements(element) {
  return element.querySelectorAll(`
    a[href],
    input:not([disabled]),
    select:not([disabled]),
    textarea:not([disabled]),
    button:not([disabled]),
    [tabindex="0"],
    .ember-power-select-trigger
  `);
}

Here it is in action:

animated gif of trapping focus

I am still learning how to create accessible JavaScript applications, so if there is anything in this post that is incorrect or a bad practice, please let me know by reaching out to me on Twitter @iamdtang and I will update this post.

Here is the code for this post on GitHub.