DEV Community

Cover image for Prove native <dialog> focus trapping and restoration with a browser test
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Prove native <dialog> focus trapping and restoration with a browser test

Test observable behavior, not a focus model

A JavaScript object can simulate modal focus perfectly while the real page is broken. This fixture exports actual HTML, opens a native dialog with showModal(), and leaves keyboard containment to Chromium.

The application handles one explicit edge case: when the opener is removed before closure, a close listener focuses a logical fallback. When the opener remains connected, that listener does nothing, so a restoration assertion can observe native behavior.

Runnable fixture

Save this as main.mjs:

export function dialogHtml({ preventCancel = false } = {}) {
  return `<!doctype html><html lang="en"><meta charset="utf-8">
  <title>Native dialog focus fixture</title>
  <button id="opener">Edit preferences</button>
  <a id="outside" href="#summary">Background link</a>
  <button id="fallback">Continue to summary</button>
  <dialog id="preferences" aria-labelledby="dialog-title">
    <h2 id="dialog-title">Preferences</h2>
    <button id="close" autofocus>Close</button>
    <label>First name <input id="first"></label>
    <label>Last name <input id="last"></label>
  </dialog>
  <script>
    const opener = document.getElementById('opener');
    const dialog = document.getElementById('preferences');
    opener.addEventListener('click', () => dialog.showModal());
    document.getElementById('close').addEventListener('click', () => dialog.close());
    dialog.addEventListener('close', () => {
      // The application chooses the next workflow target if its opener is gone.
      if (!opener.isConnected) document.getElementById('fallback').focus();
    });
    ${preventCancel ? "dialog.addEventListener('cancel', event => event.preventDefault());" : ''}
  </script></html>`;
}

Enter fullscreen mode Exit fullscreen mode

Checks that make the example useful

The full test file opens the dialog with Enter, checks :modal and the autofocus target, then sends Tab and Shift+Tab. It asserts real movement between the two inputs and rejects each background focus target. A separate programmatic focus attempt checks that the background link is inert.

The boundary cases cover Escape after removing the opener, a cancel listener that prevents dismissal, and show() as a non-modal control. Preventing cancel does not remove the explicit Close button. Do not confuse a hidden-button click timeout with an exception from the dialog API.

Use Node 22, run npm init -y, install playwright@1.63.0, then run npx playwright install chromium. The full article includes main.test.mjs; execute it with node --test main.test.mjs. No production website or application server is required.

A passing Chromium run is not a cross-browser or screen-reader audit. Repeat the relevant checks against the actual component and supported engines.

Full test file, setup, and limitations: https://chriseugenerodriguez.com/blog/native-dialog-focus-test

Top comments (0)