Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Remove iframe handling from UI
  • Loading branch information
jsnajdr committed Oct 22, 2026
commit b9adbba463968c2fe2c300450e5a8ad48e58fe66
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Breaking Changes

- `Select` and `SelectControl`: The `sm`, `md`, and `lg` popup width presets now set a maximum width and size to content, retaining the anchor-width minimum. For fixed widths, compose `Select` with a popup `className` or `style`. Combobox, Autocomplete, and searchable-select presets remain fixed ([#84086](https://github.com/WordPress/gutenberg/pull/84086)).
- `Menu`, `Popover`: Remove the same-origin iframe outside-press workaround. Parent documents that need canvas presses to dismiss popups should forward those events from the iframe document onto the `<iframe>` element, as the block editor canvas does.

### New Features

Expand Down
16 changes: 4 additions & 12 deletions packages/ui/src/menu/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Menu as _Menu } from '@base-ui/react/menu';
import { DirectionProvider } from '../utils/direction-provider';
import { MenuContext, MenuGroupContext } from './context';
import type { RootProps } from './types';
import { useIframeDismissalBridge } from '../utils/use-iframe-dismissal-bridge';

/**
* Groups all parts of a menu.
Expand Down Expand Up @@ -62,22 +61,15 @@ function Root( props: RootProps ) {
trigger.focus();
}
};
const iframeDismissalProps = useIframeDismissalBridge<
_Menu.Root.Actions,
_Menu.Root.ChangeEventDetails
>( {
actionsRef: props.actionsRef,
defaultOpen: props.defaultOpen,
modal: props.modal,
onOpenChange: handleOpenChange,
open: props.open,
} );

return (
<DirectionProvider>
<MenuContext.Provider value={ { isSubmenu: false } }>
<MenuGroupContext.Provider value={ null }>
<_Menu.Root { ...props } { ...iframeDismissalProps } />
<_Menu.Root
{ ...props }
onOpenChange={ handleOpenChange }
/>
</MenuGroupContext.Provider>
</MenuContext.Provider>
</DirectionProvider>
Expand Down
55 changes: 0 additions & 55 deletions packages/ui/src/menu/test/index.browser.test.tsx

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're removing the iframe tests in ui but add no coverage for the bubbling that replaces them. Should we add a minimal browser or e2e test where a non-modal popup in the parent closes on a canvas press, and the press still reaches its canvas target once?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, I added an e2e test for closing the tags popup. Fits very nicely into the existing taxonomies.spec.js suites that tests that UI.

Which other parts of the Gutenberg UI can help us test the other variants? So that we cover the entire Autocomplete/Combobox/Popover/Menu spectrum?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tags input uses SearchableChipSelectControl, so the new test covers Combobox. For Menu, we could open the editor's Options menu and click the canvas. That already dismisses through useIframeDismissalBridge, so the test would protect the behavior we're preserving when we remove that workaround.

For Popover, the block inspector's style-state info popover (e.g. "More information about Hover" for a Button block) is a possible example, but it also dismisses on hover leave. We'd need to ensure the test actually depends on the canvas press. In human testing, a keyboard interaction (instead of mouse click) to open the infotip would work I think, but not sure if that will translate correctly to an automated test.

I haven't found a suitable @wordpress/ui Autocomplete popup in the editor. The workflow palette uses it inline inside a modal.

I think Tags plus Options would give us useful e2e coverage. Could we also add a focused browser test around the block-editor Iframe to assert that the canvas target receives the click exactly once? The tags test currently only checks dismissal.

Original file line number Diff line number Diff line change
Expand Up @@ -73,61 +73,6 @@ describe( 'Menu', () => {
await expect.element( page.getByRole( 'menu' ) ).toBeVisible();
} );

it( 'closes a non-modal menu without consuming an iframe pointer interaction', async () => {
const user = userEvent;
const onCanvasClick = vi.fn();

function ControlledMenuWithIframes() {
const [ open, setOpen ] = useState( false );

return (
<>
<Menu.Root
modal={ false }
onOpenChange={ setOpen }
open={ open }
>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Popup>
<Menu.Item>
<Menu.ItemLabel>Duplicate</Menu.ItemLabel>
</Menu.Item>
</Menu.Popup>
</Menu.Root>
<iframe title="Secondary canvas" />
<iframe
title="Editor canvas"
style={ { display: 'block', marginTop: 200 } }
/>
</>
);
}

await render( <ControlledMenuWithIframes /> );

const iframe =
screen.getByTitle< HTMLIFrameElement >( 'Editor canvas' );
const iframeDocument = iframe.contentDocument;
if ( ! iframeDocument ) {
throw new Error( 'Expected a same-origin iframe document.' );
}
const canvasTarget = iframeDocument.createElement( 'button' );
canvasTarget.textContent = 'Edit block';
canvasTarget.addEventListener( 'click', onCanvasClick );
iframeDocument.body.appendChild( canvasTarget );

await user.click( screen.getByRole( 'button', { name: 'Actions' } ) );
await expect.element( page.getByRole( 'menu' ) ).toBeVisible();

const frame = page.frameLocator( page.getByTitle( 'Editor canvas' ) );
await frame.getByRole( 'button', { name: 'Edit block' } ).click();

await waitFor( () => {
expect( screen.queryByRole( 'menu' ) ).not.toBeInTheDocument();
} );
expect( onCanvasClick ).toHaveBeenCalledTimes( 1 );
} );

it( 'keeps prefix icons hidden from assistive technology', async () => {
const user = userEvent;
await render(
Expand Down
48 changes: 1 addition & 47 deletions packages/ui/src/menu/test/index.jsdom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from 'vitest';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef, useId, useState } from '@wordpress/element';
import { createRef, useId } from '@wordpress/element';
import { isRTL } from '@wordpress/i18n';
import * as Menu from '../index';
import {
Expand Down Expand Up @@ -212,52 +212,6 @@ describe( 'Menu', () => {
expect( triggerWithPayload ).toBeDefined();
} );

it( 'closes a non-modal menu on iframe pointerdown after its trigger becomes disabled', async () => {
const user = userEvent.setup();

function MenuTriggerDisabledWhileOpen() {
const [ open, setOpen ] = useState( false );

return (
<>
<Menu.Root
modal={ false }
onOpenChange={ setOpen }
open={ open }
>
<Menu.Trigger disabled={ open }>Actions</Menu.Trigger>
<Menu.Popup>
<Menu.Item>
<Menu.ItemLabel>Duplicate</Menu.ItemLabel>
</Menu.Item>
</Menu.Popup>
</Menu.Root>
<iframe title="Editor canvas" />
</>
);
}

render( <MenuTriggerDisabledWhileOpen /> );
const iframe = screen.getByTitle( 'Editor canvas' );
const iframeDocument = document.implementation.createHTMLDocument();
Object.defineProperty( iframe, 'contentDocument', {
configurable: true,
get: () => iframeDocument,
} );

await user.click( screen.getByRole( 'button', { name: 'Actions' } ) );
expect( await screen.findByRole( 'menu' ) ).toBeVisible();

act( () => {
iframeDocument.dispatchEvent(
new MouseEvent( 'pointerdown', { bubbles: true } )
);
} );
await waitFor( () => {
expect( screen.queryByRole( 'menu' ) ).not.toBeInTheDocument();
} );
} );

describe( 'group composition', () => {
const nestingCases = [
{
Expand Down
14 changes: 1 addition & 13 deletions packages/ui/src/popover/root.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Popover as _Popover } from '@base-ui/react/popover';
import type { RootProps } from './types';
import { DirectionProvider } from '../utils/direction-provider';
import { useIframeDismissalBridge } from '../utils/use-iframe-dismissal-bridge';

/**
* An accessible popup anchored to a trigger element.
Expand Down Expand Up @@ -40,20 +39,9 @@ import { useIframeDismissalBridge } from '../utils/use-iframe-dismissal-bridge';
* ```
*/
function Root( props: RootProps ) {
const iframeDismissalProps = useIframeDismissalBridge<
_Popover.Root.Actions,
_Popover.Root.ChangeEventDetails
>( {
defaultOpen: props.defaultOpen,
modal: props.modal ?? false,
onOpenChange: ( nextOpen, eventDetails ) =>
props.onOpenChange?.( nextOpen, eventDetails ),
open: props.open,
} );

return (
<DirectionProvider>
<_Popover.Root { ...props } { ...iframeDismissalProps } />
<_Popover.Root { ...props } />
</DirectionProvider>
);
}
Expand Down
39 changes: 0 additions & 39 deletions packages/ui/src/popover/test/index.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -809,43 +809,4 @@ describe( 'Popover', () => {
expect( customFocus ).toHaveBeenCalled();
} );
} );

it( 'closes a non-modal popover without consuming an iframe pointer interaction', async () => {
const user = userEvent;
const onCanvasClick = vi.fn();

await render(
<>
<Popover.Root>
<Popover.Trigger>Open</Popover.Trigger>
<Popover.Popup>
<Popover.Title>Title</Popover.Title>
</Popover.Popup>
</Popover.Root>
<iframe title="Editor canvas" />
</>
);

const iframe =
screen.getByTitle< HTMLIFrameElement >( 'Editor canvas' );
const iframeDocument = iframe.contentDocument;
if ( ! iframeDocument ) {
throw new Error( 'Expected a same-origin iframe document.' );
}
const canvasTarget = iframeDocument.createElement( 'button' );
canvasTarget.textContent = 'Edit block';
canvasTarget.addEventListener( 'click', onCanvasClick );
iframeDocument.body.appendChild( canvasTarget );

await user.click( screen.getByRole( 'button', { name: 'Open' } ) );
await expect.element( page.getByRole( 'dialog' ) ).toBeVisible();

const frame = page.frameLocator( page.getByTitle( 'Editor canvas' ) );
await frame.getByRole( 'button', { name: 'Edit block' } ).click();

await waitFor( () => {
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
} );
expect( onCanvasClick ).toHaveBeenCalledTimes( 1 );
} );
} );

This file was deleted.

This file was deleted.

Loading