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>`;
}
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)