Test the boundary, not an assumed focus trap
Nested popovers have two different light-dismiss boundaries: a click inside the parent closes the child; a click outside the entire stack closes both. Popovers remain non-modal. Use showPopover({ source }) for the invocation relationship, and test application-owned selection focus separately from native dismissal.
A small browser fixture
The panels are labeled groups of buttons, not a complete ARIA menu. The child is a DOM sibling opened from a source inside the parent. Tab enters the child after that source. Clicking an option explicitly restores source focus.
Save as main.mjs:
// Two sibling auto popovers. The source establishes their logical nesting.
export const fixture = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Nested popover checks</title>
<style>
body { font: 16px system-ui; padding: 24px; }
[popover] { position: fixed; inset: 120px auto auto 40px; margin: 0; width: 220px; padding: 16px; border: 1px solid; background: white; color: black; }
#subMenu { left: 320px; }
[popover] button { display: block; margin: 8px 0; }
</style></head><body>
<button id="menuToggle" popovertarget="parentMenu">Open options</button>
<button id="afterControl">Background action</button>
<div id="parentMenu" popover="auto" role="group" aria-label="Options">
<button id="openSubProg" aria-expanded="false" aria-controls="subMenu" aria-details="subMenu">More options</button>
<button id="parentOption2">Parent action</button>
</div>
<div id="subMenu" popover="auto" role="group" aria-label="More options">
<button id="subItem1">Choose first option</button>
<button id="subItem2">Choose second option</button>
</div>
<script>
const trigger = document.getElementById('openSubProg');
const sub = document.getElementById('subMenu');
function openSub() { sub.showPopover({ source: trigger }); }
trigger.addEventListener('click', openSub);
trigger.addEventListener('keydown', event => {
if (event.key === 'ArrowRight') { event.preventDefault(); openSub(); }
});
sub.addEventListener('toggle', () => {
trigger.setAttribute('aria-expanded', String(sub.matches(':popover-open')));
});
for (const button of sub.querySelectorAll('button')) {
button.addEventListener('click', () => {
sub.hidePopover();
// Selection is an application action: explicitly return to its source.
trigger.focus();
});
}
</script></body></html>`;
Run and interpret the checks
With Node 22, run npm init -y, npm install --save-dev playwright@1.63.0, and npx playwright install chromium. Save the article's main.test.mjs beside the fixture and run node --test main.test.mjs.
The seven cases cover keyboard order, both click boundaries, sequential Escape, selection focus, non-modal background focus, and parent closure. waitForFunction receives one serialized argument, not multiple positional selectors.
Do not infer a focus trap or universal Escape focus restoration from the source option. The selection handler owns its explicit focus move. There is no generic microtask workaround for arbitrary toggle reentrancy. These Chromium checks do not replace cross-browser or screen-reader testing.
Full test file and explanation: https://chriseugenerodriguez.com/blog/accessible-nested-popovers-showpopover-focus-light-dismiss
Top comments (0)