Skip to content

Commit 9dff15a

Browse files
authored
Merge e92200b into 5e25c6a
2 parents 5e25c6a + e92200b commit 9dff15a

14 files changed

Lines changed: 137 additions & 954 deletions

‎packages/block-editor/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- `ColorPanel`: Fix inconsistent heading level and all-caps styling on the Elements panel by defaulting to heading level 2 ([#85247](https://github.com/WordPress/gutenberg/pull/85247)).
1313
- Position, Text shadow: Show the "Default" option as selected instead of the `Select` placeholder ([#85172](https://github.com/WordPress/gutenberg/pull/85172)).
1414
- Leave the props that `LineHeightControl`, `LetterSpacingControl`, `TextIndentControl`, `ColorPaletteControl`, `ColorGradientSettingsDropdown` and `FontSizePicker` override out of their types, and accept partial color objects in the color lookups ([#85213](https://github.com/WordPress/gutenberg/pull/85213)).
15+
- Forward `pointerdown`, `mousedown`, and `click` from the editor canvas iframe onto the outer `<iframe>` element so parent-document popups can dismiss on canvas presses, and stop React portal bubbling for those redispatched events so parent trees do not see them twice ([#85263](https://github.com/WordPress/gutenberg/pull/85263)).
1516

1617
### Internal
1718

‎packages/block-editor/src/components/iframe/index.tsx‎

Lines changed: 109 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import clsx from 'clsx';
22
import { version as reactVersion } from 'react';
3-
import type { CSSProperties, ForwardedRef, Ref, SyntheticEvent } from 'react';
3+
import type {
4+
CSSProperties,
5+
EventHandler,
6+
ForwardedRef,
7+
IframeHTMLAttributes,
8+
Ref,
9+
SyntheticEvent,
10+
} from 'react';
411
import { useState, createPortal, forwardRef } from '@wordpress/element';
512
import { __ } from '@wordpress/i18n';
613
import { useMergeRefs, useRefEffect, useDisabled } from '@wordpress/compose';
@@ -13,9 +20,45 @@ import { store as blockEditorStore } from '../../store';
1320
import type { ResolvedAssets } from '../../store/types';
1421
import type { EventConstructor, IframeProps } from './types';
1522

23+
/**
24+
* Events redispatched onto the outer `<iframe>` element.
25+
*
26+
* Native listeners attach inside the iframe document (`useBubbleEvents`).
27+
* Matching React portal bubbles are stopped so parents do not also see the
28+
* portal delivery.
29+
*/
30+
const BRIDGED_EVENTS = [
31+
{ native: 'dragover', react: 'onDragOver' },
32+
{ native: 'mousemove', react: 'onMouseMove' },
33+
{ native: 'pointerdown', react: 'onPointerDown' },
34+
{ native: 'mousedown', react: 'onMouseDown' },
35+
{ native: 'click', react: 'onClick' },
36+
{ native: 'keydown', react: 'onKeyDown' },
37+
] as const satisfies ReadonlyArray< {
38+
native: string;
39+
react: keyof IframeHTMLAttributes< HTMLIFrameElement >;
40+
} >;
41+
42+
type BridgedReactEvent = ( typeof BRIDGED_EVENTS )[ number ][ 'react' ];
43+
type BridgedEventHandler =
44+
EventHandler< SyntheticEvent< HTMLIFrameElement > > | undefined;
45+
46+
/**
47+
* Resolves the parent-window event constructor that matches `event`.
48+
* Looks up by the event prototype's constructor name so the redispatched
49+
* event is created in this realm (not the iframe's).
50+
*
51+
* @param event Native or React synthetic event from the iframe document.
52+
*/
53+
function getEventConstructor( event: Event | SyntheticEvent ) {
54+
const constructorName = Object.getPrototypeOf( event ).constructor.name;
55+
return ( window as unknown as Record< string, EventConstructor > )[
56+
constructorName
57+
];
58+
}
59+
1660
function bubbleEvent(
1761
event: Event | SyntheticEvent,
18-
Constructor: EventConstructor,
1962
frame: HTMLIFrameElement
2063
) {
2164
const init: {
@@ -48,7 +91,7 @@ function bubbleEvent(
4891
init.clientY! += rect.top;
4992
}
5093

51-
const newEvent = new Constructor( event.type, init );
94+
const newEvent = new ( getEventConstructor( event ) )( event.type, init );
5295
if ( init.defaultPrevented ) {
5396
newEvent.preventDefault();
5497
}
@@ -60,12 +103,63 @@ function bubbleEvent(
60103
}
61104

62105
/**
63-
* Bubbles some event types (keydown, keypress, and dragover) to parent document
64-
* document to ensure that the keyboard shortcuts and drag and drop work.
106+
* Stops a React synthetic event that bubbled across the portal boundary from
107+
* the iframe document into the parent React tree.
108+
*
109+
* @param event React synthetic event that may have crossed documents.
110+
*/
111+
function stopReactPortalPropagation( event: SyntheticEvent ) {
112+
if (
113+
event.currentTarget.ownerDocument ===
114+
( event.target as Element ).ownerDocument
115+
) {
116+
return;
117+
}
118+
119+
const { stopPropagation } = event.nativeEvent;
120+
event.nativeEvent.stopPropagation = () => {};
121+
event.stopPropagation();
122+
event.nativeEvent.stopPropagation = stopPropagation;
123+
}
124+
125+
/**
126+
* React props that stop portal bubbles for redispatched events at the outer
127+
* `<iframe>`. Forwards any matching consumer handler first.
65128
*
66-
* Ideally, we should remove event bubbling in the future. Keyboard shortcuts
67-
* should be context dependent, e.g. actions on blocks like Cmd+A should not
68-
* work globally outside the block editor.
129+
* @param props Iframe props that may include event handlers.
130+
*/
131+
function getPortalBoundaryEventProps( props: IframeProps ) {
132+
const boundaryProps = {} as Pick<
133+
IframeHTMLAttributes< HTMLIFrameElement >,
134+
BridgedReactEvent
135+
>;
136+
137+
for ( const { react: propName } of BRIDGED_EVENTS ) {
138+
boundaryProps[ propName ] = ( event ) => {
139+
( props[ propName ] as BridgedEventHandler )?.( event );
140+
stopReactPortalPropagation( event );
141+
};
142+
}
143+
144+
return boundaryProps;
145+
}
146+
147+
/**
148+
* Bubbles selected native events from the iframe document onto the outer
149+
* `<iframe>` element so parent-document listeners can observe them.
150+
*
151+
* - `dragover` and `mousemove` keep drag-and-drop and pointer tracking working
152+
* across the frame boundary.
153+
* - `pointerdown`, `mousedown`, and `click` let parent-document popups treat
154+
* a canvas press as an outside press.
155+
* - `keydown` lets parent shortcuts see canvas keystrokes.
156+
*
157+
* Pair with `getPortalBoundaryEventProps` so React ancestors do not also
158+
* receive the portal-bubbled synthetic event for those same interactions.
159+
*
160+
* Ideally, we should remove the `keydown` event bubbling in the future.
161+
* Keyboard shortcuts should be context dependent, e.g. actions on blocks
162+
* like Cmd+A should not work globally outside the block editor.
69163
*
70164
* @param iframeDocument Document to attach listeners to.
71165
*/
@@ -79,29 +173,17 @@ function useBubbleEvents( iframeDocument: Document | undefined ) {
79173
}
80174
const { frameElement } = defaultView;
81175
const html = iframeDocument!.documentElement;
82-
const eventTypes = [ 'dragover', 'mousemove' ];
83176
const handlers: Record< string, ( event: Event ) => void > = {};
84-
for ( const name of eventTypes ) {
85-
handlers[ name ] = ( event ) => {
86-
const prototype = Object.getPrototypeOf( event );
87-
const constructorName = prototype.constructor.name;
88-
// The window has the constructors of the events it dispatches.
89-
const Constructor = (
90-
window as unknown as Record< string, EventConstructor >
91-
)[ constructorName ];
92-
// The iframe window's frame is the iframe.
93-
bubbleEvent(
94-
event,
95-
Constructor,
96-
frameElement as HTMLIFrameElement
97-
);
177+
for ( const { native } of BRIDGED_EVENTS ) {
178+
handlers[ native ] = ( event ) => {
179+
bubbleEvent( event, frameElement as HTMLIFrameElement );
98180
};
99-
html.addEventListener( name, handlers[ name ] );
181+
html.addEventListener( native, handlers[ native ] );
100182
}
101183

102184
return () => {
103-
for ( const name of eventTypes ) {
104-
html.removeEventListener( name, handlers[ name ] );
185+
for ( const { native } of BRIDGED_EVENTS ) {
186+
html.removeEventListener( native, handlers[ native ] );
105187
}
106188
};
107189
} );
@@ -343,9 +425,9 @@ function Iframe( {
343425
const iframe = (
344426
<>
345427
{ shouldRenderFocusCaptureElements && before }
346-
{ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ }
347428
<iframe
348429
{ ...props }
430+
{ ...getPortalBoundaryEventProps( props ) }
349431
style={ {
350432
...props.style,
351433
height: props.style?.height,
@@ -359,36 +441,6 @@ function Iframe( {
359441
tabIndex={ tabIndex }
360442
src={ src }
361443
title={ title }
362-
onKeyDown={ ( event ) => {
363-
if ( props.onKeyDown ) {
364-
props.onKeyDown( event );
365-
}
366-
// If the event originates from inside the iframe, it means
367-
// it bubbled through the portal, but only with React
368-
// events. We need to bubble native events as well,
369-
// though by doing so we also trigger another React event,
370-
// so we need to stop the propagation of this event to avoid
371-
// duplication.
372-
if (
373-
event.currentTarget.ownerDocument !==
374-
( event.target as Element ).ownerDocument
375-
) {
376-
// We should only stop propagation of the React event,
377-
// the native event should further bubble inside the
378-
// iframe to the document and window.
379-
// Alternatively, we could consider redispatching the
380-
// native event in the iframe.
381-
const { stopPropagation } = event.nativeEvent;
382-
event.nativeEvent.stopPropagation = () => {};
383-
event.stopPropagation();
384-
event.nativeEvent.stopPropagation = stopPropagation;
385-
bubbleEvent(
386-
event,
387-
window.KeyboardEvent,
388-
event.currentTarget
389-
);
390-
}
391-
} }
392444
>
393445
{ iframeDocument &&
394446
createPortal(

‎packages/ui/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Breaking Changes
66

77
- `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)).
8+
- `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 ([#85263](https://github.com/WordPress/gutenberg/pull/85263)).
89

910
### New Features
1011

‎packages/ui/src/menu/root.tsx‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Menu as _Menu } from '@base-ui/react/menu';
22
import { DirectionProvider } from '../utils/direction-provider';
33
import { MenuContext, MenuGroupContext } from './context';
44
import type { RootProps } from './types';
5-
import { useIframeDismissalBridge } from '../utils/use-iframe-dismissal-bridge';
65

76
/**
87
* Groups all parts of a menu.
@@ -62,22 +61,15 @@ function Root( props: RootProps ) {
6261
trigger.focus();
6362
}
6463
};
65-
const iframeDismissalProps = useIframeDismissalBridge<
66-
_Menu.Root.Actions,
67-
_Menu.Root.ChangeEventDetails
68-
>( {
69-
actionsRef: props.actionsRef,
70-
defaultOpen: props.defaultOpen,
71-
modal: props.modal,
72-
onOpenChange: handleOpenChange,
73-
open: props.open,
74-
} );
7564

7665
return (
7766
<DirectionProvider>
7867
<MenuContext.Provider value={ { isSubmenu: false } }>
7968
<MenuGroupContext.Provider value={ null }>
80-
<_Menu.Root { ...props } { ...iframeDismissalProps } />
69+
<_Menu.Root
70+
{ ...props }
71+
onOpenChange={ handleOpenChange }
72+
/>
8173
</MenuGroupContext.Provider>
8274
</MenuContext.Provider>
8375
</DirectionProvider>

‎packages/ui/src/menu/test/index.browser.test.tsx‎

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -73,61 +73,6 @@ describe( 'Menu', () => {
7373
await expect.element( page.getByRole( 'menu' ) ).toBeVisible();
7474
} );
7575

76-
it( 'closes a non-modal menu without consuming an iframe pointer interaction', async () => {
77-
const user = userEvent;
78-
const onCanvasClick = vi.fn();
79-
80-
function ControlledMenuWithIframes() {
81-
const [ open, setOpen ] = useState( false );
82-
83-
return (
84-
<>
85-
<Menu.Root
86-
modal={ false }
87-
onOpenChange={ setOpen }
88-
open={ open }
89-
>
90-
<Menu.Trigger>Actions</Menu.Trigger>
91-
<Menu.Popup>
92-
<Menu.Item>
93-
<Menu.ItemLabel>Duplicate</Menu.ItemLabel>
94-
</Menu.Item>
95-
</Menu.Popup>
96-
</Menu.Root>
97-
<iframe title="Secondary canvas" />
98-
<iframe
99-
title="Editor canvas"
100-
style={ { display: 'block', marginTop: 200 } }
101-
/>
102-
</>
103-
);
104-
}
105-
106-
await render( <ControlledMenuWithIframes /> );
107-
108-
const iframe =
109-
screen.getByTitle< HTMLIFrameElement >( 'Editor canvas' );
110-
const iframeDocument = iframe.contentDocument;
111-
if ( ! iframeDocument ) {
112-
throw new Error( 'Expected a same-origin iframe document.' );
113-
}
114-
const canvasTarget = iframeDocument.createElement( 'button' );
115-
canvasTarget.textContent = 'Edit block';
116-
canvasTarget.addEventListener( 'click', onCanvasClick );
117-
iframeDocument.body.appendChild( canvasTarget );
118-
119-
await user.click( screen.getByRole( 'button', { name: 'Actions' } ) );
120-
await expect.element( page.getByRole( 'menu' ) ).toBeVisible();
121-
122-
const frame = page.frameLocator( page.getByTitle( 'Editor canvas' ) );
123-
await frame.getByRole( 'button', { name: 'Edit block' } ).click();
124-
125-
await waitFor( () => {
126-
expect( screen.queryByRole( 'menu' ) ).not.toBeInTheDocument();
127-
} );
128-
expect( onCanvasClick ).toHaveBeenCalledTimes( 1 );
129-
} );
130-
13176
it( 'keeps prefix icons hidden from assistive technology', async () => {
13277
const user = userEvent;
13378
await render(

0 commit comments

Comments
 (0)