From c10964f44836a07512668fab2e3cc69419620ed8 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 22 Aug 2026 01:41:51 +0200 Subject: [PATCH 1/4] Menu: Close non-modal menus on iframe interaction --- packages/ui/src/menu/root.tsx | 58 ++++- .../ui/src/menu/stories/e2e/index.story.tsx | 38 ++++ packages/ui/src/menu/test/index.test.tsx | 211 ++++++++++++++++++ .../menu/use-close-on-iframe-pointer-down.ts | 113 ++++++++++ test/storybook-playwright/specs/menu.spec.ts | 84 +++++++ 5 files changed, 500 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/menu/stories/e2e/index.story.tsx create mode 100644 packages/ui/src/menu/use-close-on-iframe-pointer-down.ts create mode 100644 test/storybook-playwright/specs/menu.spec.ts diff --git a/packages/ui/src/menu/root.tsx b/packages/ui/src/menu/root.tsx index aba7a0c3f15376..6b4d6d2081f090 100644 --- a/packages/ui/src/menu/root.tsx +++ b/packages/ui/src/menu/root.tsx @@ -1,7 +1,14 @@ import { Menu as _Menu } from '@base-ui/react/menu'; +import { + useCallback, + useImperativeHandle, + useRef, + useState, +} from '@wordpress/element'; import { DirectionProvider } from '../utils/direction-provider'; import { MenuContext } from './context'; import type { RootProps } from './types'; +import { useCloseOnIframePointerDown } from './use-close-on-iframe-pointer-down'; /** * Groups all parts of a menu. @@ -23,10 +30,42 @@ import type { RootProps } from './types'; * ``` */ function Root( props: RootProps ) { - const { onOpenChange, ...rootProps } = props; + const { + actionsRef, + defaultOpen, + modal, + onOpenChange, + open: openProp, + ...rootProps + } = props; + const internalActionsRef = useRef< _Menu.Root.Actions | null >( null ); + const [ uncontrolledOpen, setUncontrolledOpen ] = useState( + defaultOpen ?? false + ); + const [ ownerDocument, setOwnerDocument ] = useState< Document | null >( + () => ( typeof document === 'undefined' ? null : document ) + ); + const open = openProp ?? uncontrolledOpen; + const close = useCallback( () => { + internalActionsRef.current?.close(); + }, [] ); + + useImperativeHandle( + actionsRef, + () => ( { + close, + unmount: () => internalActionsRef.current?.unmount(), + } ), + [ close ] + ); + useCloseOnIframePointerDown( { + enabled: open && modal === false, + onPointerDown: close, + ownerDocument, + } ); const handleOpenChange: NonNullable< RootProps[ 'onOpenChange' ] > = ( - open, + nextOpen, eventDetails ) => { const trigger = eventDetails.trigger; @@ -40,10 +79,17 @@ function Root( props: RootProps ) { ? eventTarget.closest( '[role="menu"]' ) : null; - onOpenChange?.( open, eventDetails ); + onOpenChange?.( nextOpen, eventDetails ); + + if ( ! eventDetails.isCanceled ) { + setUncontrolledOpen( nextOpen ); + if ( nextOpen && trigger ) { + setOwnerDocument( trigger.ownerDocument ); + } + } if ( - ! open && + ! nextOpen && eventDetails.reason === 'item-press' && ! eventDetails.isCanceled && TriggerElement && @@ -67,7 +113,11 @@ function Root( props: RootProps ) { <_Menu.Root { ...rootProps } + actionsRef={ internalActionsRef } + defaultOpen={ defaultOpen } + modal={ modal } onOpenChange={ handleOpenChange } + open={ openProp } /> diff --git a/packages/ui/src/menu/stories/e2e/index.story.tsx b/packages/ui/src/menu/stories/e2e/index.story.tsx new file mode 100644 index 00000000000000..f41e90c6270e6a --- /dev/null +++ b/packages/ui/src/menu/stories/e2e/index.story.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import * as Menu from '../..'; + +const meta: Meta< typeof Menu.Root > = { + title: 'Design System/Components/Menu', + component: Menu.Root, +}; + +export default meta; + +type Story = StoryObj< typeof Menu.Root >; + +export const NonModalWithIframe: Story = { + render: () => ( + <> + + Open menu + + + First action + + + Second action + + + + +