diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 23e6a7768838..3f9c789b9d5f 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -154,6 +154,13 @@ type PopoverMenuProps = Partial & { * */ shouldEnableNewFocusManagement?: boolean; + /** + * Whether to return focus to the trigger when the menu is dismissed without navigating. + * Defaults to the inverse of `shouldEnableNewFocusManagement`, because that manager owns the restore when it is on. + * Set it explicitly for a trigger the manager cannot restore — it only tracks text inputs, so a button is left with nothing to return to. + */ + shouldReturnFocus?: boolean; + /** How to re-focus after the modal is dismissed */ restoreFocusType?: BaseModalProps['restoreFocusType']; @@ -326,6 +333,7 @@ function BasePopoverMenu({ withoutOverlay = false, shouldSetModalVisibility = true, shouldEnableNewFocusManagement, + shouldReturnFocus, restoreFocusType, shouldShowRadioButton = false, containerStyles, @@ -699,6 +707,7 @@ function BasePopoverMenu({ withoutOverlay={withoutOverlay} shouldSetModalVisibility={shouldSetModalVisibility} shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} + shouldReturnFocus={shouldReturnFocus} restoreFocusType={restoreFocusType} innerContainerStyle={{...styles.pv0, ...innerContainerStyle}} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} @@ -709,7 +718,7 @@ function BasePopoverMenu({ > diff --git a/src/components/ThreeDotsMenu/index.tsx b/src/components/ThreeDotsMenu/index.tsx index 5245e35be926..261aa98a6eed 100644 --- a/src/components/ThreeDotsMenu/index.tsx +++ b/src/components/ThreeDotsMenu/index.tsx @@ -215,6 +215,8 @@ function ThreeDotsMenu({ shouldSetModalVisibility={shouldSetModalVisibility} anchorRef={buttonRef} shouldEnableNewFocusManagement + // The button blurs itself before opening and is not a text input, so ComposerFocusManager has nothing to restore — the trap has to return focus. + shouldReturnFocus restoreFocusType={restoreFocusType} enableEdgeToEdgeBottomSafeAreaPadding /> diff --git a/tests/unit/PopoverMenuFocusReturnTest.tsx b/tests/unit/PopoverMenuFocusReturnTest.tsx new file mode 100644 index 000000000000..d8e496423257 --- /dev/null +++ b/tests/unit/PopoverMenuFocusReturnTest.tsx @@ -0,0 +1,77 @@ +import {render} from '@testing-library/react-native'; + +import type FocusTrapForModalProps from '@components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps'; +import PopoverMenu from '@components/PopoverMenu'; + +import CONST from '@src/CONST'; + +import type {View} from 'react-native'; + +import React, {createRef} from 'react'; + +const capturedTrapProps: FocusTrapForModalProps[] = []; + +jest.mock('@components/FocusTrap/FocusTrapForModal', () => ({ + __esModule: true, + default: (props: FocusTrapForModalProps) => { + capturedTrapProps.push(props); + return props.children; + }, +})); + +// The real popover measures layout and mounts a Modal; neither matters for the focus-return wiring under test. +jest.mock('@components/PopoverWithMeasuredContent', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +/** + * `shouldEnableNewFocusManagement` hands the focus return to ComposerFocusManager, which only ever saves and restores + * *inputs*. A popover anchored to a button — the ⋯ trigger, which also blurs itself before opening — has no input to + * restore, so the trap standing down left focus on `` after Escape. `shouldReturnFocus` overrides that default. + */ +describe('PopoverMenu focus return', () => { + const anchorRef = createRef(); + + function renderPopoverMenu(overrides: Partial> = {}) { + capturedTrapProps.length = 0; + render( + , + ); + return capturedTrapProps.at(-1); + } + + it('returns focus when the caller opts in, even with the new focus manager on', () => { + expect(renderPopoverMenu({shouldEnableNewFocusManagement: true, shouldReturnFocus: true})?.shouldReturnFocus).toBe(true); + }); + + it('keeps the derived default when the prop is omitted, so other callers are unchanged', () => { + expect(renderPopoverMenu({shouldEnableNewFocusManagement: true})?.shouldReturnFocus).toBe(false); + expect(renderPopoverMenu()?.shouldReturnFocus).toBe(true); + }); + + it('honors an explicit opt-out', () => { + expect(renderPopoverMenu({shouldReturnFocus: false})?.shouldReturnFocus).toBe(false); + }); + + it('passes the anchor as the launcher, so a trigger that blurs itself is still restorable', () => { + expect(renderPopoverMenu({shouldEnableNewFocusManagement: true, shouldReturnFocus: true})?.launcherRef).toBe(anchorRef); + }); + + it('opts in regardless of restoreFocusType, which only governs ComposerFocusManager', () => { + const props = renderPopoverMenu({ + shouldEnableNewFocusManagement: true, + shouldReturnFocus: true, + restoreFocusType: CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE, + }); + expect(props?.shouldReturnFocus).toBe(true); + }); +});