Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`. ([#80735](https://github.com/WordPress/gutenberg/pull/80735)).

### TypeScript

Expand Down
21 changes: 21 additions & 0 deletions packages/components/src/menu/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
useMemo,
forwardRef,
useCallback,
useRef,
useInsertionEffect,
} from '@wordpress/element';

/**
Expand All @@ -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 <body>.
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).
Expand Down
78 changes: 78 additions & 0 deletions packages/components/src/menu/test/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
Expand Down Expand Up @@ -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 (
<>
<Menu>
<Menu.TriggerButton
render={ <Button __next40pxDefaultSize /> }
>
Open dropdown
</Menu.TriggerButton>
<Menu.Popover>
<Menu.Item
onClick={ () => setIsModalOpen( true ) }
>
Open modal
</Menu.Item>
</Menu.Popover>
</Menu>
{ isModalOpen && (
<Modal
onRequestClose={ () => setIsModalOpen( false ) }
>
<p>Modal content</p>
<Button
__next40pxDefaultSize
variant="primary"
onClick={ () => setIsModalOpen( false ) }
>
Close modal
</Button>
</Modal>
) }
</>
);
};

render( <FocusRestorationTest /> );

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(
<Menu defaultOpen>
Expand Down
Loading