From d26c71b371be361f0877765e667bb88e9b9dbed0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 3 Aug 2026 16:20:10 +0530 Subject: [PATCH 1/7] Fix keyboard focus not returning to FAB and composer + after Back Signed-off-by: krishna2323 --- src/components/FloatingActionButton.tsx | 9 + src/libs/LauncherStack.ts | 20 +- .../AttachmentPickerWithMenuItems.tsx | 19 ++ .../FABPopoverContent/FABPopoverMenu.tsx | 11 +- tests/ui/FABPopoverMenuLauncherTest.tsx | 68 ++++++ tests/ui/FloatingActionButtonLauncherTest.tsx | 90 ++++++++ .../AttachmentPickerMenuLauncherTest.tsx | 193 ++++++++++++++++++ tests/unit/LauncherStackTest.ts | 25 ++- 8 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 tests/ui/FABPopoverMenuLauncherTest.tsx create mode 100644 tests/ui/FloatingActionButtonLauncherTest.tsx create mode 100644 tests/ui/components/AttachmentPickerMenuLauncherTest.tsx diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index f7df8c1f54c2..ac7cb9387ee2 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -4,6 +4,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; import variables from '@styles/variables'; @@ -80,6 +81,14 @@ function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabe }); const toggleFabAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => { + // Register before the blur below — FocusTrapForModal.onActivate only reads document.activeElement, which is + // body once we blur, so without this NavigationFocusReturn has no launcher to restore on Back. + if (!isActive) { + const launcher = resolvePopoverLauncherElement(fabPressable); + if (launcher) { + setActivePopoverLauncher(launcher); + } + } // Drop focus to avoid blue focus ring. fabPressable.current?.blur(); onPress(event); diff --git a/src/libs/LauncherStack.ts b/src/libs/LauncherStack.ts index 2de159227450..795dd7cab240 100644 --- a/src/libs/LauncherStack.ts +++ b/src/libs/LauncherStack.ts @@ -2,15 +2,33 @@ * Stack of popover/modal launcher elements — the element that opened a focus trap. Top is the most recent. * pickLauncher prefers the topmost active entry, else the most recent deactivated-within-LAUNCHER_CLEAR_DELAY_MS. */ +import type {RefObject} from 'react'; + import {LAUNCHER_CLEAR_DELAY_MS, LAUNCHER_STACK_MAX} from './focusReturnTimings'; // deactivatedAt is set on trap close; entry lives LAUNCHER_CLEAR_DELAY_MS so deferred-nav popovers can still consume it. type LauncherEntry = {element: HTMLElement; deactivatedAt?: number}; +/** Trigger refs come in RN (`View`, `Text`) and DOM flavors depending on the component, so stay agnostic and let the resolver narrow. */ +type PopoverLauncherRef = RefObject; + // Stack (not slot) so nested + sequential traps retain correct launcher context. const launcherStack: LauncherEntry[] = []; let hasWarnedAboutOverflow = false; +/** Resolve a RN View ref to its web host node for LauncherStack registration. No-op on native. */ +function resolvePopoverLauncherElement(ref: PopoverLauncherRef | null | undefined): HTMLElement | null { + if (typeof document === 'undefined' || !ref?.current) { + return null; + } + // On web, RN View refs are DOM nodes; instanceof avoids an unsafe cast. + const node = ref.current; + if (!(node instanceof HTMLElement) || !document.contains(node)) { + return null; + } + return node; +} + // Two passes so nested traps resolve to the outer (active) launcher, not the just-closed inner. function pickLauncher(): HTMLElement | null { if (typeof document === 'undefined') { @@ -97,4 +115,4 @@ function resetLauncherStackForTests(): void { hasWarnedAboutOverflow = false; } -export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests}; +export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests, resolvePopoverLauncherElement}; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 466aadb383ff..428af10b32fd 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -24,6 +24,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isSafari} from '@libs/Browser'; import getIconForAction from '@libs/getIconForAction'; +import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; import {canCreateTaskInReport, getPayeeName, hasViolations as hasViolationsReportUtils, isPolicyExpenseChat, isReportOwner, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; @@ -467,6 +468,15 @@ function AttachmentPickerWithMenuItems({ } onAddActionPressed(); + // Register before the blur below — FocusTrapForModal.onActivate only reads + // document.activeElement, which is body once we blur, so without this + // NavigationFocusReturn has no launcher to restore on Back. + if (!isMenuVisible) { + const launcher = resolvePopoverLauncherElement(actionButtonRef); + if (launcher) { + setActivePopoverLauncher(launcher); + } + } // Drop focus to avoid blue focus ring. actionButtonRef.current?.blur(); setMenuVisibility(!isMenuVisible); @@ -515,6 +525,15 @@ function AttachmentPickerWithMenuItems({ }); } }} + onModalHide={() => { + // The create button registers itself as the launcher before opening this menu. Deactivating on hide + // lets the entry expire when the menu closes without navigating, so it can't be picked up as a stale + // launcher later. + const launcher = resolvePopoverLauncherElement(actionButtonRef); + if (launcher) { + markActivePopoverLauncherDeactivated(launcher); + } + }} anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} anchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index 5ab0c6f4462a..fc2b0a323de4 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -9,6 +9,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {close} from '@libs/actions/Modal'; import {isSafari} from '@libs/Browser'; +import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement} from '@libs/LauncherStack'; import CONST from '@src/CONST'; @@ -119,7 +120,15 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio onClose={handleClose} isVisible={isVisible} onModalWillShow={() => setContentActivityMode('visible')} - onModalHide={() => setContentActivityMode('hidden')} + onModalHide={() => { + setContentActivityMode('hidden'); + // The FAB registers itself as the launcher before opening this menu. Deactivating on hide lets the + // entry expire when the menu closes without navigating, so it can't be picked up as a stale launcher later. + const launcher = resolvePopoverLauncherElement(anchorRef); + if (launcher) { + markActivePopoverLauncherDeactivated(launcher); + } + }} fromSidebarMediumScreen={!shouldUseNarrowLayout} animationIn="fadeIn" animationOut="fadeOut" diff --git a/tests/ui/FABPopoverMenuLauncherTest.tsx b/tests/ui/FABPopoverMenuLauncherTest.tsx new file mode 100644 index 000000000000..09aaacbbd6a1 --- /dev/null +++ b/tests/ui/FABPopoverMenuLauncherTest.tsx @@ -0,0 +1,68 @@ +import {act, render} from '@testing-library/react-native'; + +import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement} from '@libs/LauncherStack'; + +import FABPopoverMenu from '@pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu'; + +import React from 'react'; + +jest.mock('@libs/LauncherStack', () => ({ + resolvePopoverLauncherElement: jest.fn(), + setActivePopoverLauncher: jest.fn(), + markActivePopoverLauncherDeactivated: jest.fn(), + pickLauncher: jest.fn(() => null), + consumeLauncher: jest.fn(), + resetLauncherStackForTests: jest.fn(), +})); + +const latestPopoverProps: {current: {onModalHide?: () => void} | null} = {current: null}; + +jest.mock('@components/PopoverWithMeasuredContent', () => (props: {onModalHide?: () => void}) => { + latestPopoverProps.current = props; + return null; +}); + +const mockAnchor = document.createElement('div'); + +function renderFABMenu() { + const anchorRef = React.createRef(); + return render( + + {null} + , + ); +} + +describe('FABPopoverMenu launcher deactivation', () => { + beforeEach(() => { + latestPopoverProps.current = null; + jest.clearAllMocks(); + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); + }); + + it('deactivates the FAB launcher entry once the menu is hidden', () => { + renderFABMenu(); + + act(() => { + latestPopoverProps.current?.onModalHide?.(); + }); + + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalledWith(mockAnchor); + }); + + it('does not deactivate anything on hide when the anchor has no host node (native)', () => { + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); + renderFABMenu(); + + act(() => { + latestPopoverProps.current?.onModalHide?.(); + }); + + expect(markActivePopoverLauncherDeactivated).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/FloatingActionButtonLauncherTest.tsx b/tests/ui/FloatingActionButtonLauncherTest.tsx new file mode 100644 index 000000000000..c1d1a39738f0 --- /dev/null +++ b/tests/ui/FloatingActionButtonLauncherTest.tsx @@ -0,0 +1,90 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import FloatingActionButton from '@components/FloatingActionButton'; + +import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; + +import CONST from '@src/CONST'; + +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; + +// FloatingActionButton relies on ProductTrainingContext, so provide a minimal mock. +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: (): { + renderProductTrainingTooltip: () => null; + shouldShowProductTrainingTooltip: boolean; + hideProductTrainingTooltip: () => void; + } => ({ + renderProductTrainingTooltip: () => null, + shouldShowProductTrainingTooltip: false, + hideProductTrainingTooltip: () => {}, + }), +})); + +// useResponsiveLayout determines LHB visibility. The manual mock pins a wide layout, keeping behaviour deterministic. +jest.mock('@hooks/useResponsiveLayout'); + +jest.mock('@libs/LauncherStack', () => ({ + resolvePopoverLauncherElement: jest.fn(), + setActivePopoverLauncher: jest.fn(), + markActivePopoverLauncherDeactivated: jest.fn(), + pickLauncher: jest.fn(() => null), + consumeLauncher: jest.fn(), + resetLauncherStackForTests: jest.fn(), +})); + +const mockAnchor = document.createElement('button'); + +describe('FloatingActionButton launcher registration', () => { + const onPress = jest.fn(); + + const renderFAB = (isActive: boolean) => + render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); + }); + + it('registers the FAB as the launcher before opening the menu', () => { + renderFAB(false); + + fireEvent.press(screen.getByTestId('floating-action-button')); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(mockAnchor); + // The registration must happen before onPress opens the popover — the focus trap activates with a blurred + // (body) activeElement, so it can only find the launcher if it is already on the stack. + const registerOrder = jest.mocked(setActivePopoverLauncher).mock.invocationCallOrder.at(0) ?? Infinity; + const openOrder = onPress.mock.invocationCallOrder.at(0) ?? -Infinity; + expect(registerOrder).toBeLessThan(openOrder); + }); + + it('does not register a launcher when the press closes an already-open menu', () => { + renderFAB(true); + + fireEvent.press(screen.getByTestId('floating-action-button')); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalled(); + }); + + it('does not register anything when the anchor has no host node (native)', () => { + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); + renderFAB(false); + + fireEvent.press(screen.getByTestId('floating-action-button')); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + expect(onPress).toHaveBeenCalled(); + }); +}); diff --git a/tests/ui/components/AttachmentPickerMenuLauncherTest.tsx b/tests/ui/components/AttachmentPickerMenuLauncherTest.tsx new file mode 100644 index 000000000000..9b9d6d7bc58a --- /dev/null +++ b/tests/ui/components/AttachmentPickerMenuLauncherTest.tsx @@ -0,0 +1,193 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {PopoverMenuProps} from '@components/PopoverMenu'; + +import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; + +import AttachmentPickerWithMenuItems from '@pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Report} from '@src/types/onyx'; + +import type * as NativeNavigation from '@react-navigation/native'; +import type {View} from 'react-native'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {translateLocal} from '../../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), + useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + isTopmostRouteModalScreen: jest.fn(() => false), +})); + +jest.mock('@libs/LauncherStack', () => ({ + resolvePopoverLauncherElement: jest.fn(), + setActivePopoverLauncher: jest.fn(), + markActivePopoverLauncherDeactivated: jest.fn(), + pickLauncher: jest.fn(() => null), + consumeLauncher: jest.fn(), + resetLauncherStackForTests: jest.fn(), +})); + +const latestPopoverProps: {current: PopoverMenuProps | null} = {current: null}; + +jest.mock('@components/PopoverMenu', () => (props: PopoverMenuProps) => { + latestPopoverProps.current = props; + return null; +}); + +jest.mock( + '@components/AttachmentPicker', + () => + ({children}: {children: (args: {openPicker: () => void}) => React.ReactNode}) => + children({openPicker: jest.fn()}), +); + +const CURRENT_USER_ACCOUNT_ID = 1; +const CURRENT_USER_EMAIL = 'user@test.com'; +const MOCK_POLICY_ID = 'policy-123'; +const MOCK_REPORT_ID = 'report-456'; + +const MOCK_REPORT: Report = { + reportID: MOCK_REPORT_ID, + policyID: MOCK_POLICY_ID, + ownerAccountID: CURRENT_USER_ACCOUNT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, +}; + +const MOCK_PERSONAL_DETAILS: PersonalDetails = { + accountID: CURRENT_USER_ACCOUNT_ID, + login: CURRENT_USER_EMAIL, + displayName: 'Test User', +}; + +const mockAnchor = document.createElement('button'); + +function renderComponent(isMenuVisible: boolean) { + const actionButtonRef = React.createRef(); + return render( + + + , + ); +} + +function pressCreateButton() { + fireEvent.press(screen.getByLabelText(translateLocal('accessibilityHints.openActionsMenu'))); +} + +describe('AttachmentPickerWithMenuItems launcher registration', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + latestPopoverProps.current = null; + jest.clearAllMocks(); + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); + await act(async () => { + await Onyx.merge(ONYXKEYS.SESSION, { + accountID: CURRENT_USER_ACCOUNT_ID, + email: CURRENT_USER_EMAIL, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, { + id: MOCK_POLICY_ID, + name: 'Test Workspace', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + isPolicyExpenseChatEnabled: true, + pendingAction: null, + owner: CURRENT_USER_EMAIL, + outputCurrency: CONST.CURRENCY.USD, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${MOCK_REPORT_ID}`, MOCK_REPORT); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + await waitForBatchedUpdatesWithAct(); + }); + + it('registers the create button as the launcher when opening the menu', async () => { + renderComponent(false); + await waitForBatchedUpdatesWithAct(); + + pressCreateButton(); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(mockAnchor); + }); + + it('does not register a launcher when the press closes an already-open menu', async () => { + renderComponent(true); + await waitForBatchedUpdatesWithAct(); + + pressCreateButton(); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + }); + + it('deactivates the launcher entry once the menu is hidden', async () => { + renderComponent(true); + await waitForBatchedUpdatesWithAct(); + + act(() => { + latestPopoverProps.current?.onModalHide?.(); + }); + + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalledWith(mockAnchor); + }); + + it('does not deactivate anything on hide when the anchor has no host node (native)', async () => { + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); + renderComponent(true); + await waitForBatchedUpdatesWithAct(); + + act(() => { + latestPopoverProps.current?.onModalHide?.(); + }); + + expect(markActivePopoverLauncherDeactivated).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/LauncherStackTest.ts b/tests/unit/LauncherStackTest.ts index 43f07f996cf2..3515b892a209 100644 --- a/tests/unit/LauncherStackTest.ts +++ b/tests/unit/LauncherStackTest.ts @@ -1,10 +1,11 @@ // Typed require with explicit .ts path — matches the project's test-file convention. -const {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests} = require<{ +const {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests, resolvePopoverLauncherElement} = require<{ pickLauncher: () => HTMLElement | null; consumeLauncher: (element: HTMLElement) => void; setActivePopoverLauncher: (element: HTMLElement) => void; markActivePopoverLauncherDeactivated: (element?: HTMLElement) => void; resetLauncherStackForTests: () => void; + resolvePopoverLauncherElement: (ref: {current: unknown} | null | undefined) => HTMLElement | null; }>('../../src/libs/LauncherStack.ts'); function appendButton(): HTMLButtonElement { @@ -179,4 +180,26 @@ describe('LauncherStack', () => { expect(pickLauncher()).toBeNull(); }); }); + + describe('resolvePopoverLauncherElement', () => { + it('returns the host node for an attached ref', () => { + const button = appendButton(); + expect(resolvePopoverLauncherElement({current: button})).toBe(button); + }); + + it('returns null for an empty or missing ref', () => { + expect(resolvePopoverLauncherElement(null)).toBeNull(); + expect(resolvePopoverLauncherElement(undefined)).toBeNull(); + expect(resolvePopoverLauncherElement({current: null})).toBeNull(); + }); + + it('returns null for a detached node — a launcher outside the document can never receive focus', () => { + const detached = document.createElement('button'); + expect(resolvePopoverLauncherElement({current: detached})).toBeNull(); + }); + + it('returns null for a non-DOM ref value (native View instance)', () => { + expect(resolvePopoverLauncherElement({current: {measure: () => {}}})).toBeNull(); + }); + }); }); From 24e43a710584b1db9abf0ec6424eda8e6cfd3566 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 3 Aug 2026 17:02:33 +0530 Subject: [PATCH 2/7] Gate FAB launcher registration on the menu actually opening Signed-off-by: krishna2323 --- src/components/FloatingActionButton.tsx | 9 -- .../FloatingActionButtonAndPopover.tsx | 8 ++ ...tingActionButtonAndPopoverLauncherTest.tsx | 117 ++++++++++++++++++ tests/ui/FloatingActionButtonLauncherTest.tsx | 90 -------------- 4 files changed, 125 insertions(+), 99 deletions(-) create mode 100644 tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx delete mode 100644 tests/ui/FloatingActionButtonLauncherTest.tsx diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index ac7cb9387ee2..f7df8c1f54c2 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -4,7 +4,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; import variables from '@styles/variables'; @@ -81,14 +80,6 @@ function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabe }); const toggleFabAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => { - // Register before the blur below — FocusTrapForModal.onActivate only reads document.activeElement, which is - // body once we blur, so without this NavigationFocusReturn has no launcher to restore on Back. - if (!isActive) { - const launcher = resolvePopoverLauncherElement(fabPressable); - if (launcher) { - setActivePopoverLauncher(launcher); - } - } // Drop focus to avoid blue focus ring. fabPressable.current?.blur(); onPress(event); diff --git a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx index 3d70c91acd3b..98c51acc3df6 100644 --- a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx @@ -2,6 +2,7 @@ import useDragoverDismiss from '@hooks/useDragoverDismiss'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; import {generateReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -39,6 +40,13 @@ function FloatingActionButtonAndPopover() { if (!isFocused && shouldUseNarrowLayout) { return; } + // The FAB blurs itself before opening, so FocusTrapForModal.onActivate only ever sees document.body and can't + // infer the launcher. Register it here — gated on the menu actually opening — so NavigationFocusReturn has + // something to restore on Back. FABPopoverMenu deactivates the entry again on hide. + const launcher = resolvePopoverLauncherElement(fabRef); + if (launcher) { + setActivePopoverLauncher(launcher); + } setIsCreateMenuActive(true); }; diff --git a/tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx b/tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx new file mode 100644 index 000000000000..2d05706c83ef --- /dev/null +++ b/tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx @@ -0,0 +1,117 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; + +import FloatingActionButtonAndPopover from '@pages/inbox/sidebar/FloatingActionButtonAndPopover'; + +import type * as NativeNavigation from '@react-navigation/native'; + +import React from 'react'; + +let mockIsFocused = true; +let mockShouldUseNarrowLayout = false; + +jest.mock('@react-navigation/native', () => ({ + ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), + useIsFocused: () => mockIsFocused, + useFocusEffect: jest.fn(), +})); + +jest.mock('@hooks/useResponsiveLayout', () => () => ({ + shouldUseNarrowLayout: mockShouldUseNarrowLayout, + isSmallScreenWidth: mockShouldUseNarrowLayout, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isExtraSmallScreenWidth: false, + isMediumScreenWidth: false, + onboardingIsMediumOrLargerScreenWidth: true, + isLargeScreenWidth: !mockShouldUseNarrowLayout, + isSmallScreen: mockShouldUseNarrowLayout, +})); + +jest.mock('@libs/LauncherStack', () => ({ + resolvePopoverLauncherElement: jest.fn(), + setActivePopoverLauncher: jest.fn(), + markActivePopoverLauncherDeactivated: jest.fn(), + pickLauncher: jest.fn(() => null), + consumeLauncher: jest.fn(), + resetLauncherStackForTests: jest.fn(), +})); + +const FAB_TEST_ID = 'mock-fab'; + +// The real FAB pulls in reanimated and Svg; a bare Pressable is enough to drive toggleCreateMenu. +jest.mock('@pages/inbox/sidebar/FABPopoverContent/FABButtons', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest.requireActual returns an untyped module + const {Pressable} = jest.requireActual('react-native'); + return ({onPress}: {onPress: () => void}) => ( + + ); +}); + +const latestMenuProps: {current: {isVisible?: boolean} | null} = {current: null}; + +jest.mock('@pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu', () => (props: {isVisible?: boolean}) => { + latestMenuProps.current = props; + return null; +}); + +const mockAnchor = document.createElement('div'); + +describe('FloatingActionButtonAndPopover launcher registration', () => { + beforeEach(() => { + mockIsFocused = true; + mockShouldUseNarrowLayout = false; + latestMenuProps.current = null; + jest.clearAllMocks(); + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); + }); + + it('registers the FAB as the launcher when the menu opens', () => { + render(); + + fireEvent.press(screen.getByTestId(FAB_TEST_ID)); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(mockAnchor); + expect(latestMenuProps.current?.isVisible).toBe(true); + }); + + it('does not register a launcher when the press closes an already-open menu', () => { + render(); + + fireEvent.press(screen.getByTestId(FAB_TEST_ID)); + jest.mocked(setActivePopoverLauncher).mockClear(); + + fireEvent.press(screen.getByTestId(FAB_TEST_ID)); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + expect(latestMenuProps.current?.isVisible).toBe(false); + }); + + it('does not register a launcher when showCreateMenu bails out on an unfocused narrow layout', () => { + // The menu never opens here, so no onModalHide would ever fire to deactivate a registered entry — + // registering anyway would leave the FAB lingering as an active launcher. + mockIsFocused = false; + mockShouldUseNarrowLayout = true; + render(); + + fireEvent.press(screen.getByTestId(FAB_TEST_ID)); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + expect(latestMenuProps.current?.isVisible).toBe(false); + }); + + it('does not register anything when the anchor has no host node (native)', () => { + jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); + render(); + + fireEvent.press(screen.getByTestId(FAB_TEST_ID)); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + expect(latestMenuProps.current?.isVisible).toBe(true); + }); +}); diff --git a/tests/ui/FloatingActionButtonLauncherTest.tsx b/tests/ui/FloatingActionButtonLauncherTest.tsx deleted file mode 100644 index c1d1a39738f0..000000000000 --- a/tests/ui/FloatingActionButtonLauncherTest.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; - -import FloatingActionButton from '@components/FloatingActionButton'; - -import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; - -import CONST from '@src/CONST'; - -import {NavigationContainer} from '@react-navigation/native'; -import React from 'react'; - -// FloatingActionButton relies on ProductTrainingContext, so provide a minimal mock. -jest.mock('@components/ProductTrainingContext', () => ({ - useProductTrainingContext: (): { - renderProductTrainingTooltip: () => null; - shouldShowProductTrainingTooltip: boolean; - hideProductTrainingTooltip: () => void; - } => ({ - renderProductTrainingTooltip: () => null, - shouldShowProductTrainingTooltip: false, - hideProductTrainingTooltip: () => {}, - }), -})); - -// useResponsiveLayout determines LHB visibility. The manual mock pins a wide layout, keeping behaviour deterministic. -jest.mock('@hooks/useResponsiveLayout'); - -jest.mock('@libs/LauncherStack', () => ({ - resolvePopoverLauncherElement: jest.fn(), - setActivePopoverLauncher: jest.fn(), - markActivePopoverLauncherDeactivated: jest.fn(), - pickLauncher: jest.fn(() => null), - consumeLauncher: jest.fn(), - resetLauncherStackForTests: jest.fn(), -})); - -const mockAnchor = document.createElement('button'); - -describe('FloatingActionButton launcher registration', () => { - const onPress = jest.fn(); - - const renderFAB = (isActive: boolean) => - render( - - - , - ); - - beforeEach(() => { - jest.clearAllMocks(); - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); - }); - - it('registers the FAB as the launcher before opening the menu', () => { - renderFAB(false); - - fireEvent.press(screen.getByTestId('floating-action-button')); - - expect(setActivePopoverLauncher).toHaveBeenCalledWith(mockAnchor); - // The registration must happen before onPress opens the popover — the focus trap activates with a blurred - // (body) activeElement, so it can only find the launcher if it is already on the stack. - const registerOrder = jest.mocked(setActivePopoverLauncher).mock.invocationCallOrder.at(0) ?? Infinity; - const openOrder = onPress.mock.invocationCallOrder.at(0) ?? -Infinity; - expect(registerOrder).toBeLessThan(openOrder); - }); - - it('does not register a launcher when the press closes an already-open menu', () => { - renderFAB(true); - - fireEvent.press(screen.getByTestId('floating-action-button')); - - expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - expect(onPress).toHaveBeenCalled(); - }); - - it('does not register anything when the anchor has no host node (native)', () => { - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); - renderFAB(false); - - fireEvent.press(screen.getByTestId('floating-action-button')); - - expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - expect(onPress).toHaveBeenCalled(); - }); -}); From 832dfc9c45ce937206d7d8397d2e0c5514688d97 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 10 Aug 2026 06:07:33 +0530 Subject: [PATCH 3/7] Return keyboard focus to the popover trigger centrally, via the focus trap Signed-off-by: krishna2323 --- .../FocusTrapForModalProps.ts | 14 + .../FocusTrap/FocusTrapForModal/index.web.tsx | 43 ++- src/components/Modal/BaseModal.tsx | 2 + .../Modal/ReanimatedModal/index.tsx | 2 + src/components/Modal/ReanimatedModal/types.ts | 11 +- src/components/Popover/index.tsx | 4 + src/components/PopoverMenu/index.tsx | 1 + .../PopoverMenu/v2/content/BaseContent.tsx | 5 +- .../PopoverWithMeasuredContent/index.tsx | 2 + .../Accessibility/blurActiveElement/index.ts | 12 + src/libs/LauncherStack.ts | 28 +- src/libs/lastTrapFocusReturn.ts | 26 ++ .../AttachmentPickerWithMenuItems.tsx | 19 -- .../FABPopoverContent/FABPopoverMenu.tsx | 12 +- .../FloatingActionButtonAndPopover.tsx | 8 - tests/ui/FABPopoverMenuLauncherTest.tsx | 68 ----- ...tingActionButtonAndPopoverLauncherTest.tsx | 117 -------- .../AttachmentPickerMenuLauncherTest.tsx | 193 ------------- tests/unit/FocusTrapForModalTest.tsx | 270 +++++++++++++++++- tests/unit/LauncherStackTest.ts | 35 +-- 20 files changed, 412 insertions(+), 460 deletions(-) create mode 100644 src/libs/lastTrapFocusReturn.ts delete mode 100644 tests/ui/FABPopoverMenuLauncherTest.tsx delete mode 100644 tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx delete mode 100644 tests/ui/components/AttachmentPickerMenuLauncherTest.tsx diff --git a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts index b5145472502b..8d269f6c6bfe 100644 --- a/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts +++ b/src/components/FocusTrap/FocusTrapForModal/FocusTrapForModalProps.ts @@ -1,4 +1,7 @@ import type {FocusTrapProps} from 'focus-trap-react'; +import type {RefObject} from 'react'; +// eslint-disable-next-line no-restricted-imports -- type-only: the launcher union must cover every anchor shape popovers pass, including RN Text anchors +import type {Text, View} from 'react-native'; type FocusTrapOptions = Exclude; @@ -8,6 +11,17 @@ type FocusTrapForModalProps = { initialFocus?: FocusTrapOptions['initialFocus']; shouldPreventScroll?: boolean; shouldReturnFocus?: boolean; + + /** + * The element that opened this trap — a popover's anchor. Only consulted when `document.activeElement` + * is `body` at activation time: triggers that blur themselves to avoid a focus ring (the FAB, the composer + * "+") leave nothing to infer the launcher from, so both the dismiss-time focus return and the nav-back + * restore have no target. Pass the same ref the popover already uses to position itself. + * + * Deliberately covers every anchor shape in use (`View`, `Text`, DOM element): the trap narrows it with + * `instanceof HTMLElement` anyway, so a ref it cannot use is simply ignored rather than rejected. + */ + launcherRef?: RefObject; }; export default FocusTrapForModalProps; diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index e320cafd34da..528a6b26df7b 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -1,5 +1,6 @@ import blurActiveElement from '@libs/Accessibility/blurActiveElement'; -import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack'; +import {clearLastTrapFocusReturn, setLastTrapFocusReturn} from '@libs/lastTrapFocusReturn'; +import {hasLauncher, markActivePopoverLauncherDeactivated, pickLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import restoreFocusWithModality from '@libs/restoreFocusWithModality'; import sharedTrapStack from '@libs/sharedTrapStack'; @@ -9,18 +10,40 @@ import React, {useRef} from 'react'; import type FocusTrapForModalProps from './FocusTrapForModalProps'; -function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true}: FocusTrapForModalProps) { +/** On web an RN `View` ref IS the DOM node, so narrow with `instanceof` rather than casting. A detached anchor can never take focus, so it is no better than nothing. */ +function resolveLauncherElement(ref: FocusTrapForModalProps['launcherRef']): HTMLElement | null { + const node = ref?.current; + if (!(node instanceof HTMLElement) || !document.contains(node)) { + return null; + } + return node; +} + +function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true, launcherRef}: FocusTrapForModalProps) { // Track this trap's own launcher so onPostDeactivate targets the right shared-stack entry. const cachedLauncherRef = useRef(null); + // How many traps were already active when we opened. focus-trap pushes us onto the stack *after* onActivate and + // removes us *before* onPostDeactivate, so comparing against this tells an ancestor trap (was already there) apart + // from one that opened on top of us while we were open. + const trapDepthAtActivateRef = useRef(0); return ( { + trapDepthAtActivateRef.current = sharedTrapStack.length; + // A new trap is opening, so the previous trap's focus return is finished and no longer needs shielding. + // Cleared before the blur below, which must still be free to drop focus from that element. + clearLastTrapFocusReturn(); // Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below). - const launcher = document.activeElement; + const activeElement = document.activeElement; blurActiveElement(); - if (launcher instanceof HTMLElement && launcher !== document.body) { + // What actually held focus wins; then the anchor, for triggers that blur themselves before opening. + // The LauncherStack is the last resort for modals with no anchor at all — the global confirm modal + // opened from a popover has neither a focused element nor an anchorRef, but the popover that opened + // it registered its own launcher, and that is the element the user came from. + const launcher = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : (resolveLauncherElement(launcherRef) ?? pickLauncher()); + if (launcher) { cachedLauncherRef.current = launcher; setActivePopoverLauncher(launcher); } @@ -31,10 +54,20 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven if (!launcher) { return; } + // A forward navigation consumes the launcher off the stack (captureTriggerForRoute), handing the + // restore to NavigationFocusReturn's Back handling. Returning focus here as well would yank it + // away from the destination screen's own autofocus — e.g. FAB > Start chat losing its search input. + const wasClaimedByNavigation = !hasLauncher(launcher); + // A trap opened on top of us and still owns focus (e.g. selecting "Create report" in the FAB menu + // opens the empty-report confirm modal). Returning focus to our launcher would pull the focus ring + // out to the FAB behind that modal, and leave the user nothing to return to when it closes. + const isCoveredByNewerTrap = sharedTrapStack.length > trapDepthAtActivateRef.current; // Mark first so a throw in restoreFocusWithModality can't leak the LauncherStack entry; the deferred clear keeps the post-hide capture window. markActivePopoverLauncherDeactivated(launcher); - if (shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { + if (!wasClaimedByNavigation && !isCoveredByNewerTrap && shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll}); + // Shield it from the modal's own hide-time blur, which runs after this on an Escape dismissal. + setLastTrapFocusReturn(launcher); } }, preventScroll: shouldPreventScroll, diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index c750415e81ea..f4280f2ecd2f 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -66,6 +66,7 @@ function BaseModal({ modalId, shouldEnableNewFocusManagement = false, shouldReturnFocus, + launcherRef, restoreFocusType, shouldUseModalPaddingStyle = true, initialFocus = false, @@ -371,6 +372,7 @@ function BaseModal({ shouldEnableNewFocusManagement={shouldEnableNewFocusManagement} supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']} shouldReturnFocus={shouldReturnFocus} + launcherRef={launcherRef} > {isVisibleState && containerView} diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts index 0ec5d9ea95ae..37266cbedad6 100644 --- a/src/components/Modal/ReanimatedModal/types.ts +++ b/src/components/Modal/ReanimatedModal/types.ts @@ -2,8 +2,9 @@ import type {FocusTrapOptions} from '@components/Modal/types'; import type CONST from '@src/CONST'; -import type {ReactNode} from 'react'; -import type {NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native'; +import type {ReactNode, RefObject} from 'react'; +// eslint-disable-next-line no-restricted-imports -- type-only: the launcher union must cover every anchor shape popovers pass, including RN Text anchors +import type {NativeSyntheticEvent, StyleProp, Text, View, ViewProps, ViewStyle} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; @@ -150,6 +151,12 @@ type ReanimatedModalProps = ViewProps & */ shouldReturnFocus?: boolean; + /** + * The element that opened this modal — a popover's anchor. Used only when nothing held focus at activation time, + * which is the case for triggers that blur themselves to avoid a focus ring (the FAB, the composer "+"). + */ + launcherRef?: RefObject; + /** Whether to ignore the back handler during transition */ shouldIgnoreBackHandlerDuringTransition?: boolean; }; diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index 47da5fdbc4fa..d7f4923d694d 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -126,6 +126,8 @@ function Popover(props: PopoverProps) { animationIn={animationIn} animationOut={animationOut} enableEdgeToEdgeBottomSafeAreaPadding={enableEdgeToEdgeBottomSafeAreaPadding} + // The anchor is the launcher fallback for triggers that blur themselves before opening. + launcherRef={props.anchorRef} />, document.body, ); @@ -157,6 +159,8 @@ function Popover(props: PopoverProps) { animationIn={animationIn} animationOut={animationOut} enableEdgeToEdgeBottomSafeAreaPadding={enableEdgeToEdgeBottomSafeAreaPadding} + // The anchor is the launcher fallback for triggers that blur themselves before opening. + launcherRef={props.anchorRef} /> ); } diff --git a/src/components/PopoverMenu/index.tsx b/src/components/PopoverMenu/index.tsx index 659076764ea7..23e6a7768838 100644 --- a/src/components/PopoverMenu/index.tsx +++ b/src/components/PopoverMenu/index.tsx @@ -710,6 +710,7 @@ function BasePopoverMenu({ - + diff --git a/src/components/PopoverWithMeasuredContent/index.tsx b/src/components/PopoverWithMeasuredContent/index.tsx index e87bbf851724..bc2882f52c9f 100644 --- a/src/components/PopoverWithMeasuredContent/index.tsx +++ b/src/components/PopoverWithMeasuredContent/index.tsx @@ -41,6 +41,8 @@ function PopoverWithMeasuredContent({shouldWrapModalChildrenInScrollViewIfBottom animationIn="slideInUp" animationOut="slideOutDown" shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode} + // The anchor is the launcher fallback for triggers that blur themselves before opening. + launcherRef={props.anchorRef} /> ); } diff --git a/src/libs/Accessibility/blurActiveElement/index.ts b/src/libs/Accessibility/blurActiveElement/index.ts index 71040ea24623..a674f5827f1a 100644 --- a/src/libs/Accessibility/blurActiveElement/index.ts +++ b/src/libs/Accessibility/blurActiveElement/index.ts @@ -1,7 +1,19 @@ +import {getLastTrapFocusReturn} from '@libs/lastTrapFocusReturn'; + +/** + * Drops focus from whatever currently holds it. + * + * Leaves alone an element a focus trap just returned focus to. A closing modal blurs focus so it can't be left on + * content that is about to unmount — but by then its trap may have already handed focus back to the launcher that + * opened it, which lives outside the modal. Blurring that would silently undo the return. + */ const blurActiveElement = () => { if (!(document.activeElement instanceof HTMLElement)) { return; } + if (document.activeElement === getLastTrapFocusReturn()) { + return; + } document.activeElement.blur(); }; diff --git a/src/libs/LauncherStack.ts b/src/libs/LauncherStack.ts index 795dd7cab240..96ef0772a13a 100644 --- a/src/libs/LauncherStack.ts +++ b/src/libs/LauncherStack.ts @@ -2,33 +2,15 @@ * Stack of popover/modal launcher elements — the element that opened a focus trap. Top is the most recent. * pickLauncher prefers the topmost active entry, else the most recent deactivated-within-LAUNCHER_CLEAR_DELAY_MS. */ -import type {RefObject} from 'react'; - import {LAUNCHER_CLEAR_DELAY_MS, LAUNCHER_STACK_MAX} from './focusReturnTimings'; // deactivatedAt is set on trap close; entry lives LAUNCHER_CLEAR_DELAY_MS so deferred-nav popovers can still consume it. type LauncherEntry = {element: HTMLElement; deactivatedAt?: number}; -/** Trigger refs come in RN (`View`, `Text`) and DOM flavors depending on the component, so stay agnostic and let the resolver narrow. */ -type PopoverLauncherRef = RefObject; - // Stack (not slot) so nested + sequential traps retain correct launcher context. const launcherStack: LauncherEntry[] = []; let hasWarnedAboutOverflow = false; -/** Resolve a RN View ref to its web host node for LauncherStack registration. No-op on native. */ -function resolvePopoverLauncherElement(ref: PopoverLauncherRef | null | undefined): HTMLElement | null { - if (typeof document === 'undefined' || !ref?.current) { - return null; - } - // On web, RN View refs are DOM nodes; instanceof avoids an unsafe cast. - const node = ref.current; - if (!(node instanceof HTMLElement) || !document.contains(node)) { - return null; - } - return node; -} - // Two passes so nested traps resolve to the outer (active) launcher, not the just-closed inner. function pickLauncher(): HTMLElement | null { if (typeof document === 'undefined') { @@ -67,6 +49,14 @@ function pickLauncher(): HTMLElement | null { return null; } +/** + * Whether `element` is still tracked. A forward navigation consumes its launcher (see captureTriggerForRoute), so a + * missing entry means navigation already claimed this launcher and owns the focus restore from here on. + */ +function hasLauncher(element: HTMLElement): boolean { + return launcherStack.some((entry) => entry.element === element); +} + function consumeLauncher(element: HTMLElement): void { const idx = launcherStack.findIndex((e) => e.element === element); if (idx >= 0) { @@ -115,4 +105,4 @@ function resetLauncherStackForTests(): void { hasWarnedAboutOverflow = false; } -export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests, resolvePopoverLauncherElement}; +export {pickLauncher, consumeLauncher, hasLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests}; diff --git a/src/libs/lastTrapFocusReturn.ts b/src/libs/lastTrapFocusReturn.ts new file mode 100644 index 000000000000..d3ab8ca188d7 --- /dev/null +++ b/src/libs/lastTrapFocusReturn.ts @@ -0,0 +1,26 @@ +/** + * The element a focus trap just handed focus back to when it closed. + * + * A closing modal blurs whatever holds focus so it can't be left on content that is about to unmount. But the trap + * returns focus to the launcher — an element *outside* the modal — and depending on how the modal was dismissed that + * return can happen first, in which case the blur silently undoes it. Escape is the clearest case: focus-trap + * deactivates on Escape (its `escapeDeactivates` default) before the app closes the modal, so the order is + * return-then-blur, where a button press gives blur-then-return. + * + * Set on a successful return, cleared when the next trap activates, so it only shields focus during that window. + */ +let lastReturnedElement: HTMLElement | null = null; + +function setLastTrapFocusReturn(element: HTMLElement): void { + lastReturnedElement = element; +} + +function clearLastTrapFocusReturn(): void { + lastReturnedElement = null; +} + +function getLastTrapFocusReturn(): HTMLElement | null { + return lastReturnedElement; +} + +export {setLastTrapFocusReturn, clearLastTrapFocusReturn, getLastTrapFocusReturn}; diff --git a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 428af10b32fd..466aadb383ff 100644 --- a/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -24,7 +24,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isSafari} from '@libs/Browser'; import getIconForAction from '@libs/getIconForAction'; -import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; import Navigation from '@libs/Navigation/Navigation'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; import {canCreateTaskInReport, getPayeeName, hasViolations as hasViolationsReportUtils, isPolicyExpenseChat, isReportOwner, temporary_getMoneyRequestOptions} from '@libs/ReportUtils'; @@ -468,15 +467,6 @@ function AttachmentPickerWithMenuItems({ } onAddActionPressed(); - // Register before the blur below — FocusTrapForModal.onActivate only reads - // document.activeElement, which is body once we blur, so without this - // NavigationFocusReturn has no launcher to restore on Back. - if (!isMenuVisible) { - const launcher = resolvePopoverLauncherElement(actionButtonRef); - if (launcher) { - setActivePopoverLauncher(launcher); - } - } // Drop focus to avoid blue focus ring. actionButtonRef.current?.blur(); setMenuVisibility(!isMenuVisible); @@ -525,15 +515,6 @@ function AttachmentPickerWithMenuItems({ }); } }} - onModalHide={() => { - // The create button registers itself as the launcher before opening this menu. Deactivating on hide - // lets the entry expire when the menu closes without navigating, so it can't be picked up as a stale - // launcher later. - const launcher = resolvePopoverLauncherElement(actionButtonRef); - if (launcher) { - markActivePopoverLauncherDeactivated(launcher); - } - }} anchorPosition={popoverAnchorPosition ?? {horizontal: 0, vertical: 0}} anchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, diff --git a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx index fc2b0a323de4..103b81dd0610 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx +++ b/src/pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu.tsx @@ -9,7 +9,6 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {close} from '@libs/actions/Modal'; import {isSafari} from '@libs/Browser'; -import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement} from '@libs/LauncherStack'; import CONST from '@src/CONST'; @@ -120,15 +119,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio onClose={handleClose} isVisible={isVisible} onModalWillShow={() => setContentActivityMode('visible')} - onModalHide={() => { - setContentActivityMode('hidden'); - // The FAB registers itself as the launcher before opening this menu. Deactivating on hide lets the - // entry expire when the menu closes without navigating, so it can't be picked up as a stale launcher later. - const launcher = resolvePopoverLauncherElement(anchorRef); - if (launcher) { - markActivePopoverLauncherDeactivated(launcher); - } - }} + onModalHide={() => setContentActivityMode('hidden')} fromSidebarMediumScreen={!shouldUseNarrowLayout} animationIn="fadeIn" animationOut="fadeOut" @@ -141,6 +132,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio diff --git a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx index 98c51acc3df6..3d70c91acd3b 100644 --- a/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/inbox/sidebar/FloatingActionButtonAndPopover.tsx @@ -2,7 +2,6 @@ import useDragoverDismiss from '@hooks/useDragoverDismiss'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; import {generateReportID} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -40,13 +39,6 @@ function FloatingActionButtonAndPopover() { if (!isFocused && shouldUseNarrowLayout) { return; } - // The FAB blurs itself before opening, so FocusTrapForModal.onActivate only ever sees document.body and can't - // infer the launcher. Register it here — gated on the menu actually opening — so NavigationFocusReturn has - // something to restore on Back. FABPopoverMenu deactivates the entry again on hide. - const launcher = resolvePopoverLauncherElement(fabRef); - if (launcher) { - setActivePopoverLauncher(launcher); - } setIsCreateMenuActive(true); }; diff --git a/tests/ui/FABPopoverMenuLauncherTest.tsx b/tests/ui/FABPopoverMenuLauncherTest.tsx deleted file mode 100644 index 09aaacbbd6a1..000000000000 --- a/tests/ui/FABPopoverMenuLauncherTest.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement} from '@libs/LauncherStack'; - -import FABPopoverMenu from '@pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu'; - -import React from 'react'; - -jest.mock('@libs/LauncherStack', () => ({ - resolvePopoverLauncherElement: jest.fn(), - setActivePopoverLauncher: jest.fn(), - markActivePopoverLauncherDeactivated: jest.fn(), - pickLauncher: jest.fn(() => null), - consumeLauncher: jest.fn(), - resetLauncherStackForTests: jest.fn(), -})); - -const latestPopoverProps: {current: {onModalHide?: () => void} | null} = {current: null}; - -jest.mock('@components/PopoverWithMeasuredContent', () => (props: {onModalHide?: () => void}) => { - latestPopoverProps.current = props; - return null; -}); - -const mockAnchor = document.createElement('div'); - -function renderFABMenu() { - const anchorRef = React.createRef(); - return render( - - {null} - , - ); -} - -describe('FABPopoverMenu launcher deactivation', () => { - beforeEach(() => { - latestPopoverProps.current = null; - jest.clearAllMocks(); - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); - }); - - it('deactivates the FAB launcher entry once the menu is hidden', () => { - renderFABMenu(); - - act(() => { - latestPopoverProps.current?.onModalHide?.(); - }); - - expect(markActivePopoverLauncherDeactivated).toHaveBeenCalledWith(mockAnchor); - }); - - it('does not deactivate anything on hide when the anchor has no host node (native)', () => { - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); - renderFABMenu(); - - act(() => { - latestPopoverProps.current?.onModalHide?.(); - }); - - expect(markActivePopoverLauncherDeactivated).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx b/tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx deleted file mode 100644 index 2d05706c83ef..000000000000 --- a/tests/ui/FloatingActionButtonAndPopoverLauncherTest.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; - -import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; - -import FloatingActionButtonAndPopover from '@pages/inbox/sidebar/FloatingActionButtonAndPopover'; - -import type * as NativeNavigation from '@react-navigation/native'; - -import React from 'react'; - -let mockIsFocused = true; -let mockShouldUseNarrowLayout = false; - -jest.mock('@react-navigation/native', () => ({ - ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), - useIsFocused: () => mockIsFocused, - useFocusEffect: jest.fn(), -})); - -jest.mock('@hooks/useResponsiveLayout', () => () => ({ - shouldUseNarrowLayout: mockShouldUseNarrowLayout, - isSmallScreenWidth: mockShouldUseNarrowLayout, - isInNarrowPaneModal: false, - isExtraSmallScreenHeight: false, - isExtraSmallScreenWidth: false, - isMediumScreenWidth: false, - onboardingIsMediumOrLargerScreenWidth: true, - isLargeScreenWidth: !mockShouldUseNarrowLayout, - isSmallScreen: mockShouldUseNarrowLayout, -})); - -jest.mock('@libs/LauncherStack', () => ({ - resolvePopoverLauncherElement: jest.fn(), - setActivePopoverLauncher: jest.fn(), - markActivePopoverLauncherDeactivated: jest.fn(), - pickLauncher: jest.fn(() => null), - consumeLauncher: jest.fn(), - resetLauncherStackForTests: jest.fn(), -})); - -const FAB_TEST_ID = 'mock-fab'; - -// The real FAB pulls in reanimated and Svg; a bare Pressable is enough to drive toggleCreateMenu. -jest.mock('@pages/inbox/sidebar/FABPopoverContent/FABButtons', () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest.requireActual returns an untyped module - const {Pressable} = jest.requireActual('react-native'); - return ({onPress}: {onPress: () => void}) => ( - - ); -}); - -const latestMenuProps: {current: {isVisible?: boolean} | null} = {current: null}; - -jest.mock('@pages/inbox/sidebar/FABPopoverContent/FABPopoverMenu', () => (props: {isVisible?: boolean}) => { - latestMenuProps.current = props; - return null; -}); - -const mockAnchor = document.createElement('div'); - -describe('FloatingActionButtonAndPopover launcher registration', () => { - beforeEach(() => { - mockIsFocused = true; - mockShouldUseNarrowLayout = false; - latestMenuProps.current = null; - jest.clearAllMocks(); - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); - }); - - it('registers the FAB as the launcher when the menu opens', () => { - render(); - - fireEvent.press(screen.getByTestId(FAB_TEST_ID)); - - expect(setActivePopoverLauncher).toHaveBeenCalledWith(mockAnchor); - expect(latestMenuProps.current?.isVisible).toBe(true); - }); - - it('does not register a launcher when the press closes an already-open menu', () => { - render(); - - fireEvent.press(screen.getByTestId(FAB_TEST_ID)); - jest.mocked(setActivePopoverLauncher).mockClear(); - - fireEvent.press(screen.getByTestId(FAB_TEST_ID)); - - expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - expect(latestMenuProps.current?.isVisible).toBe(false); - }); - - it('does not register a launcher when showCreateMenu bails out on an unfocused narrow layout', () => { - // The menu never opens here, so no onModalHide would ever fire to deactivate a registered entry — - // registering anyway would leave the FAB lingering as an active launcher. - mockIsFocused = false; - mockShouldUseNarrowLayout = true; - render(); - - fireEvent.press(screen.getByTestId(FAB_TEST_ID)); - - expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - expect(latestMenuProps.current?.isVisible).toBe(false); - }); - - it('does not register anything when the anchor has no host node (native)', () => { - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); - render(); - - fireEvent.press(screen.getByTestId(FAB_TEST_ID)); - - expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - expect(latestMenuProps.current?.isVisible).toBe(true); - }); -}); diff --git a/tests/ui/components/AttachmentPickerMenuLauncherTest.tsx b/tests/ui/components/AttachmentPickerMenuLauncherTest.tsx deleted file mode 100644 index 9b9d6d7bc58a..000000000000 --- a/tests/ui/components/AttachmentPickerMenuLauncherTest.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import {act, fireEvent, render, screen} from '@testing-library/react-native'; - -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import type {PopoverMenuProps} from '@components/PopoverMenu'; - -import {markActivePopoverLauncherDeactivated, resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack'; - -import AttachmentPickerWithMenuItems from '@pages/inbox/report/ReportActionCompose/AttachmentPickerWithMenuItems'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Report} from '@src/types/onyx'; - -import type * as NativeNavigation from '@react-navigation/native'; -import type {View} from 'react-native'; - -import React from 'react'; -import Onyx from 'react-native-onyx'; - -import {translateLocal} from '../../utils/TestHelper'; -import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; - -jest.mock('@react-navigation/native', () => ({ - ...((): typeof NativeNavigation => jest.requireActual('@react-navigation/native'))(), - useNavigation: jest.fn(() => ({navigate: jest.fn(), addListener: jest.fn(() => jest.fn())})), - useIsFocused: jest.fn(() => true), - useRoute: jest.fn(() => ({key: '', name: '', params: {reportID: '1'}})), -})); - -jest.mock('@libs/Navigation/Navigation', () => ({ - navigate: jest.fn(), - setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), - getActiveRoute: jest.fn(() => ''), - getActiveRouteWithoutParams: jest.fn(() => ''), - isNavigationReady: jest.fn(() => Promise.resolve()), - isTopmostRouteModalScreen: jest.fn(() => false), -})); - -jest.mock('@libs/LauncherStack', () => ({ - resolvePopoverLauncherElement: jest.fn(), - setActivePopoverLauncher: jest.fn(), - markActivePopoverLauncherDeactivated: jest.fn(), - pickLauncher: jest.fn(() => null), - consumeLauncher: jest.fn(), - resetLauncherStackForTests: jest.fn(), -})); - -const latestPopoverProps: {current: PopoverMenuProps | null} = {current: null}; - -jest.mock('@components/PopoverMenu', () => (props: PopoverMenuProps) => { - latestPopoverProps.current = props; - return null; -}); - -jest.mock( - '@components/AttachmentPicker', - () => - ({children}: {children: (args: {openPicker: () => void}) => React.ReactNode}) => - children({openPicker: jest.fn()}), -); - -const CURRENT_USER_ACCOUNT_ID = 1; -const CURRENT_USER_EMAIL = 'user@test.com'; -const MOCK_POLICY_ID = 'policy-123'; -const MOCK_REPORT_ID = 'report-456'; - -const MOCK_REPORT: Report = { - reportID: MOCK_REPORT_ID, - policyID: MOCK_POLICY_ID, - ownerAccountID: CURRENT_USER_ACCOUNT_ID, - type: CONST.REPORT.TYPE.EXPENSE, - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS_NUM.OPEN, - chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, -}; - -const MOCK_PERSONAL_DETAILS: PersonalDetails = { - accountID: CURRENT_USER_ACCOUNT_ID, - login: CURRENT_USER_EMAIL, - displayName: 'Test User', -}; - -const mockAnchor = document.createElement('button'); - -function renderComponent(isMenuVisible: boolean) { - const actionButtonRef = React.createRef(); - return render( - - - , - ); -} - -function pressCreateButton() { - fireEvent.press(screen.getByLabelText(translateLocal('accessibilityHints.openActionsMenu'))); -} - -describe('AttachmentPickerWithMenuItems launcher registration', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - }); - - beforeEach(async () => { - latestPopoverProps.current = null; - jest.clearAllMocks(); - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(mockAnchor); - await act(async () => { - await Onyx.merge(ONYXKEYS.SESSION, { - accountID: CURRENT_USER_ACCOUNT_ID, - email: CURRENT_USER_EMAIL, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${MOCK_POLICY_ID}`, { - id: MOCK_POLICY_ID, - name: 'Test Workspace', - type: CONST.POLICY.TYPE.TEAM, - role: CONST.POLICY.ROLE.ADMIN, - isPolicyExpenseChatEnabled: true, - pendingAction: null, - owner: CURRENT_USER_EMAIL, - outputCurrency: CONST.CURRENCY.USD, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${MOCK_REPORT_ID}`, MOCK_REPORT); - }); - await waitForBatchedUpdatesWithAct(); - }); - - afterEach(async () => { - await act(async () => { - await Onyx.clear(); - }); - await waitForBatchedUpdatesWithAct(); - }); - - it('registers the create button as the launcher when opening the menu', async () => { - renderComponent(false); - await waitForBatchedUpdatesWithAct(); - - pressCreateButton(); - - expect(setActivePopoverLauncher).toHaveBeenCalledWith(mockAnchor); - }); - - it('does not register a launcher when the press closes an already-open menu', async () => { - renderComponent(true); - await waitForBatchedUpdatesWithAct(); - - pressCreateButton(); - - expect(setActivePopoverLauncher).not.toHaveBeenCalled(); - }); - - it('deactivates the launcher entry once the menu is hidden', async () => { - renderComponent(true); - await waitForBatchedUpdatesWithAct(); - - act(() => { - latestPopoverProps.current?.onModalHide?.(); - }); - - expect(markActivePopoverLauncherDeactivated).toHaveBeenCalledWith(mockAnchor); - }); - - it('does not deactivate anything on hide when the anchor has no host node (native)', async () => { - jest.mocked(resolvePopoverLauncherElement).mockReturnValue(null); - renderComponent(true); - await waitForBatchedUpdatesWithAct(); - - act(() => { - latestPopoverProps.current?.onModalHide?.(); - }); - - expect(markActivePopoverLauncherDeactivated).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index 283f7399b387..3190d13a00be 100644 --- a/tests/unit/FocusTrapForModalTest.tsx +++ b/tests/unit/FocusTrapForModalTest.tsx @@ -2,13 +2,17 @@ import {render} from '@testing-library/react-native'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal/index.web'; -import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack'; +import {hasLauncher, markActivePopoverLauncherDeactivated, pickLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; +import sharedTrapStack from '@libs/sharedTrapStack'; import React from 'react'; jest.mock('@libs/LauncherStack', () => ({ setActivePopoverLauncher: jest.fn(), markActivePopoverLauncherDeactivated: jest.fn(), + // Still on the stack by default — i.e. the trap closed without a forward navigation consuming its launcher. + hasLauncher: jest.fn(() => true), + pickLauncher: jest.fn(() => null), })); let capturedOptions: {onActivate?: () => void; onPostDeactivate?: () => void} | null = null; @@ -22,6 +26,14 @@ jest.mock('focus-trap-react', () => ({ jest.mock('@libs/Accessibility/blurActiveElement', () => ({__esModule: true, default: jest.fn()})); +const mockSetLastTrapFocusReturn = jest.fn(); +const mockClearLastTrapFocusReturn = jest.fn(); +jest.mock('@libs/lastTrapFocusReturn', () => ({ + setLastTrapFocusReturn: (element: HTMLElement) => mockSetLastTrapFocusReturn(element), + clearLastTrapFocusReturn: () => mockClearLastTrapFocusReturn(), + getLastTrapFocusReturn: () => null, +})); + const mockRestoreFocusWithModality = jest.fn(); jest.mock('@libs/restoreFocusWithModality', () => ({ __esModule: true, @@ -48,6 +60,13 @@ describe('FocusTrapForModal — launcher capture', () => { capturedOptions = null; (setActivePopoverLauncher as jest.Mock).mockClear(); (markActivePopoverLauncherDeactivated as jest.Mock).mockClear(); + (hasLauncher as jest.Mock).mockClear(); + (hasLauncher as jest.Mock).mockReturnValue(true); + (pickLauncher as jest.Mock).mockClear(); + (pickLauncher as jest.Mock).mockReturnValue(null); + mockSetLastTrapFocusReturn.mockClear(); + mockClearLastTrapFocusReturn.mockClear(); + sharedTrapStack.length = 0; mockRestoreFocusWithModality.mockReset(); document.body.innerHTML = ''; }); @@ -122,4 +141,253 @@ describe('FocusTrapForModal — launcher capture', () => { expect(setActivePopoverLauncher).not.toHaveBeenCalled(); expect(markActivePopoverLauncherDeactivated).not.toHaveBeenCalled(); }); + + describe('launcherRef fallback', () => { + // Triggers that blur themselves to avoid a focus ring (the FAB, the composer "+") leave activeElement + // as body, so the anchor is the only thing left to identify the launcher with. + it('falls back to the anchor when activeElement is body, and returns focus to it on dismiss', () => { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(anchor); + expect(markActivePopoverLauncherDeactivated).toHaveBeenCalledWith(anchor); + expect(mockRestoreFocusWithModality).toHaveBeenCalledWith(anchor, expect.anything()); + }); + + it('prefers the element that actually held focus over the anchor', () => { + const anchor = document.createElement('button'); + const focused = document.createElement('input'); + document.body.appendChild(anchor); + document.body.appendChild(focused); + + render( + + {null} + , + ); + + withActiveElement(focused, () => { + capturedOptions?.onActivate?.(); + }); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(focused); + }); + + it('ignores an anchor that is not an attached DOM node (native ref / unmounted trigger)', () => { + const detached = document.createElement('button'); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(setActivePopoverLauncher).not.toHaveBeenCalled(); + expect(mockRestoreFocusWithModality).not.toHaveBeenCalled(); + }); + }); + + describe('navigation hand-off', () => { + // captureTriggerForRoute consumes the launcher on a forward navigation and owns the Back restore from there. + // Returning focus here too would pull it off the destination screen's autofocused input (FAB > Start chat). + it('skips the dismiss-time focus return when navigation already consumed the launcher', () => { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + (hasLauncher as jest.Mock).mockReturnValue(false); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(anchor); + expect(mockRestoreFocusWithModality).not.toHaveBeenCalled(); + }); + }); + + describe('shielding the return from the modal hide-time blur', () => { + // Escape deactivates the trap (focus-trap's escapeDeactivates default) before the app closes the modal, so the + // return runs first and ReanimatedModal's blurActiveElement would otherwise wipe it. A button press is the + // reverse order, which is why Cancel never showed this. + it('marks the returned element so the closing modal will not blur it', () => { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(mockSetLastTrapFocusReturn).toHaveBeenCalledWith(anchor); + }); + + it('does not mark anything when no focus return happened', () => { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + (hasLauncher as jest.Mock).mockReturnValue(false); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(mockSetLastTrapFocusReturn).not.toHaveBeenCalled(); + }); + + it('drops the shield when the next trap activates, so its own blur still works', () => { + render({null}); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + }); + + expect(mockClearLastTrapFocusReturn).toHaveBeenCalled(); + }); + }); + + describe('anchorless modals', () => { + // The global confirm modal (FAB > Create report > "You already have an empty report") is centered, so it has + // no anchorRef and nothing is focused when it opens. The popover that opened it registered the real trigger. + it('falls back to the LauncherStack when there is neither a focused element nor an anchor', () => { + const fab = document.createElement('button'); + document.body.appendChild(fab); + (pickLauncher as jest.Mock).mockReturnValue(fab); + + render({null}); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(fab); + expect(mockRestoreFocusWithModality).toHaveBeenCalledWith(fab, expect.anything()); + }); + + it('prefers an explicit anchor over the LauncherStack', () => { + const anchor = document.createElement('button'); + const stacked = document.createElement('button'); + document.body.appendChild(anchor); + document.body.appendChild(stacked); + (pickLauncher as jest.Mock).mockReturnValue(stacked); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + }); + + expect(setActivePopoverLauncher).toHaveBeenCalledWith(anchor); + }); + }); + + describe('covered by a newer trap', () => { + // Selecting "Create report" in the FAB menu opens a confirm modal while the menu is still closing. The menu's + // trap must not pull the focus ring back out to the FAB behind that modal. + it('skips the focus return when a trap opened on top while we were open', () => { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + // Nothing else was open when we activated... + capturedOptions?.onActivate?.(); + // ...but a modal opened on top before we finished closing (focus-trap removes us before onPostDeactivate). + sharedTrapStack.length = 1; + capturedOptions?.onPostDeactivate?.(); + }); + + expect(mockRestoreFocusWithModality).not.toHaveBeenCalled(); + }); + + it('still returns focus when only the ancestor trap we opened inside remains', () => { + const anchor = document.createElement('button'); + document.body.appendChild(anchor); + // An outer modal was already active when this popover opened, and is still active as it closes. + sharedTrapStack.length = 1; + + render( + + {null} + , + ); + + withActiveElement(document.body, () => { + capturedOptions?.onActivate?.(); + capturedOptions?.onPostDeactivate?.(); + }); + + expect(mockRestoreFocusWithModality).toHaveBeenCalledWith(anchor, expect.anything()); + }); + }); }); diff --git a/tests/unit/LauncherStackTest.ts b/tests/unit/LauncherStackTest.ts index 3515b892a209..f370d20bb348 100644 --- a/tests/unit/LauncherStackTest.ts +++ b/tests/unit/LauncherStackTest.ts @@ -1,11 +1,11 @@ // Typed require with explicit .ts path — matches the project's test-file convention. -const {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests, resolvePopoverLauncherElement} = require<{ +const {pickLauncher, consumeLauncher, hasLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests} = require<{ pickLauncher: () => HTMLElement | null; consumeLauncher: (element: HTMLElement) => void; + hasLauncher: (element: HTMLElement) => boolean; setActivePopoverLauncher: (element: HTMLElement) => void; markActivePopoverLauncherDeactivated: (element?: HTMLElement) => void; resetLauncherStackForTests: () => void; - resolvePopoverLauncherElement: (ref: {current: unknown} | null | undefined) => HTMLElement | null; }>('../../src/libs/LauncherStack.ts'); function appendButton(): HTMLButtonElement { @@ -181,25 +181,26 @@ describe('LauncherStack', () => { }); }); - describe('resolvePopoverLauncherElement', () => { - it('returns the host node for an attached ref', () => { - const button = appendButton(); - expect(resolvePopoverLauncherElement({current: button})).toBe(button); + describe('hasLauncher', () => { + it('is true for a registered launcher, active or deactivated', () => { + const a = appendButton(); + setActivePopoverLauncher(a); + expect(hasLauncher(a)).toBe(true); + markActivePopoverLauncherDeactivated(a); + expect(hasLauncher(a)).toBe(true); }); - it('returns null for an empty or missing ref', () => { - expect(resolvePopoverLauncherElement(null)).toBeNull(); - expect(resolvePopoverLauncherElement(undefined)).toBeNull(); - expect(resolvePopoverLauncherElement({current: null})).toBeNull(); + it('is false for an element that was never registered', () => { + expect(hasLauncher(appendButton())).toBe(false); }); - it('returns null for a detached node — a launcher outside the document can never receive focus', () => { - const detached = document.createElement('button'); - expect(resolvePopoverLauncherElement({current: detached})).toBeNull(); - }); - - it('returns null for a non-DOM ref value (native View instance)', () => { - expect(resolvePopoverLauncherElement({current: {measure: () => {}}})).toBeNull(); + // This is the signal FocusTrapForModal uses to tell "closed in place" from "closed because we navigated": + // captureTriggerForRoute consumes the launcher on a forward nav and owns the Back restore from then on. + it('is false once a forward navigation has consumed the launcher', () => { + const a = appendButton(); + setActivePopoverLauncher(a); + consumeLauncher(a); + expect(hasLauncher(a)).toBe(false); }); }); }); From 6a1128e8b75763ea52ec22d5196d54b1b4127335 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Mon, 10 Aug 2026 06:30:42 +0530 Subject: [PATCH 4/7] fix test file lint issue. Signed-off-by: krishna2323 --- tests/unit/FocusTrapForModalTest.tsx | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index 3190d13a00be..1761991da6a3 100644 --- a/tests/unit/FocusTrapForModalTest.tsx +++ b/tests/unit/FocusTrapForModalTest.tsx @@ -29,8 +29,12 @@ jest.mock('@libs/Accessibility/blurActiveElement', () => ({__esModule: true, def const mockSetLastTrapFocusReturn = jest.fn(); const mockClearLastTrapFocusReturn = jest.fn(); jest.mock('@libs/lastTrapFocusReturn', () => ({ - setLastTrapFocusReturn: (element: HTMLElement) => mockSetLastTrapFocusReturn(element), - clearLastTrapFocusReturn: () => mockClearLastTrapFocusReturn(), + setLastTrapFocusReturn: (element: HTMLElement): void => { + mockSetLastTrapFocusReturn(element); + }, + clearLastTrapFocusReturn: (): void => { + mockClearLastTrapFocusReturn(); + }, getLastTrapFocusReturn: () => null, })); @@ -58,12 +62,12 @@ function withActiveElement(element: HTMLElement, fn: () => T): T { describe('FocusTrapForModal — launcher capture', () => { beforeEach(() => { capturedOptions = null; - (setActivePopoverLauncher as jest.Mock).mockClear(); - (markActivePopoverLauncherDeactivated as jest.Mock).mockClear(); - (hasLauncher as jest.Mock).mockClear(); - (hasLauncher as jest.Mock).mockReturnValue(true); - (pickLauncher as jest.Mock).mockClear(); - (pickLauncher as jest.Mock).mockReturnValue(null); + jest.mocked(setActivePopoverLauncher).mockClear(); + jest.mocked(markActivePopoverLauncherDeactivated).mockClear(); + jest.mocked(hasLauncher).mockClear(); + jest.mocked(hasLauncher).mockReturnValue(true); + jest.mocked(pickLauncher).mockClear(); + jest.mocked(pickLauncher).mockReturnValue(null); mockSetLastTrapFocusReturn.mockClear(); mockClearLastTrapFocusReturn.mockClear(); sharedTrapStack.length = 0; @@ -218,7 +222,7 @@ describe('FocusTrapForModal — launcher capture', () => { it('skips the dismiss-time focus return when navigation already consumed the launcher', () => { const anchor = document.createElement('button'); document.body.appendChild(anchor); - (hasLauncher as jest.Mock).mockReturnValue(false); + jest.mocked(hasLauncher).mockReturnValue(false); render( { it('does not mark anything when no focus return happened', () => { const anchor = document.createElement('button'); document.body.appendChild(anchor); - (hasLauncher as jest.Mock).mockReturnValue(false); + jest.mocked(hasLauncher).mockReturnValue(false); render( { it('falls back to the LauncherStack when there is neither a focused element nor an anchor', () => { const fab = document.createElement('button'); document.body.appendChild(fab); - (pickLauncher as jest.Mock).mockReturnValue(fab); + jest.mocked(pickLauncher).mockReturnValue(fab); render({null}); @@ -321,7 +325,7 @@ describe('FocusTrapForModal — launcher capture', () => { const stacked = document.createElement('button'); document.body.appendChild(anchor); document.body.appendChild(stacked); - (pickLauncher as jest.Mock).mockReturnValue(stacked); + jest.mocked(pickLauncher).mockReturnValue(stacked); render( Date: Mon, 10 Aug 2026 06:35:47 +0530 Subject: [PATCH 5/7] fix cspell failed check. Signed-off-by: krishna2323 --- cspell.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cspell.json b/cspell.json index 79157b793bbd..8ec4d4d44edb 100644 --- a/cspell.json +++ b/cspell.json @@ -448,6 +448,7 @@ "americanexpressfdx", "bankofamerica", "Amina", + "anchorless", "androiddebugkey", "androidx", "apksigner", @@ -476,6 +477,7 @@ "autocorrection", "autodocs", "autofilled", + "autofocused", "automations", "autoplay", "autoreleasepool", From 244455be51af734063d29a2a726375eae99f1e27 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 14 Aug 2026 21:00:28 +0530 Subject: [PATCH 6/7] move method to component. Signed-off-by: krishna2323 --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 85 ++++++++++--------- 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index 528a6b26df7b..ceb66b5b640a 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -26,50 +26,55 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven // removes us *before* onPostDeactivate, so comparing against this tells an ancestor trap (was already there) apart // from one that opened on top of us while we were open. const trapDepthAtActivateRef = useRef(0); + + const onFocusTrapActive = () => { + trapDepthAtActivateRef.current = sharedTrapStack.length; + // A new trap is opening, so the previous trap's focus return is finished and no longer needs shielding. + // Cleared before the blur below, which must still be free to drop focus from that element. + clearLastTrapFocusReturn(); + // Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below). + const activeElement = document.activeElement; + blurActiveElement(); + // What actually held focus wins; then the anchor, for triggers that blur themselves before opening. + // The LauncherStack is the last resort for modals with no anchor at all — the global confirm modal + // opened from a popover has neither a focused element nor an anchorRef, but the popover that opened + // it registered its own launcher, and that is the element the user came from. + const launcher = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : (resolveLauncherElement(launcherRef) ?? pickLauncher()); + if (launcher) { + cachedLauncherRef.current = launcher; + setActivePopoverLauncher(launcher); + } + }; + + const onFocusTrapPostDeactivate = () => { + const launcher = cachedLauncherRef.current; + cachedLauncherRef.current = null; + if (!launcher) { + return; + } + // A forward navigation consumes the launcher off the stack (captureTriggerForRoute), handing the + // restore to NavigationFocusReturn's Back handling. Returning focus here as well would yank it + // away from the destination screen's own autofocus — e.g. FAB > Start chat losing its search input. + const wasClaimedByNavigation = !hasLauncher(launcher); + // A trap opened on top of us and still owns focus (e.g. selecting "Create report" in the FAB menu + // opens the empty-report confirm modal). Returning focus to our launcher would pull the focus ring + // out to the FAB behind that modal, and leave the user nothing to return to when it closes. + const isCoveredByNewerTrap = sharedTrapStack.length > trapDepthAtActivateRef.current; + // Mark first so a throw in restoreFocusWithModality can't leak the LauncherStack entry; the deferred clear keeps the post-hide capture window. + markActivePopoverLauncherDeactivated(launcher); + if (!wasClaimedByNavigation && !isCoveredByNewerTrap && shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { + restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll}); + // Shield it from the modal's own hide-time blur, which runs after this on an Escape dismissal. + setLastTrapFocusReturn(launcher); + } + }; + return ( { - trapDepthAtActivateRef.current = sharedTrapStack.length; - // A new trap is opening, so the previous trap's focus return is finished and no longer needs shielding. - // Cleared before the blur below, which must still be free to drop focus from that element. - clearLastTrapFocusReturn(); - // Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below). - const activeElement = document.activeElement; - blurActiveElement(); - // What actually held focus wins; then the anchor, for triggers that blur themselves before opening. - // The LauncherStack is the last resort for modals with no anchor at all — the global confirm modal - // opened from a popover has neither a focused element nor an anchorRef, but the popover that opened - // it registered its own launcher, and that is the element the user came from. - const launcher = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : (resolveLauncherElement(launcherRef) ?? pickLauncher()); - if (launcher) { - cachedLauncherRef.current = launcher; - setActivePopoverLauncher(launcher); - } - }, - onPostDeactivate: () => { - const launcher = cachedLauncherRef.current; - cachedLauncherRef.current = null; - if (!launcher) { - return; - } - // A forward navigation consumes the launcher off the stack (captureTriggerForRoute), handing the - // restore to NavigationFocusReturn's Back handling. Returning focus here as well would yank it - // away from the destination screen's own autofocus — e.g. FAB > Start chat losing its search input. - const wasClaimedByNavigation = !hasLauncher(launcher); - // A trap opened on top of us and still owns focus (e.g. selecting "Create report" in the FAB menu - // opens the empty-report confirm modal). Returning focus to our launcher would pull the focus ring - // out to the FAB behind that modal, and leave the user nothing to return to when it closes. - const isCoveredByNewerTrap = sharedTrapStack.length > trapDepthAtActivateRef.current; - // Mark first so a throw in restoreFocusWithModality can't leak the LauncherStack entry; the deferred clear keeps the post-hide capture window. - markActivePopoverLauncherDeactivated(launcher); - if (!wasClaimedByNavigation && !isCoveredByNewerTrap && shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { - restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll}); - // Shield it from the modal's own hide-time blur, which runs after this on an Escape dismissal. - setLastTrapFocusReturn(launcher); - } - }, + onActivate: onFocusTrapActive, + onPostDeactivate: onFocusTrapPostDeactivate, preventScroll: shouldPreventScroll, trapStack: sharedTrapStack, clickOutsideDeactivates: true, From 810413655d64b389d88e6125146e9b3ab65ec1e0 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 14 Aug 2026 21:14:27 +0530 Subject: [PATCH 7/7] Blur only focus inside the closing modal instead of tracking the trap's focus return Signed-off-by: krishna2323 --- .../FocusTrap/FocusTrapForModal/index.web.tsx | 6 -- .../Modal/ReanimatedModal/index.tsx | 10 ++- src/components/Modal/ReanimatedModal/types.ts | 5 +- .../Accessibility/blurActiveElement/index.ts | 12 ---- src/libs/lastTrapFocusReturn.ts | 26 ------- tests/unit/FocusTrapForModalTest.tsx | 72 ------------------- 6 files changed, 13 insertions(+), 118 deletions(-) delete mode 100644 src/libs/lastTrapFocusReturn.ts diff --git a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx index ceb66b5b640a..4e1eaf51c06e 100644 --- a/src/components/FocusTrap/FocusTrapForModal/index.web.tsx +++ b/src/components/FocusTrap/FocusTrapForModal/index.web.tsx @@ -1,5 +1,4 @@ import blurActiveElement from '@libs/Accessibility/blurActiveElement'; -import {clearLastTrapFocusReturn, setLastTrapFocusReturn} from '@libs/lastTrapFocusReturn'; import {hasLauncher, markActivePopoverLauncherDeactivated, pickLauncher, setActivePopoverLauncher} from '@libs/LauncherStack'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import restoreFocusWithModality from '@libs/restoreFocusWithModality'; @@ -29,9 +28,6 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven const onFocusTrapActive = () => { trapDepthAtActivateRef.current = sharedTrapStack.length; - // A new trap is opening, so the previous trap's focus return is finished and no longer needs shielding. - // Cleared before the blur below, which must still be free to drop focus from that element. - clearLastTrapFocusReturn(); // Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below). const activeElement = document.activeElement; blurActiveElement(); @@ -64,8 +60,6 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven markActivePopoverLauncherDeactivated(launcher); if (!wasClaimedByNavigation && !isCoveredByNewerTrap && shouldReturnFocus && !ReportActionComposeFocusManager.isFocused() && document.contains(launcher)) { restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll}); - // Shield it from the modal's own hide-time blur, which runs after this on an Escape dismissal. - setLastTrapFocusReturn(launcher); } }; diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index 065c2e9e9f56..24d29e70b408 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -70,6 +70,7 @@ function ReanimatedModal({ const backHandlerListener = useRef(null); const handleRef = useRef(undefined); const transitionHandleRef = useRef(null); + const containerRef = useRef(null); const styles = useThemeStyles(); @@ -143,7 +144,13 @@ function ReanimatedModal({ transitionHandleRef.current = TransitionTracker.startTransition(); onModalWillHide(); - blurActiveElement(); + // Only drop focus that is inside this modal — its content is about to unmount. By now the focus trap may + // have already returned focus to the launcher that opened us, which sits outside; blurring that would + // silently undo the return (visible on Escape, where focus-trap deactivates before we close). + const container = containerRef.current; + if (container instanceof HTMLElement && container.contains(document.activeElement)) { + blurActiveElement(); + } setIsVisibleState(false); setIsTransitioning(true); } @@ -193,6 +200,7 @@ function ReanimatedModal({ const containerView = ( ; + /** This function is called by open animation callback */ onOpenCallBack: () => void; diff --git a/src/libs/Accessibility/blurActiveElement/index.ts b/src/libs/Accessibility/blurActiveElement/index.ts index a674f5827f1a..71040ea24623 100644 --- a/src/libs/Accessibility/blurActiveElement/index.ts +++ b/src/libs/Accessibility/blurActiveElement/index.ts @@ -1,19 +1,7 @@ -import {getLastTrapFocusReturn} from '@libs/lastTrapFocusReturn'; - -/** - * Drops focus from whatever currently holds it. - * - * Leaves alone an element a focus trap just returned focus to. A closing modal blurs focus so it can't be left on - * content that is about to unmount — but by then its trap may have already handed focus back to the launcher that - * opened it, which lives outside the modal. Blurring that would silently undo the return. - */ const blurActiveElement = () => { if (!(document.activeElement instanceof HTMLElement)) { return; } - if (document.activeElement === getLastTrapFocusReturn()) { - return; - } document.activeElement.blur(); }; diff --git a/src/libs/lastTrapFocusReturn.ts b/src/libs/lastTrapFocusReturn.ts deleted file mode 100644 index d3ab8ca188d7..000000000000 --- a/src/libs/lastTrapFocusReturn.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * The element a focus trap just handed focus back to when it closed. - * - * A closing modal blurs whatever holds focus so it can't be left on content that is about to unmount. But the trap - * returns focus to the launcher — an element *outside* the modal — and depending on how the modal was dismissed that - * return can happen first, in which case the blur silently undoes it. Escape is the clearest case: focus-trap - * deactivates on Escape (its `escapeDeactivates` default) before the app closes the modal, so the order is - * return-then-blur, where a button press gives blur-then-return. - * - * Set on a successful return, cleared when the next trap activates, so it only shields focus during that window. - */ -let lastReturnedElement: HTMLElement | null = null; - -function setLastTrapFocusReturn(element: HTMLElement): void { - lastReturnedElement = element; -} - -function clearLastTrapFocusReturn(): void { - lastReturnedElement = null; -} - -function getLastTrapFocusReturn(): HTMLElement | null { - return lastReturnedElement; -} - -export {setLastTrapFocusReturn, clearLastTrapFocusReturn, getLastTrapFocusReturn}; diff --git a/tests/unit/FocusTrapForModalTest.tsx b/tests/unit/FocusTrapForModalTest.tsx index 1761991da6a3..fc47e2aa2e74 100644 --- a/tests/unit/FocusTrapForModalTest.tsx +++ b/tests/unit/FocusTrapForModalTest.tsx @@ -26,18 +26,6 @@ jest.mock('focus-trap-react', () => ({ jest.mock('@libs/Accessibility/blurActiveElement', () => ({__esModule: true, default: jest.fn()})); -const mockSetLastTrapFocusReturn = jest.fn(); -const mockClearLastTrapFocusReturn = jest.fn(); -jest.mock('@libs/lastTrapFocusReturn', () => ({ - setLastTrapFocusReturn: (element: HTMLElement): void => { - mockSetLastTrapFocusReturn(element); - }, - clearLastTrapFocusReturn: (): void => { - mockClearLastTrapFocusReturn(); - }, - getLastTrapFocusReturn: () => null, -})); - const mockRestoreFocusWithModality = jest.fn(); jest.mock('@libs/restoreFocusWithModality', () => ({ __esModule: true, @@ -68,8 +56,6 @@ describe('FocusTrapForModal — launcher capture', () => { jest.mocked(hasLauncher).mockReturnValue(true); jest.mocked(pickLauncher).mockClear(); jest.mocked(pickLauncher).mockReturnValue(null); - mockSetLastTrapFocusReturn.mockClear(); - mockClearLastTrapFocusReturn.mockClear(); sharedTrapStack.length = 0; mockRestoreFocusWithModality.mockReset(); document.body.innerHTML = ''; @@ -243,64 +229,6 @@ describe('FocusTrapForModal — launcher capture', () => { }); }); - describe('shielding the return from the modal hide-time blur', () => { - // Escape deactivates the trap (focus-trap's escapeDeactivates default) before the app closes the modal, so the - // return runs first and ReanimatedModal's blurActiveElement would otherwise wipe it. A button press is the - // reverse order, which is why Cancel never showed this. - it('marks the returned element so the closing modal will not blur it', () => { - const anchor = document.createElement('button'); - document.body.appendChild(anchor); - - render( - - {null} - , - ); - - withActiveElement(document.body, () => { - capturedOptions?.onActivate?.(); - capturedOptions?.onPostDeactivate?.(); - }); - - expect(mockSetLastTrapFocusReturn).toHaveBeenCalledWith(anchor); - }); - - it('does not mark anything when no focus return happened', () => { - const anchor = document.createElement('button'); - document.body.appendChild(anchor); - jest.mocked(hasLauncher).mockReturnValue(false); - - render( - - {null} - , - ); - - withActiveElement(document.body, () => { - capturedOptions?.onActivate?.(); - capturedOptions?.onPostDeactivate?.(); - }); - - expect(mockSetLastTrapFocusReturn).not.toHaveBeenCalled(); - }); - - it('drops the shield when the next trap activates, so its own blur still works', () => { - render({null}); - - withActiveElement(document.body, () => { - capturedOptions?.onActivate?.(); - }); - - expect(mockClearLastTrapFocusReturn).toHaveBeenCalled(); - }); - }); - describe('anchorless modals', () => { // The global confirm modal (FAB > Create report > "You already have an empty report") is centered, so it has // no anchorRef and nothing is focused when it opens. The popover that opened it registered the real trigger.