Focus Trap Keydown

Recipe 6.4 Keep focus contained

Code:

<style>
   [role="dialog"] {
    align-items: center;
    background: rgb( 0 0 0 / 0.2);
    inset: 0;
    justify-content: center;
    margin: auto;
    position: fixed;
  }

   [role="dialog"]:not([hidden]) {
    display: flex;
  }

   [role="dialog"] > div {
    background: rgb(255 255 255);
    box-sizing: border-box;
    max-width: 40rem;
    padding: 2rem;
    width: 90vw;
  }

   :focus-visible {
    outline: 4px solid #123456;
    outline-offset: 4px;
  }

   [role="dialog"]:focus-visible {
    outline-offset: -8px;
  }
</style>

<header>
  <a href="#">Home</a>
</header>

<main>
  <button class="open">Login</button>
</main>

<div role="dialog" aria-modal="true" hidden aria-labelledby="heading">
  <div>
    <button class="close">Close</button>
  
    <h1 id="heading" tabindex="-1">Login</h1>

    <a href="#">A link</a>
  </div>
</div>

<script>
const dialogOpen = document.querySelector(".open");
const dialogClose = document.querySelector(".close");
const dialog = document.querySelector('[role="dialog"]');
const heading = dialog.querySelectorAll('[tabindex="-1"]')[0];
const focusableElements = dialog.querySelectorAll("button, a");

let trigger;

dialogOpen.addEventListener("click", open);
dialogClose.addEventListener("click", close);

dialog.addEventListener("keydown", (e) => {
  if (e.code !== "Tab") return;

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

  if (e.shiftKey) {
    if (first === active) {
      e.preventDefault();
      last.focus();
    }
  } else if (last === active) {
    e.preventDefault();
    first.focus();
  }
});

function open() {
  trigger = document.activeElement;
  dialogToggle();
}

function close() {
  dialogToggle();
}

function dialogToggle() {
  const isOpen = !dialog.hasAttribute("hidden");

  if (!isOpen) {
    dialog.removeAttribute("hidden");
    heading.focus();
  } else {
    dialog.setAttribute("hidden", "hidden");
    trigger.focus();
  }
}
</script>
Live Demo on CodePen