Skip to the code
cf.

Modal dialog with focus management

HardDOM

The page has a button and a hidden dialog:

<button id="open" type="button">Delete account</button>
<div id="confirm" role="dialog" aria-modal="true" aria-labelledby="confirm-title" hidden>
  <h2 id="confirm-title">Delete your account?</h2>
  <label>Type DELETE to confirm <input id="confirm-text"></label>
  <button id="cancel" type="button" data-close>Cancel</button>
  <button id="delete" type="button">Delete</button>
</div>

Write createDialog(dialog), which returns { open, close }.

open() remembers the element that has focus (document.activeElement), removes hidden from dialog, and moves focus to the first focusable element inside it. Calling open() while the dialog is already open does nothing.

close() adds hidden back and returns focus to the element that had it before open(). Pressing Escape inside the open dialog closes it, and so does clicking any element inside it that has a data-close attribute.

While it's open, keep Tab inside: Tab on the last focusable element moves focus to the first, and Shift+Tab on the first moves focus to the last, both with preventDefault(). Leave every other Tab press alone, so the browser moves between the elements in between.

Focusable means links with an href; buttons, inputs, selects, and textareas that aren't disabled; and anything with a tabindex other than -1. Work them out when Tab is pressed, since they can change while the dialog is open.

const confirmDelete = createDialog(document.getElementById("confirm"));
document.getElementById("open").addEventListener("click", confirmDelete.open);
// focus "Delete account" and press Enter: the dialog shows, and the text field has focus
// press Escape: the dialog hides, and "Delete account" has focus again

What the tests check

  1. open() shows the dialog and focuses its first focusable element
    const dialog = document.getElementById("confirm");
    const modal = createDialog(dialog);
    document.getElementById("open").focus();
    modal.open();
    expect(dialog.hidden).toBe(false);
    expect(document.activeElement).toBe(document.getElementById("confirm-text"));
    
  2. Escape closes the dialog
    const dialog = document.getElementById("confirm");
    const modal = createDialog(dialog);
    modal.open();
    fire(document.activeElement, "keydown", { key: "Escape" });
    expect(dialog.hidden).toBe(true);
    
  3. closing returns focus to where it was before opening
    const opener = document.getElementById("open");
    const modal = createDialog(document.getElementById("confirm"));
    opener.focus();
    modal.open();
    modal.close();
    expect(document.activeElement).toBe(opener);
    modal.open();
    fire(document.activeElement, "keydown", { key: "Escape" });
    expect(document.activeElement).toBe(opener);
    
  4. clicking an element with data-close closes it
    const opener = document.getElementById("open");
    const dialog = document.getElementById("confirm");
    const modal = createDialog(dialog);
    opener.focus();
    modal.open();
    fire(document.getElementById("cancel"), "click");
    expect(dialog.hidden).toBe(true);
    expect(document.activeElement).toBe(opener);
    
  5. Tab on the last element wraps to the first
    const modal = createDialog(document.getElementById("confirm"));
    modal.open();
    const last = document.getElementById("delete");
    last.focus();
    const event = fire(last, "keydown", { key: "Tab" });
    expect(event.defaultPrevented).toBe(true);
    expect(document.activeElement).toBe(document.getElementById("confirm-text"));
    
  6. Shift+Tab on the first element wraps to the last
    const modal = createDialog(document.getElementById("confirm"));
    modal.open();
    const event = fire(document.getElementById("confirm-text"), "keydown", { key: "Tab", shiftKey: true });
    expect(event.defaultPrevented).toBe(true);
    expect(document.activeElement).toBe(document.getElementById("delete"));
    
  7. leaves Tab alone between the first and the last element
    const modal = createDialog(document.getElementById("confirm"));
    modal.open();
    const cancel = document.getElementById("cancel");
    cancel.focus();
    expect(fire(cancel, "keydown", { key: "Tab" }).defaultPrevented).toBe(false);
    expect(fire(cancel, "keydown", { key: "Tab", shiftKey: true }).defaultPrevented).toBe(false);
    expect(document.activeElement).toBe(cancel);
    
  8. skips disabled elements when wrapping
    const modal = createDialog(document.getElementById("confirm"));
    modal.open();
    document.getElementById("delete").disabled = true;
    const cancel = document.getElementById("cancel");
    cancel.focus();
    const event = fire(cancel, "keydown", { key: "Tab" });
    expect(event.defaultPrevented).toBe(true);
    expect(document.activeElement).toBe(document.getElementById("confirm-text"));
    
  9. opening again while open keeps the original element to return to
    const opener = document.getElementById("open");
    const modal = createDialog(document.getElementById("confirm"));
    opener.focus();
    modal.open();
    modal.open();
    modal.close();
    expect(document.activeElement).toBe(opener);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).