Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cc6d020
fix: ensure focus restoration on back navigation (#76921)
mavrickdeveloper Jan 18, 2026
383fd2a
refactor: use state-based validation for focus restoration (#76921)
mavrickdeveloper Jan 21, 2026
ec56985
fix(ConfirmModal): defer keyboard check to trap activation time
mavrickdeveloper Jan 22, 2026
170a84c
fix(NavigationFocusManager): prioritize exact text matches over prefi…
mavrickdeveloper Jan 23, 2026
b31475f
fix(focus): harden focus restoration paths and add regression coverage
mavrickdeveloper Feb 9, 2026
569e1f9
fix(focus): make popover keyboard focus deterministic and HMR-safe
mavrickdeveloper Feb 10, 2026
1ed0d06
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 16, 2026
21dec14
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 18, 2026
dd3a68c
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 24, 2026
1b8a7f0
test: add integration tests for ConfirmModal and ButtonWithDropdownMe…
mavrickdeveloper Feb 24, 2026
64790cb
test: update focus restoration and navigation test suites
mavrickdeveloper Feb 24, 2026
93d2d94
test: add accessibility and focus unit tests and trim redundant navig…
mavrickdeveloper Feb 25, 2026
3e91814
fix: resolve CI blockers for ESLint, typecheck, spellcheck, and Prettier
mavrickdeveloper Feb 25, 2026
06e1957
fix: inline web logic in blurActiveInputElementTest to avoid jest-exp…
mavrickdeveloper Feb 25, 2026
a7e94c6
style: apply Prettier formatting to match CI lockfile dependencies
mavrickdeveloper Feb 25, 2026
4c56440
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Feb 27, 2026
826d7ae
NAB: Test File Names Don't Follow Existing Convention
mavrickdeveloper Feb 27, 2026
7e519ab
Missing wasOpenedViaKeyboard in React.memo Comparison
mavrickdeveloper Feb 27, 2026
9fb18ca
refactor: split NavigationFocusManager by platform
mavrickdeveloper Mar 9, 2026
7c78404
fix: remove dead metadata API from NavigationFocusManager - Dead/Unus…
mavrickdeveloper Mar 9, 2026
31593f2
chore: exclude implementation note from PR - Dead/Unused Exported Cod…
mavrickdeveloper Mar 9, 2026
8bd309c
fix: add shared platform types for focus restore helpers - Shared typ…
mavrickdeveloper Mar 10, 2026
3257391
fix: use typed anchor ref narrowing in ButtonWithDropdownMenu
mavrickdeveloper Mar 10, 2026
7cce066
Extract shared focus utility for web focus handling
mavrickdeveloper Mar 10, 2026
5446a10
Resolve useAutoFocusInput merge with main and preserve keyboard focus…
mavrickdeveloper Mar 11, 2026
6d822de
fix: apply Prettier ordering in FocusTrapForScreenTest.tsx
mavrickdeveloper Mar 11, 2026
08ee6e6
test: remove deprecated InteractionManager typing in useAutoFocusInpu…
mavrickdeveloper Mar 11, 2026
041c6b3
fix: resolve web focus restoration from screen route boundaries
mavrickdeveloper Mar 13, 2026
edb2d8d
fix: preserve ScreenWrapper accessibility props
mavrickdeveloper Mar 13, 2026
15a1f60
Merge remote-tracking branch 'origin/main' into HEAD
mavrickdeveloper Mar 13, 2026
6d7ad85
fix: harden focus management
mavrickdeveloper Mar 14, 2026
9ec6d37
fix: narrow navigation focus follow-ups and restore MenuItem accessib…
mavrickdeveloper Mar 15, 2026
9a212ae
Merge remote-tracking branch 'origin/main' into fix/76921-focus-resto…
mavrickdeveloper Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {PortalProvider} from '@gorhom/portal';
import * as Sentry from '@sentry/react-native';
import React from 'react';
import React, {useEffect} from 'react';
import {LogBox, View} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {PickerStateProvider} from 'react-native-picker-select';
Expand Down Expand Up @@ -51,6 +51,7 @@ import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
import HybridAppHandler from './HybridAppHandler';
import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
import './libs/HybridApp';
import NavigationFocusManager from './libs/NavigationFocusManager';
import {AttachmentModalContextProvider} from './pages/media/AttachmentModalScreen/AttachmentModalContext';
import ExpensifyCardContextProvider from './pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardContextProvider';
import TravelCVVContextProvider from './pages/settings/Wallet/TravelCVVPage/TravelCVVContextProvider';
Expand All @@ -75,6 +76,16 @@ function App() {
useDefaultDragAndDrop();
OnyxUpdateManager();

// Initialize NavigationFocusManager for web focus restoration during back navigation
// This captures focus on pointerdown/keydown before navigation changes focus to body
useEffect(() => {
NavigationFocusManager.initialize();

return () => {
NavigationFocusManager.destroy();
};
}, []);

return (
<StrictModeWrapper>
<SplashScreenStateContextProvider>
Expand Down
6 changes: 4 additions & 2 deletions src/components/ApprovalWorkflowSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
</Text>
</View>
)}
{/* MenuItems are display-only (interactive={false}) because the outer
PressableWithoutFeedback handles all click interactions. */}
<MenuItem
title={translate('workflowsExpensesFromPage.title')}
style={styles.p0}
Expand All @@ -84,7 +86,7 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
iconHeight={20}
iconWidth={20}
iconFill={theme.icon}
onPress={onPress}
interactive={false}
shouldRemoveBackground
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_EXPENSES_FROM}
/>
Expand All @@ -106,7 +108,7 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
iconWidth={20}
numberOfLinesDescription={1}
iconFill={theme.icon}
onPress={onPress}
interactive={false}
shouldRemoveBackground
helperText={getApprovalLimitDescription({approver, currency, translate, personalDetailsByEmail})}
helperTextStyle={styles.workflowApprovalLimitText}
Expand Down
58 changes: 47 additions & 11 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type {RefObject} from 'react';
import type {RefCallback} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';
import type {GestureResponderEvent} from 'react-native';
Expand All @@ -14,9 +14,11 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import mergeRefs from '@libs/mergeRefs';
import NavigationFocusManager from '@libs/NavigationFocusManager';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import type {AnchorPosition} from '@src/styles';
import viewRef from '@src/types/utils/viewRef';
import type {ButtonWithDropdownMenuProps} from './types';

const defaultAnchorAlignment = {
Expand All @@ -25,6 +27,8 @@ const defaultAnchorAlignment = {
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
};

type DropdownAnchor = View | HTMLDivElement | null;

function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownMenuProps<IValueType>) {
const {
success = true,
Expand Down Expand Up @@ -74,18 +78,22 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
// In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests.
const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null;
const [popoverAnchorPosition, setPopoverAnchorPosition] = useState<AnchorPosition | null>(defaultPopoverAnchorPosition);
const dropdownAnchor = useRef<View | null>(null);
const dropdownAnchor = useRef<DropdownAnchor>(null);
const [wasOpenedViaKeyboard, setWasOpenedViaKeyboard] = useState(false);
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor);
const setDropdownAnchor: RefCallback<View> = useCallback((node: DropdownAnchor) => {
dropdownAnchor.current = node;
}, []);
// eslint-disable-next-line react-hooks/refs -- mergeRefs creates a ref callback and does not read ref.current during render
const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, setDropdownAnchor);
const selectedItem = options.at(selectedItemIndex) ?? options.at(0);
const areAllOptionsDisabled = options.every((option) => option.disabled);
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
const isButtonSizeSmall = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL;
const isButtonSizeExtraSmall = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.EXTRA_SMALL;
const nullCheckRef = (refParam: RefObject<View | null>) => refParam ?? null;
const shouldShowButtonRightIcon = !!options.at(0)?.shouldShowButtonRightIcon;

useEffect(() => {
Expand All @@ -101,7 +109,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
return;
}

calculatePopoverPosition(dropdownAnchor, anchorAlignment).then(setPopoverAnchorPosition);
calculatePopoverPosition(viewRef(dropdownAnchor), anchorAlignment).then(setPopoverAnchorPosition);
}, [isMenuVisible, calculatePopoverPosition, anchorAlignment]);

const handleSingleOptionPress = useCallback(
Expand All @@ -123,12 +131,27 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
[options, onPress, onOptionSelected, onSubItemSelected],
);

/** Opens or closes the menu with keyboard tracking */
const toggleMenu = useCallback(() => {
if (!isMenuVisible) {
// Capture keyboard state BEFORE menu opens
const wasKeyboardInteraction = NavigationFocusManager.wasRecentKeyboardInteraction();
setWasOpenedViaKeyboard(wasKeyboardInteraction);
if (wasKeyboardInteraction) {
NavigationFocusManager.clearKeyboardInteractionFlag();
}
} else {
setWasOpenedViaKeyboard(false);
}
setIsMenuVisible(!isMenuVisible);
}, [isMenuVisible]);

useKeyboardShortcut(
CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER,
(e) => {
if (shouldAlwaysShowDropdownMenu || options.length) {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
toggleMenu();
return;
}
if (selectedItem?.value) {
Expand All @@ -150,18 +173,28 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
const handlePress = useCallback(
(event?: GestureResponderEvent | KeyboardEvent) => {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
toggleMenu();
} else if (selectedItem?.value) {
onPress(event, selectedItem.value);
}
},
[isMenuVisible, isSplitButton, onPress, selectedItem?.value],
[isSplitButton, onPress, selectedItem?.value, toggleMenu],
);

useImperativeHandle(ref, () => ({
setIsMenuVisible,
}));

const focusDropdownAnchor = useCallback(() => {
const anchor = dropdownAnchor.current;

if (!anchor || !('focus' in anchor) || typeof anchor.focus !== 'function') {
return;
}

anchor.focus();
}, []);

return (
<View style={wrapperStyle}>
{shouldAlwaysShowDropdownMenu || options.length > 1 ? (
Expand Down Expand Up @@ -199,12 +232,12 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM

{isSplitButton && (
<Button
ref={dropdownAnchor}
ref={setDropdownAnchor}
success={success}
isDisabled={isDisabled}
shouldStayNormalOnDisable={shouldStayNormalOnDisable}
style={[styles.pl0]}
onPress={() => setIsMenuVisible(!isMenuVisible)}
onPress={toggleMenu}
shouldRemoveLeftBorderRadius
extraSmall={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.EXTRA_SMALL}
large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE}
Expand Down Expand Up @@ -271,9 +304,12 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
isVisible={isMenuVisible}
onClose={() => {
setIsMenuVisible(false);
setWasOpenedViaKeyboard(false);
onOptionsMenuHide?.();
}}
wasOpenedViaKeyboard={wasOpenedViaKeyboard}
onModalShow={onOptionsMenuShow}
onModalHide={focusDropdownAnchor}
onItemSelected={(selectedSubitem, index, event) => {
onSubItemSelected?.(selectedSubitem, index, event);
if (selectedSubitem.shouldCloseModalOnSelect !== false) {
Expand All @@ -282,7 +318,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
}}
anchorPosition={popoverAnchorPosition}
shouldShowSelectedItemCheck={shouldShowSelectedItemCheck}
anchorRef={nullCheckRef(dropdownAnchor)}
anchorRef={dropdownAnchor}
scrollContainerStyle={!shouldUseModalPaddingStyle && isSmallScreenWidth && {...styles.pt4, paddingBottom}}
anchorAlignment={anchorAlignment}
shouldUseModalPaddingStyle={shouldUseModalPaddingStyle}
Expand Down
Loading
Loading