How can a browser test prove that a native <dialog> traps focus and restores it on cancel?
Run a real Chromium regression test for native dialog focus, Escape dismissal, prevented cancel, and a removed opener with an explicit fallback.
A dialog can look modal while keyboard focus still reaches the page behind it. A unit test of a JavaScript focus model cannot catch that: it verifies your model, not the browser. This example loads a small HTML fixture into Chromium and sends real keyboard events through Playwright. The assertions inspect the resulting DOM state and active element.
The boundary matters. These checks cover one fixture in one browser engine, not every accessibility requirement. They distinguish native focus restoration from an application-provided fallback, test both forward and reverse navigation, and compare modal opening with a non-modal control case. Keep the fixture and tests together so a change to one cannot silently invalidate the explanation.
Keep application behavior separate from browser behavior
The fixture exports dialogHtml from main.mjs. It returns an HTML document containing an opener, a background link, a fallback button, and a labeled dialog. The dialog contains a Close button followed by two inputs. Its click handler calls showModal; the explicit Close button calls close. There is no handwritten Tab handler and no array that pretends to represent browser focus.
The Close button has autofocus, giving this example a deliberate initial target. That choice is not a universal recommendation for every dialog: an edit form may need its first field, while a longer informational dialog may need a different initial reading position. Choose the target for the task, then assert that target rather than accepting whichever element happens to be first.
One small application behavior remains. A close-event listener checks whether the opener is still connected. If it has disappeared, the handler focuses Continue to summary. When the opener remains connected, this handler does nothing, so the restoration assertion is not accidentally testing our own focus call. The conditional prevents an important false positive: code that always refocuses the opener would conceal a regression in native restoration.
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>`;
}
SourcesMDN: `<dialog>` element (opens a new tab)MDN: `HTMLDialogElement.showModal()` (opens a new tab)
Install the browser and load a network-free fixture
Create a small directory with the two files shown here, main.mjs and main.test.mjs. Using Node 22, initialize the directory with npm init -y, install the pinned dependency with npm install --save-dev playwright@1.63.0, then run npx playwright install chromium. Execute the checks with node --test main.test.mjs. Browser installation is a setup step; it is not performed by the tests.
withPage launches a fresh browser for each case and closes it in finally. A route handler aborts outgoing requests, and page.setContent loads the fixture directly. There is no application server, signed-in session, external stylesheet, or production website involved. This makes failures easier to locate and keeps a browser test from quietly depending on an unrelated service.
An installation or launch error is not a focus regression. Resolve a missing browser executable or operating-system dependency before interpreting DOM assertions. In CI, use an image with the matching Playwright browser version and its system dependencies already installed. Keep untrusted examples isolated from credentials and network access; the convenience of a browser fixture does not justify giving generated code your normal development environment.
SourcesWAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)WCAG H102: Creating modal dialogs with the HTML dialog element (opens a new tab)
Verify keyboard opening, traversal, and Escape
openByKeyboard first focuses the opener and presses Enter. The helper checks the :modal pseudo-class as well as the active element. Checking only the open property would be weaker: a dialog opened with show is also open, but it does not have the same modal behavior. The initial active element must be the autofocus Close button declared by this fixture.
The first test then verifies concrete movement: Tab reaches the first input, another Tab reaches the last input, and Shift+Tab returns to the first. These positive assertions stop an interface with completely broken keyboard movement from passing merely because focus never reached the background. A second loop exercises both directions and rejects all three known background targets, not just one button.
The loop does not insist that every boundary keypress wraps directly between the first and last controls. Browser chrome can participate in the traversal; the page-level invariant here is that the opener, background link, and fallback remain unavailable while the modal is active. A programmatic attempt to focus the background link adds a separate check of inert behavior. Finally, Escape closes the dialog, and focus must return to the still-connected opener. waitForFunction waits for the close state without an arbitrary sleep.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
import { dialogHtml } from './main.mjs';
async function withPage(options, run) {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.route('**/*', route => route.abort());
await page.setContent(dialogHtml(options));
await run(page);
} finally { await browser.close(); }
}
const activeId = page => page.evaluate(() => document.activeElement.id);
async function openByKeyboard(page) {
await page.locator('#opener').focus();
await page.keyboard.press('Enter');
assert.equal(await page.locator('#preferences').evaluate(dialog => dialog.matches(':modal')), true);
assert.equal(await activeId(page), 'close');
}
test('native modal receives focus, contains page focus and restores the opener on Escape', async () => {
await withPage({}, async page => {
await openByKeyboard(page);
await page.keyboard.press('Tab'); assert.equal(await activeId(page), 'first');
await page.keyboard.press('Tab'); assert.equal(await activeId(page), 'last');
await page.keyboard.press('Shift+Tab'); assert.equal(await activeId(page), 'first');
// Browser chrome may enter the tab cycle; background page controls must not.
for (const key of ['Tab', 'Tab', 'Tab', 'Shift+Tab', 'Shift+Tab', 'Shift+Tab']) {
await page.keyboard.press(key);
assert.equal(['opener', 'outside', 'fallback'].includes(await activeId(page)), false);
}
await page.locator('#first').focus();
await page.locator('#outside').evaluate(element => element.focus());
assert.equal(await activeId(page), 'first');
await page.keyboard.press('Escape');
await page.waitForFunction(() => !document.getElementById('preferences').open);
assert.equal(await activeId(page), 'opener');
});
});
test('application fallback receives focus after the opener is removed', async () => {
await withPage({}, async page => {
await openByKeyboard(page);
await page.locator('#opener').evaluate(element => element.remove());
await page.keyboard.press('Escape');
await page.waitForFunction(() => document.activeElement.id === 'fallback');
assert.equal(await page.locator('#preferences').evaluate(dialog => dialog.open), false);
assert.equal(await activeId(page), 'fallback');
});
});
test('preventing cancel keeps the modal open until its close button is activated', async () => {
await withPage({ preventCancel: true }, async page => {
await openByKeyboard(page);
await page.keyboard.press('Escape');
assert.equal(await page.locator('#preferences').evaluate(dialog => dialog.matches(':modal')), true);
await page.locator('#close').click();
assert.equal(await page.locator('#preferences').evaluate(dialog => dialog.open), false);
assert.equal(await activeId(page), 'opener');
});
});
test('non-modal show does not make the background inert', async () => {
await withPage({}, async page => {
await page.locator('#preferences').evaluate(dialog => dialog.show());
assert.equal(await page.locator('#preferences').evaluate(dialog => dialog.matches(':modal')), false);
await page.locator('#outside').focus();
assert.equal(await activeId(page), 'outside');
});
});
SourcesMDN: `HTMLDialogElement.showModal()` (opens a new tab)WCAG H102: Creating modal dialogs with the HTML dialog element (opens a new tab)
Cover removed openers and prevented cancellation
The removed-opener test opens the same fixture, deletes the invoking button, and sends Escape. It waits for the explicit fallback to become active, then checks that the dialog is closed. This is a regression test for the application listener, not evidence that a browser invents a suitable replacement target. In a real editor, the logical next target could be a newly created row or the heading of the section the user just changed.
The prevented-cancel test passes preventCancel:true into the fixture. That option installs a cancel listener which calls preventDefault. Escape must leave the dialog modal. The test then activates the visible Close button and verifies closure and restoration. Covering the explicit exit matters: deliberately blocking Escape must not accidentally leave a user with no usable dismissal control.
Avoid testing a hidden Close button by waiting for Playwright to time out and calling that success. A timeout would describe automation actionability, not the dialog close API. Likewise, expecting an exception because a dialog is closed makes no sense if the test never closed it. Assert a state transition that the fixture actually performs, and keep assertion names specific enough to identify which behavior broke.
SourcesMDN: `<dialog>` element (opens a new tab)WAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)
Use a non-modal control case to catch false confidence
The final test opens the dialog using show instead of showModal. It expects :modal to be false and deliberately focuses the background link. The link should become active. This control case checks that the fixture can expose a meaningful difference between modal and non-modal opening rather than producing the same convenient focus result in every configuration.
When adapting the example, make one intentional breaking change before trusting the suite. Replacing showModal with show should fail the modal-opening assertion. Removing autofocus should challenge the initial-target expectation if the browser chooses a different control. Removing the fallback listener should fail the removed-opener case. These are suggested local checks, not claims that a mutation-testing service has already run.
Keep failure handling outside the behavior under test. Do not catch every browser exception and report a successful fallback; that would hide broken setup and real regressions behind the same green result. Report the failed test name and assertion, preserve the fixture version, and distinguish an assertion failure from a runner that could not launch or finish.
SourcesWAI-WCAG H102: Creating modal dialogs with the HTML dialog element (opens a new tab)MDN: `HTMLDialogElement.showModal()` (opens a new tab)
Know what this regression suite does not establish
Four passing cases are evidence about this fixture in the Chromium version used by the runner. They are not a cross-browser compatibility report. If your application supports other engines, run equivalent cases in Firefox and WebKit and record the versions separately. Keyboard behavior in a headed browser and on the operating systems your customers use still deserves manual attention.
The suite also does not evaluate screen-reader announcements, whether the title communicates the task, reading order, contrast, or the suitability of the chosen initial target. A heading referenced by aria-labelledby is useful markup, but its presence alone is not an accessibility audit. Combine these focused regression checks with task-based keyboard and assistive-technology testing.
For integration into an application, retain the same observable contract but use the actual opener and dialog component. Add tests for rerendering, validation errors, and navigation only when those behaviors exist in the workflow. The useful result is a small set of assertions that fail for concrete user-facing reasons, with application fallback logic kept visibly separate from native browser behavior.
SourcesMDN: `<dialog>` element (opens a new tab)WAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)WCAG H102: Creating modal dialogs with the HTML dialog element (opens a new tab)
