Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2f6cf38
Fix workspace keyboard focus not returning to 3-dot trigger after Back
Krishna2323 Jul 27, 2026
8160c2f
Fix focus return to Overview Name/Description rows after Back
Krishna2323 Jul 28, 2026
b25fd3c
Add ThreeDotsMenu unit tests for deferred focus restore handshake
Krishna2323 Jul 28, 2026
f6e34ed
Fix FocusTrapForModalTest unsafe type assertions for lint seatbelt
Krishna2323 Jul 28, 2026
bc52a32
Prefer registered ThreeDots launcher over nested menu focus
Krishna2323 Jul 28, 2026
3934675
Only reuse stack launcher when focus is already inside the trap
Krishna2323 Jul 28, 2026
cff4130
Use display:contents host for FocusTrap launcher container
Krishna2323 Jul 28, 2026
716191e
Skip ThreeDots anchor restore on Safari immediate selection path
Krishna2323 Jul 28, 2026
e89efd9
Restore More-button focus after ButtonWithDropdownMenu navigation
Krishna2323 Jul 28, 2026
f2a933a
Add BaseModal coverage for modal-hide callback ordering
Krishna2323 Jul 29, 2026
cfdbf44
Merge branch 'Expensify:main' into krishna2323/issue/96971
Krishna2323 Aug 3, 2026
6d39781
Merge branch 'main' into krishna2323/issue/96971
Krishna2323 Aug 5, 2026
0f43f91
fix: latch the Enter/Space activation target so autofocused pages don…
Krishna2323 Aug 9, 2026
1faab55
Merge branch 'Expensify:main' into krishna2323/issue/96971
Krishna2323 Aug 9, 2026
efaa3d6
Merge branch 'main' into krishna2323/issue/96971
Krishna2323 Aug 11, 2026
15fd9ac
merge main and resolve conflicts.
Krishna2323 Aug 18, 2026
ceed731
fix: restore anchor focus when esc is pressed.
Krishna2323 Aug 18, 2026
ccf49bd
refactor.
Krishna2323 Aug 18, 2026
c6a429a
fix: return focus to the ⋯ trigger when a popover menu is dismissed
Krishna2323 Aug 18, 2026
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
11 changes: 10 additions & 1 deletion src/components/PopoverMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ type PopoverMenuProps = Partial<ModalAnimationProps> & {
* */
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'];

Expand Down Expand Up @@ -326,6 +333,7 @@ function BasePopoverMenu({
withoutOverlay = false,
shouldSetModalVisibility = true,
shouldEnableNewFocusManagement,
shouldReturnFocus,
restoreFocusType,
shouldShowRadioButton = false,
containerStyles,
Expand Down Expand Up @@ -699,6 +707,7 @@ function BasePopoverMenu({
withoutOverlay={withoutOverlay}
shouldSetModalVisibility={shouldSetModalVisibility}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
shouldReturnFocus={shouldReturnFocus}
restoreFocusType={restoreFocusType}
innerContainerStyle={{...styles.pv0, ...innerContainerStyle}}
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
Expand All @@ -709,7 +718,7 @@ function BasePopoverMenu({
>
<FocusTrapForModal
active={isVisible}
shouldReturnFocus={!shouldEnableNewFocusManagement}
shouldReturnFocus={shouldReturnFocus ?? !shouldEnableNewFocusManagement}
launcherRef={anchorRef}
>
<CompactMenuContext.Provider value>
Expand Down
2 changes: 2 additions & 0 deletions src/components/ThreeDotsMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
/>
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/PopoverMenuFocusReturnTest.tsx
Original file line number Diff line number Diff line change
@@ -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 `<body>` after Escape. `shouldReturnFocus` overrides that default.
*/
describe('PopoverMenu focus return', () => {
const anchorRef = createRef<View>();

function renderPopoverMenu(overrides: Partial<React.ComponentProps<typeof PopoverMenu>> = {}) {
capturedTrapProps.length = 0;
render(
<PopoverMenu
isVisible
anchorRef={anchorRef}
anchorPosition={{horizontal: 0, vertical: 0}}
menuItems={[{text: 'Duplicate workspace', onSelected: jest.fn()}]}
onClose={jest.fn()}
onItemSelected={jest.fn()}
{...overrides}
/>,
);
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);
});
});
Loading