Code

js/a11y.js — the focus trap

In the code column — js/a11y.js with trapFocus and hideBackground. This is the heart of the lesson.

trapFocus(container) does all three things:

  • Moves focus in (initialFocus?.focus()) on open.
  • Traps it via a Tab listener: finds the first and last focusable element; Tab on the last → wraps to the first, Shift+Tab on the first → to the last.
  • Restores focus: before opening it remembers document.activeElement (the button that opened it), and release() calls .focus() on it.
export function trapFocus(container, { initialFocus } = {}) {
  const previouslyFocused = document.activeElement;      // who opened us

  function onKeydown(e) {
    if (e.key !== 'Tab') return;
    const f = getFocusable(container);
    const first = f[0], last = f[f.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  }

  container.addEventListener('keydown', onKeydown);
  (initialFocus || getFocusable(container)[0])?.focus();  // (1) move in

  return function release() {
    container.removeEventListener('keydown', onKeydown);
    previouslyFocused?.focus();                            // (3) restore
  };
}

getFocusable filters el.offsetParent !== null — it skips hidden elements (a hidden button shouldn't be a focus stop).

hideBackground(dialog) sets aria-hidden="true" on every child of body except the dialog, and returns a restore function. That way the screen reader "can't see" the background.

Tip. The returned release() / restore() functions are a clean pattern: trapFocus and hideBackground turn on a behavior and return a way to undo it. The modal just calls them on open and their returned functions on close. No global variables, no "is it still active".