diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index c58d4ef05d8032..2cf32c03568a60 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -24,6 +24,7 @@ - `ContentEditableControl`: Associate the label with the `contentEditable` field via `aria-labelledby` instead of an invalid `label[for]`, which triggered Chrome console errors ([#80344](https://github.com/WordPress/gutenberg/pull/80344)). - `SearchControl`: Render suffix only if there is one. ([#80356](https://github.com/WordPress/gutenberg/pull/80356), [#80406](https://github.com/WordPress/gutenberg/pull/80406)). - `ColorPicker`: Keep the visual picker in native HSVA so gradient/controlled HSLA echoes no longer jitter the saturation pointer, and preserve the black-edge saturation coordinate without leaving white at a chromatic position ([#80205](https://github.com/WordPress/gutenberg/pull/80205)). +- `Menu`: Focus the trigger before the popover is removed from the DOM so that a Modal's `useFocusReturn` captures the trigger, not ``. ([#80735](https://github.com/WordPress/gutenberg/pull/80735)). ### TypeScript diff --git a/packages/components/src/menu/popover.tsx b/packages/components/src/menu/popover.tsx index a4fb2a3a61fa53..a06fe85591e536 100644 --- a/packages/components/src/menu/popover.tsx +++ b/packages/components/src/menu/popover.tsx @@ -11,6 +11,8 @@ import { useMemo, forwardRef, useCallback, + useRef, + useInsertionEffect, } from '@wordpress/element'; /** @@ -27,6 +29,25 @@ export const Popover = forwardRef< >( function Popover( { gutter, shift, modal = true, ...otherProps }, ref ) { const menuContext = useContext( Context ); + const open = Ariakit.useStoreState( menuContext?.store, 'open' ); + const disclosureElement = Ariakit.useStoreState( + menuContext?.store, + 'disclosureElement' + ); + + // Focus the trigger before the popover is removed from the DOM so that + // any `useFocusReturn` ref callback (which fires after the DOM mutations + // in the same commit) captures the trigger, not . + const prevOpenRef = useRef( open ); + useInsertionEffect( () => { + const wasOpen = prevOpenRef.current; + prevOpenRef.current = open; + + if ( wasOpen && ! open && disclosureElement ) { + disclosureElement.focus(); + } + }, [ open, disclosureElement ] ); + // Extract the side from the applied placement — useful for animations. // Using `currentPlacement` instead of `placement` to make sure that we // use the final computed placement (including "flips" etc). diff --git a/packages/components/src/menu/test/index.tsx b/packages/components/src/menu/test/index.tsx index b8f5092e18ab45..5615371ed537f4 100644 --- a/packages/components/src/menu/test/index.tsx +++ b/packages/components/src/menu/test/index.tsx @@ -13,6 +13,8 @@ import { useState } from '@wordpress/element'; * Internal dependencies */ import { Menu } from '..'; +import Modal from '../../modal'; +import Button from '../../button'; const waitForFocusedMenu = () => waitFor( () => expect( screen.getByRole( 'menu' ) ).toHaveFocus() ); @@ -233,6 +235,82 @@ describe( 'Menu', () => { ); } ); + // See: https://github.com/WordPress/gutenberg/issues/80734 + it( 'should return focus to the trigger button after closing a modal opened from a menu item', async () => { + const FocusRestorationTest = () => { + const [ isModalOpen, setIsModalOpen ] = useState( false ); + return ( + <> + + } + > + Open dropdown + + + setIsModalOpen( true ) } + > + Open modal + + + + { isModalOpen && ( + setIsModalOpen( false ) } + > +

Modal content

+ +
+ ) } + + ); + }; + + render( ); + + const trigger = screen.getByRole( 'button', { + name: 'Open dropdown', + } ); + + // Open the menu + await user.click( trigger ); + await waitForFocusedMenu(); + + // Click the menu item to open the modal. + // `hideOnClick` is `true` by default, so the menu will close. + await user.click( + screen.getByRole( 'menuitem', { name: 'Open modal' } ) + ); + + // Menu should be closed + await waitFor( () => + expect( screen.queryByRole( 'menu' ) ).not.toBeInTheDocument() + ); + + // Modal should be open + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + + // Close the modal + await user.click( + screen.getByRole( 'button', { name: 'Close modal' } ) + ); + + // Modal should be closed + await waitFor( () => + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument() + ); + + // Focus should be back on the trigger button + await waitFor( () => expect( trigger ).toHaveFocus() ); + } ); + it( 'should close when clicking outside of the content', async () => { render(