Skip to main content

Testing portals & overlays

Dialogs, menus, drawers, popovers, and tooltips usually render outside the subtree of the element that triggers them. A framework "portals" their markup to a node near the end of <body> so stacking and clipping work regardless of where the trigger lives. That breaks the default locator assumption: a child part is normally found as a descendant of its driver's element, but portalled content is a sibling of <body>, not a descendant of the trigger.

This guide covers the recipe atomic-testing already provides for that case, the stacked-overlay consideration, and the one environment limitation you must plan around β€” the native HTML Popover API under jsdom.

The portal re-root recipe​

A driver whose content is portalled re-roots its child lookups to wherever the overlay actually mounts. Two optional hooks on the base driver (packages/core/src/drivers/ComponentDriver.ts) control this:

  • overriddenParentLocator() β€” return the locator the children should be resolved relative to, instead of this driver's own element. For a portalled overlay that is a document-root anchor (e.g. the presentation wrapper the overlay renders into), built with the 'Root' relative position so the search starts from the document, not the trigger.
  • overrideLocatorRelativePosition() β€” return how the children attach to that re-rooted parent. Overlays return 'Same': the content is matched at the re-rooted scope itself rather than as a nested descendant.

The relative-position vocabulary is the LocatorRelativePosition union in packages/core/src/locators/LocatorRelativePosition.ts. Descendant is the default everywhere; Root and Same are the two values that make portal re-rooting expressible as ordinary CSS.

Canonical implementations​

Do not reinvent this β€” copy the shape from the Material UI drivers, which are the reference implementations:

  • packages/component-driver-mui-v7/src/components/DialogDriver.ts
  • packages/component-driver-mui-v7/src/components/MenuDriver.ts
  • packages/component-driver-mui-v7/src/components/DrawerDriver.ts
  • packages/component-driver-mui-v7/src/components/OverlayDriver.ts

Each overrides the two hooks above to re-root at the overlay's presentation container and matches its parts there. The overlay's own interior comes from the caller via within(parts) β€” so a portalled interior is declared the same way as any other component's.

Interiors anchor at the surface, not the presentation container

Re-rooting lands these drivers on MUI's role="presentation" Modal root, whose own children are the backdrop and two focus-trap sentinels β€” the caller's content is further in, inside .MuiDialog-paper / .MuiDrawer-paper. So these drivers override interiorLocator to that surface, and within(parts) resolves there rather than at the re-rooted locator. Scene code is unaffected; it is what keeps a relative interior part (byCssSelector('*', 'Child')) addressing your markup instead of MUI's backdrop.

Writing your own overlay driver: override interiorLocator only if your driver's locator is a wrapper, and point it at an element containing every caller slot. MUI spreads content across DialogTitle/DialogContent/DialogActions as siblings, so the paper is correct and .MuiDialogContent-root would silently drop the action buttons.

Which frameworks this applies to​

  • Material UI β€” the reference implementation above.
  • Radix UI / shadcn.ui β€” portals like MUI (content mounts as direct <body> children), but with no shared wrapper element at all β€” not even MUI's role="presentation" div. Follow the recipe verbatim; per-primitive re-root anchors are catalogued in the Radix driver coverage matrix.
  • Astryx β€” most overlays render in-tree, using the native HTML Popover API instead of a portal at all β€” see the jsdom limitation below.
  • Fluent UI v9 β€” portals like MUI (a cloned FluentProvider mounted on document.body, Fluent's own mountNode default), with per-primitive structural classes (fui-DialogSurface, fui-PopoverSurface, …) as the re-root anchor instead of a shared wrapper role β€” role="dialog" alone is worn by Dialog, OverlayDrawer, and TeachingPopover simultaneously, so those classes are the more specific choice. Menu re-roots via a trigger↔content id link (byLinkedElement) instead of a static class, the same reason Radix's stacked primitives do (see below). InlineDrawer is the one Fluent overlay that renders in-tree, no portal at all. Full recipe: packages/component-driver-fluent-v9/README.md.

Stacked portals​

When two overlays are open at once (a menu opened from inside a dialog, nested dialogs), every driver that re-roots to a document-root anchor will match the first such anchor it finds β€” which may not be the overlay you mean. The fix is a scoped root: re-root to the specific overlay instance (for example, anchor on the nearest container that carries a stable role/data-* for that overlay) rather than the generic document-root presentation wrapper, keeping the surrounding scope so sibling overlays never collide.

This is an ergonomics refinement of the locator strategy, not a new Interactor method β€” it is expressed entirely with the existing re-root hooks and locators. Reach for it only when a test genuinely drives more than one simultaneous overlay; a single overlay needs nothing beyond the recipe above.

Escape dismisses the topmost stacked overlay, not a specific targeted one β€” verified against real Chromium for Fluent's Dialog/Popover/Menu/ OverlayDrawer: with two overlays open, pressing Escape always closes the most-recently-opened one, regardless of which overlay's locator the key event is dispatched on (dismiss handling is typically a global, stack-ordered listener, not per-element). A driver's closeByEscape() should be called on the LAST-opened instance when driving a stacked scenario β€” this is a real framework behavior to design tests around, not a driver bug to fix.

Limitation: the native HTML Popover API under jsdom​

Newer overlays β€” including Astryx's (@astryxdesign/core ships Popover, Tooltip, Dialog, and Overlay) β€” use the native HTML Popover API: HTMLElement.showPopover(), hidePopover(), togglePopover(), the popover attribute, and the :popover-open pseudo-class.

jsdom does not implement the Popover API. There is no layout engine and no top-layer, so calling showPopover() throws or no-ops and :popover-open never matches. Test suites that need open/close behavior under jsdom must monkey-patch those methods themselves β€” atomic-testing does not do this for you.

Plan coverage by what each environment can actually observe:

What you're testingjsdom (DOM/React/Vue)Playwright (E2E)
Markup/structure of overlay content when renderedβœ…βœ…
aria-expanded / aria-controls reads on the triggerβœ…βœ…
Driver re-root resolves the overlay's partsβœ… (when the overlay is rendered)βœ…
Actual showPopover/:popover-open open & close behavior❌ (not implemented)βœ…
Top-layer stacking, visibility, geometry❌ (no layout)βœ…

The practical rule: assert structure and ARIA state in the shared suite so they run everywhere, and gate open/close behavior, visibility, and geometry to E2E. This mirrors the E2E-only gating used for the scroll, drag, and geometry primitives.

Deferred: clipboard​

Clipboard interaction (programmatic copy/paste, reading navigator.clipboard) is not provided and is intentionally out of scope until a component genuinely requires paste-driven behavior. The browser clipboard is permission-gated and unavailable under jsdom, so adding it now would buy an E2E-only primitive with no consumer. It will be designed when the first component that needs it lands β€” do not assume a clipboard primitive exists.