Skip to content

feat: support native dialog/popover elements#3

Merged
lazarv merged 3 commits intomainfrom
feat/dialog-popover
Apr 25, 2026
Merged

feat: support native dialog/popover elements#3
lazarv merged 3 commits intomainfrom
feat/dialog-popover

Conversation

@lazarv
Copy link
Copy Markdown
Contributor

@lazarv lazarv commented Apr 24, 2026

Summary

Adds top-layer projection for <dialog>.showModal() and the Popover API across both same-origin and cross-origin modes, so the projected clone is genuinely promoted to the host document's top layer rather than just inheriting an open attribute.

Motivation

The browser only promotes an element to the top layer for the specific element the imperative method was called on. Mirroring the open attribute through the existing MutationObserver isn't enough: without an explicit showModal() on the clone, :modal never matches, ::backdrop never paints, the dialog stays at its UA-default position: absolute (lands relative to its nearest positioned ancestor instead of the viewport), and clipping ancestors with overflow: hidden cut it off. Popovers worked when activated declaratively via popovertarget because the browser handles that locally on the cloned button, but showPopover() / hidePopover() / togglePopover() from script had the same gap.

Implementation

The fix mirrors the call itself, not just its visible side effects. In same-origin mode the host patches HTMLDialogElement.prototype and HTMLElement.prototype inside the iframe's realm, looks up the mirror via the existing elementMap WeakMap, and replays the call on the clone. In cross-origin mode the bridge does the equivalent prototype patching inside the iframe and emits a new vf:invokeMethod message carrying { targetId, method, args }; the host resolves the target via _remoteIdToNode and calls the method on the clone. The same-origin patch is idempotent per window via a __vfTopLayerPatched flag. Echo loops are prevented by a _suppressInvoke counter in the bridge and a per-node Symbol.for("__vfTopLayerSuppress") plus per-mirror WeakSet in same-origin mode.

The reverse direction is equally important: if the user dismisses the projected dialog via ESC, backdrop click, or an in-content close button, the source must close too, otherwise the next showModal() throws InvalidStateError. Both modes attach a one-shot close listener (or toggle listener watching for newState === "closed" on popovers) whenever an opener method is mirrored. The listeners self-remove on fire so re-opening doesn't accumulate handlers.

Example

The vanilla example gets a new "Dialog" tab pointing at examples/shared/dialog.html, which exercises both APIs and includes a dialog triggered from inside an overflow: hidden ancestor — the deliberate clipping test that originally surfaced the issue. The CSS reset in the fixture is intentionally scoped to body, h1, h2, h3, p rather than * { margin: 0; padding: 0 }, because a blanket reset overrides the UA's margin: auto on dialog[open] and [popover], which is what centers them in the top layer.

Test plan

Two new test files cover both transport modes. packages/core/test/dialog.test.ts drives the same-origin path through a real iframe fixture and asserts that source showModal() causes the mirror to match :modal, that show() opens both ends but does not match :modal, that source close() propagates to the mirror, that mirror dismissal echoes back, that closing fires the source close event exactly once (catches echo-loop bugs), that re-opening after close works (catches stuck-suppress-flag bugs), that re-running the install is a no-op, and equivalent coverage for popover open/hide/toggle and host-side dismissal. packages/core/test/cross-origin-dialog.test.ts reuses the existing setupCrossOrigin / bridgeSend / performHandshake harness with synthetic postMessage to verify the inbound vf:invokeMethod handler drives the mirror, ignores unknown targetId, ignores methods that don't exist on the target, swallows InvalidStateError from duplicate showModal, and that mirror dismissal produces exactly one outbound echo with the correct method, target, and args.

Manual verification: load the vanilla example, switch to the Dialog tab, click "Open dialog" and confirm the projection is centered with a backdrop covering the whole viewport; click "Open from here" inside the clipped parent and confirm the dialog still escapes the overflow: hidden boundary; toggle the popover and confirm it appears above all content without manual z-index.

Known gaps

If a dialog is created and showModal'd in the same tick, the MutationObserver hasn't yet shipped the new node to the host, so the clone lookup misses. This is rare in real code and out of scope here; a queue-and-flush on the next mutation pass would address it. requestClose() and CloseWatcher are not intercepted specifically — the resulting close event still round-trips correctly, but the cancel-able close pattern would need its own path if anyone relies on it.

@lazarv lazarv merged commit 5e5b976 into main Apr 25, 2026
12 checks passed
@lazarv lazarv deleted the feat/dialog-popover branch April 25, 2026 08:08
lazarv pushed a commit that referenced this pull request Apr 25, 2026
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## virtual-frame@0.1.2

### Patch Changes

- [#3](#3)
[`5e5b976`](5e5b976)
Thanks [@lazarv](https://github.com/lazarv)! - Support native
dialog/popover elements

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant