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
Tablistener: finds the first and last focusable element;Tabon the last → wraps to the first,Shift+Tabon the first → to the last. - Restores focus: before opening it remembers
document.activeElement(the button that opened it), andrelease()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:trapFocusandhideBackgroundturn 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".