What is the smallest fixture that still proves modal containment and focus movement in a native dialog?
Build a small Chromium fixture that distinguishes native dialog inertness, keyboard movement, and focus restoration without assuming immediate Tab wrapping.
A modal test can time out even when the background is correctly inert. The mistake is treating two different requirements as interchangeable: preventing focus from reaching background page controls, and forcing every boundary Tab press to wrap immediately inside the dialog. Browser controls can participate in sequential navigation. A native dialog test should not classify that as a background-page focus leak.
The fixture below keeps three tabbable controls inside the dialog: Close, a labeled input, and another button. Outside it are the opener and a separate background action. Those five controls give each assertion an observable target. This is a small diagnostic fixture, not a claim that five controls are a mathematical minimum for every modal test. Its non-modal comparison is especially useful: the same background button must be focusable when opened with show(), but unavailable when opened with showModal().
Give each control a testable purpose
The exported HTML has no external resources, framework, or server dependency. Close is an actual form button inside a form with method="dialog". The input and final button are separate tabbable descendants; the title is not a fourth keyboard stop. Count the Close button when reasoning about order. Starting on Close, two Tab presses reach the input and then the final button. They do not establish a complete wrap around the dialog.
The background button and opener are two distinct external targets. A test that excludes only the background button can still miss focus landing on the opener. Both therefore appear in the boundary assertions. Giving each control a stable ID makes the failure report specific: the test can say which action reached which unexpected target instead of reporting only that some element had focus.
The dialog has an accessible name through aria-labelledby, and the input has a visible label. Close uses autofocus to make this fixture's initial target intentional. That does not make Close the right initial target for every product. A form or long informational dialog may need a different choice. The point is to state the choice in the implementation and check it independently from later keyboard movement.
export const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Native dialog focus fixture</title>
</head>
<body>
<button id="background">Background action</button>
<button id="opener">Open dialog</button>
<dialog id="dlg" aria-labelledby="dialog-title">
<h2 id="dialog-title">Example settings</h2>
<form method="dialog">
<button id="close" autofocus>Close</button>
</form>
<label for="in1">Display name</label>
<input id="in1">
<button id="in2">Another action</button>
</dialog>
<script>
const dialog = document.getElementById('dlg');
const opener = document.getElementById('opener');
opener.addEventListener('click', () => dialog.showModal());
// Native restoration handles a connected opener; the app owns this fallback.
dialog.addEventListener('close', () => {
if (!opener.isConnected) document.getElementById('background').focus();
});
</script>
</body>
</html>`;
SourcesMDN: <dialog> element (opens a new tab)MDN: HTMLDialogElement.showModal() (opens a new tab)
Separate native modality from a custom focus loop
showModal() places the dialog in the top layer and makes the rest of its document inert. Merely calling show(), setting the open attribute, or styling a backdrop does not provide that same modal state. The opening helper checks the :modal pseudo-class rather than only the open property, then verifies the declared autofocus target. These checks distinguish a visible dialog from a modal one.
The ARIA Authoring Practices modal pattern describes immediate Tab and Shift+Tab wrapping. Do not turn that pattern guidance into an untested assertion about every native browser boundary. In Chromium, a boundary traversal can leave document focus on the body while browser controls participate in navigation. The body observation alone does not identify a particular browser toolbar control, and the test does not claim it does.
There is no custom Tab handler in this fixture. Positive assertions verify ordinary movement between known internal controls; boundary assertions reject both external page controls. If your component adds an explicit focus loop, test that authored loop separately. Adding a key handler merely to make an incorrect native-behavior assertion pass would change the mechanism under examination and hide why the original test failed.
SourcesMDN: HTMLDialogElement.showModal() (opens a new tab)WAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)
Challenge inertness instead of assuming it
The boundary test starts from Close and sends eight Tab presses, then resets to Close and sends eight Shift+Tab presses. Each press must avoid the opener and background button. The test deliberately does not wait for the last button after a single reverse boundary press: waiting longer cannot make a browser perform a focus transition it does not promise. The separate movement test ensures a completely frozen keyboard interaction cannot pass just by avoiding the background.
A second assertion challenges programmatic focus. With the input focused, the test calls focus() on each external control and verifies that focus remains on the input. This checks observable inertness in addition to sequential navigation. It does not use a forced click or synthetic click event as proof that a real user can interact with the background. Those mechanisms can bypass the interaction restrictions being tested.
The control case loads a fresh page and opens the same dialog using show(). It expects :modal to be false and successfully focuses the background button. This establishes that the external target really can receive focus when it is not inert. Without that comparison, an accidentally disabled or otherwise unfocusable background button could make a weak containment test look convincing.
SourcesWCAG H102: Creating modal dialogs with the HTML dialog element (opens a new tab)WAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)
Check native return and the application fallback separately
Two dismissal tests exercise the visible Close button and Escape. Each starts with a connected opener, checks that the dialog closes, and expects native restoration to return focus to that opener. The application close listener does nothing in this case. This separation prevents a script that always refocuses the opener from being mistaken for evidence of browser-managed restoration.
The removed-opener case deletes that button while the modal is open, then activates Close. Now the application listener supplies an explicit fallback: the background action. Its location makes sense only for this fixture. In a real workflow, choose a logical next task, newly created item, or section heading rather than arbitrarily sending focus to the first available element.
The close event is dispatched asynchronously. Observing open=false does not prove the listener has already finished. This test waits for the actual fallback target with a bounded five-second wait, then checks the closed state. It does not insert a fixed sleep or assert that the browser chooses the fallback automatically. Removing the listener should make this case fail, while leaving the connected-opener cases unaffected.
SourcesMDN: <dialog> element (opens a new tab)WAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)
Run the checks and interpret a failure
Save the two code blocks as main.mjs and main.test.mjs in a separate directory. Use Node 22, initialize it with npm init -y, install the runner with npm install --save-dev playwright@1.63.0, and install Chromium with npx playwright install chromium. Run node --test main.test.mjs. Browser installation happens during setup, never inside the test itself.
Each case launches a fresh headless browser, blocks network requests, loads the fixture with page.setContent, and closes the browser in finally. The six named cases isolate movement, background inertness, the non-modal comparison, two dismissal methods, and the removed-opener fallback. An assertion message identifies the control or keyboard step involved. A missing browser executable is a setup failure, not evidence of broken dialog behavior.
For generated examples in CI, keep the test process separate from application credentials and external network access. The no-sandbox launch argument is intended for an already isolated runner, not a recommendation to browse untrusted websites without protection. Use the matching Playwright browser image and preserve the tested source revision with its results. Do not replace a failed assertion with an unconditional pass or describe an unexecuted example as verified.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
import { html } from './main.mjs';
async function withPage(run) {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
try {
const page = await browser.newPage();
page.setDefaultTimeout(5000);
await page.route('**/*', route => route.abort());
await page.setContent(html);
await run(page);
} finally { await browser.close(); }
}
const activeId = page => page.evaluate(() => document.activeElement.id);
async function open(page) {
await page.locator('#opener').focus();
await page.keyboard.press('Enter');
assert.equal(await page.locator('#dlg').evaluate(dialog => dialog.matches(':modal')), true, 'showModal must create a modal, not just an open dialog');
assert.equal(await activeId(page), 'close', 'The declared autofocus target receives initial focus');
}
test('keyboard movement reaches each internal control in both directions', async () => {
await withPage(async page => {
await open(page);
for (const [key, expected] of [['Tab', 'in1'], ['Tab', 'in2'], ['Shift+Tab', 'in1'], ['Shift+Tab', 'close']]) {
await page.keyboard.press(key);
assert.equal(await activeId(page), expected, `${key} should reach ${expected} inside the dialog`);
}
});
});
test('boundary navigation and programmatic focus cannot reach background controls', async () => {
await withPage(async page => {
await open(page);
for (const key of ['Tab', 'Shift+Tab']) {
await page.locator('#close').focus();
for (let step = 0; step < 8; step++) {
await page.keyboard.press(key);
// Browser controls may join the cycle; background document controls may not.
assert.equal(['opener', 'background'].includes(await activeId(page)), false, `${key} step ${step + 1} reached an inert background control`);
}
}
for (const target of ['opener', 'background']) {
await page.locator('#in1').focus();
await page.locator(`#${target}`).evaluate(element => element.focus());
assert.equal(await activeId(page), 'in1', `Programmatic focus must not reach ${target} while modal`);
}
});
});
test('show is a non-modal control case where the background remains focusable', async () => {
await withPage(async page => {
await page.locator('#dlg').evaluate(dialog => dialog.show());
assert.equal(await page.locator('#dlg').evaluate(dialog => dialog.matches(':modal')), false);
await page.locator('#background').focus();
assert.equal(await activeId(page), 'background', 'The external control must be focusable when it is not inert');
});
});
for (const method of ['button', 'Escape']) {
test(`${method} dismissal restores the connected opener`, async () => {
await withPage(async page => {
await open(page);
if (method === 'button') await page.locator('#close').click();
else await page.keyboard.press('Escape');
assert.equal(await page.locator('#dlg').evaluate(dialog => dialog.open), false, 'The dialog must close');
assert.equal(await activeId(page), 'opener', 'Native restoration should focus the connected opener');
});
});
}
test('the application close listener restores a fallback after the opener is removed', async () => {
await withPage(async page => {
await open(page);
await page.locator('#opener').evaluate(element => element.remove());
await page.locator('#close').click();
// close is dispatched asynchronously; observing open=false alone is too early.
await page.waitForFunction(() => document.activeElement.id === 'background', null, { timeout: 5000 });
assert.equal(await page.locator('#dlg').evaluate(dialog => dialog.open), false);
assert.equal(await activeId(page), 'background', 'The explicit application fallback receives focus after close');
});
});
SourcesWAI-ARIA APG: Dialog (Modal) Pattern (opens a new tab)WCAG H102: Creating modal dialogs with the HTML dialog element (opens a new tab)
Keep the evidence narrower than an accessibility audit
These checks establish behavior of one fixture in the runner's Chromium build. They do not prove identical browser-toolbar navigation in Firefox, WebKit, or a headed browser on every operating system. Add those engines when they are part of your support policy, and record their results independently. A single green run should not become a claim of universal compatibility.
Focus behavior is also not the whole accessibility experience. This suite does not assess screen-reader announcements, visual contrast, zoom, touch input, pointer hit testing, or whether the chosen initial target suits the task. The labels are useful markup, not a certification. Verify the real component with keyboard and assistive-technology workflows before treating a small fixture as sufficient application coverage.
Use intentional regressions to challenge the checks. Replacing showModal() with show() should fail the modality assertion; removing the fallback listener should fail only its dedicated case. Changing a control's tab order should fail a concrete movement assertion. Those suggested checks help expose false confidence without prescribing a custom focus trap. The useful boundary is precise: background page controls stay unavailable while the native modal is active, and the application owns its explicit restoration fallback.
SourcesMDN: HTMLDialogElement.showModal() (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)

