Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
6c7e04d
fix: Screen Reader: Global: Focus is lost when returning to the previ…
TaduJR May 26, 2026
2d66e4e
fix: cap stale press triggers; log Accessibility cache-warm failures;…
TaduJR May 27, 2026
6562712
fix: Android forward auto-focus for mid-session TalkBack; consolidate…
TaduJR May 27, 2026
e4f3f8e
fix: capture/restore focus on native PUSH_PARAMS via compound key
TaduJR May 27, 2026
068e3c7
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR May 27, 2026
394f13f
fix: deterministic Android backward focus via native ACTION_ACCESSIBI…
TaduJR May 27, 2026
75b0a42
fix: revert Android focus to sendAccessibilityEvent + idle-callback r…
TaduJR May 27, 2026
e383907
chore: remove unused eslint-disable directives
TaduJR May 27, 2026
5645674
fix: clear trigger entry when skipNextFocusRestore is honoured (web +…
TaduJR May 27, 2026
74f5b90
fix: capture trigger via ref pass-through; drop findNodeHandle usages
TaduJR May 27, 2026
801c6e7
fix: initial focus on back button on screen mount
TaduJR May 27, 2026
4a372a4
fix: registry-based focus return survives react-native-screens detach…
TaduJR May 27, 2026
43d16d8
fix: disambiguate registry by id/nativeID/testID before accessibility…
TaduJR May 27, 2026
f688ab0
fix: distinguish programmatic focus via DOM attribute
TaduJR May 27, 2026
c346334
chore: tighten focus-return registry test fixtures
TaduJR May 27, 2026
57e38af
test: lock in data-programmatic-focus role suppression
TaduJR May 27, 2026
94f1325
fix: fallback route context so partial @react-navigation/native mocks…
TaduJR May 27, 2026
71d6103
test: spread requireActual in @react-navigation/native mocks to prese…
TaduJR May 27, 2026
b1467db
chore: convert single-line comments to /* */ block format
TaduJR May 27, 2026
9547045
chore: model programmatic focus as {role, isProgrammatic} context ins…
TaduJR May 27, 2026
74e06bc
refactor: SR-gate registry registration; rename to isRoleSuppressed
TaduJR May 28, 2026
62a8d04
fix: skip upcoming-transition wait for PUSH_PARAMS focus restore
TaduJR May 28, 2026
e7f413e
fix: revert save-path to skipNextFocusRestore
TaduJR May 28, 2026
4a4ff47
test: fix NavigationRouteContext mock crash
TaduJR May 28, 2026
b960997
fix: type react-navigation mock spread
TaduJR May 28, 2026
d008d72
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR May 28, 2026
9417a84
chore: trim scheduleRestore comment
TaduJR May 28, 2026
af0a898
refactor: harden screen-reader focus restoration and tidy module layout
TaduJR May 28, 2026
49ba12b
fix: keep screen-reader focus on save by keying menu rows on a stable…
TaduJR May 28, 2026
31a3289
fix: run web focus restore on TransitionTracker and end the 1s back-n…
TaduJR May 28, 2026
951a537
fix: recover focus when a trigger remounts mid-restore
TaduJR May 28, 2026
973c81d
fix: don't restore focus to the wrong row when list items share an ac…
TaduJR May 28, 2026
1d3e8d2
fix: stop a stale Back/Save press from leaking into a later focus res…
TaduJR May 28, 2026
c1eabc0
fix: key Corpay confirmation rows by field id so duplicate section la…
TaduJR May 29, 2026
c910f4d
chore: CI restart
TaduJR May 29, 2026
21c223f
fix: use a stable focus-return id for Corpay confirmation rows
TaduJR May 29, 2026
44a0fd0
fix: return focus to the edited field when saving an existing expense
TaduJR May 29, 2026
d2619e6
Merge branch 'Expensify:main' into fix-Screen-Reader-Global-Focus-is-…
TaduJR May 29, 2026
59d6217
fix: honor focus-restore skip on PUSH_PARAMS reverts and harden initi…
TaduJR May 29, 2026
7ef676a
fix: correct mobile-web screen-reader focus issues
TaduJR May 29, 2026
393b0ec
fix: key native focus-restore on identity props, not the a11y label
TaduJR May 29, 2026
fcc85bb
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR May 30, 2026
0eeb4bc
perf: warm AccessibilityInfo once across all subscribers, not per mount
TaduJR May 30, 2026
bf2af1b
fix: clear AccessibilityInfo memoized warm on rejection so the next c…
TaduJR May 30, 2026
7733817
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 1, 2026
bdfa1d7
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 3, 2026
6edc70c
refactor: default-export markProgrammaticFocus
TaduJR Jun 3, 2026
c190746
fix: drop the trigger entry on all skipNextFocusRestore paths
TaduJR Jun 3, 2026
cb4b036
chore: CI restart
TaduJR Jun 3, 2026
d0746ec
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 3, 2026
78a0fbe
fix: pass isOffline to updateMoneyRequestMerchant in the merchant step
TaduJR Jun 3, 2026
9ac40e4
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 4, 2026
0f03b20
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 8, 2026
ed43253
chore: CI restart
TaduJR Jun 8, 2026
effc8bc
fix: use armNavigateBack() in the clear-merchant branch
TaduJR Jun 8, 2026
073293e
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 10, 2026
8ebaf1e
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 10, 2026
7d45fc2
chore: replace unsafe type assertions with typed mocks
TaduJR Jun 10, 2026
fb12165
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 12, 2026
a66e2ec
fix: claim initial focus on the chat back button and the modal launcher
TaduJR Jun 13, 2026
27e1a32
refactor: extract useInitialFocusRef and restoreFocusWithModality pri…
TaduJR Jun 13, 2026
ea46446
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 13, 2026
669b046
refactor: drop useCallback from useInitialFocusRef
TaduJR Jun 13, 2026
4c94311
fix: tighten focus-claim/restore invariants on PUSH_PARAMS, re-presen…
TaduJR Jun 13, 2026
9cde887
fix: tie-break the registry rescue on back-button collisions
TaduJR Jun 13, 2026
a0720dc
chore: thread stable pressableTestID through value-displaying rows
TaduJR Jun 13, 2026
1928468
fix: react to late node attachment in useScreenInitialFocus
TaduJR Jun 13, 2026
6db0f77
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 15, 2026
eb19a7a
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 15, 2026
c15b896
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 16, 2026
7ad59fb
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 17, 2026
e3f2a8d
fix: pause parent focus-trap during launcher restore to block checkFo…
TaduJR Jun 17, 2026
f38900c
refactor: tighten contracts and clean up the subsystem
TaduJR Jun 17, 2026
cd3ba14
chore: a11y label, helper extractions, constants
TaduJR Jun 17, 2026
2d6236c
test: pin aria-label dialog-semantics contract
TaduJR Jun 17, 2026
8f999c1
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 21, 2026
514de41
fix: bail native scheduleRestore early; preserve parent-trap pause state
TaduJR Jun 21, 2026
8a20de7
fix: re-warm SR/RM caches on app foreground to recover from missed ch…
TaduJR Jun 21, 2026
264f691
fix: arbiter release, accessibility cache, monotonic TTL
TaduJR Jun 21, 2026
063554b
fix: gate SR at restore time so cold-start press survives capture
TaduJR Jun 21, 2026
8ed283e
fix: distinguish SR-unknown from SR-off; drop try/finally for RC
TaduJR Jun 22, 2026
690f47c
fix: cancel pending restore before scheduleRestore's early-bail
TaduJR Jun 22, 2026
04a56fb
fix: encapsulate warmed flag in makeWarmCache; reset on refresh
TaduJR Jun 22, 2026
640792d
fix: generation-token warm cache; consume known-off trigger; drop man…
TaduJR Jun 22, 2026
960baa1
fix: tag navigation transitions distinct from keyboard/modal
TaduJR Jun 23, 2026
fb24811
fix: preserve any-transition waiters; thread preventScroll through re…
TaduJR Jun 23, 2026
48a61ad
fix: gate fall-through on nav-active only; stable IDs for confirmatio…
TaduJR Jun 23, 2026
640ce5f
fix: iOS resume, duplicates, exception safety
TaduJR Jun 23, 2026
1f78b41
fix: defer PUSH_PARAMS attempt; decouple row IDs from edited fields
TaduJR Jun 23, 2026
92a25ab
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 23, 2026
74424d6
chore: CI restart
TaduJR Jun 23, 2026
cf697bb
chore: CI restart
TaduJR Jun 23, 2026
26c1107
Merge branch 'fix-Screen-Reader-Global-Focus-is-lost-when-returning-t…
TaduJR Jun 23, 2026
06fb6f6
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 23, 2026
c5357af
fix: annotate restoreFocusWithModality mock return as void
TaduJR Jun 23, 2026
f33b542
fix: catch stale-handle focus throws; gate chat header initial focus …
TaduJR Jun 24, 2026
e63ad5a
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 24, 2026
8db84ab
fix: register pressable during SR warm-up to match capture-side gate
TaduJR Jun 24, 2026
7817c62
fix: notify SR subscribers when AppState resume invalidates warm flag
TaduJR Jun 24, 2026
43c08c2
fix: fall through to registry rescue when stale native handle throws
TaduJR Jun 24, 2026
02bde58
fix: fire fast + registry-rescue in parallel; drop boolean stale-hand…
TaduJR Jun 24, 2026
8d40270
fix: fall through to registry rescue when stale native handle throws
TaduJR Jun 24, 2026
5c22fbd
fix: harden focus-return dialog viewport observer, save-arm count, ha…
TaduJR Jun 24, 2026
0a68193
refactor: harden focus-return rescue, list keys, and transition handles
TaduJR Jun 24, 2026
01ea0b9
fix: revert list keys; back-fill dialog label; re-arm save-nav
TaduJR Jun 24, 2026
91de9ca
fix: clear stale arm + skip-before-setter
TaduJR Jun 24, 2026
96a6a0c
fix: symmetric arm-time snapshot; hoist split out of retry loop
TaduJR Jun 25, 2026
c86f986
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 25, 2026
9da0fc2
chore: add comments and some tests
TaduJR Jun 25, 2026
4fd277c
chore: knip
TaduJR Jun 25, 2026
67592ac
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 26, 2026
e7adc29
fix: defensive focus-return during screen-reader warm-up window
TaduJR Jun 26, 2026
5d1d227
chore: drop unused ScreenReaderState type export
TaduJR Jun 26, 2026
8471c93
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 26, 2026
6e4b3b2
Merge branch 'main' of https://github.com/TaduJR/App into fix-Screen-…
TaduJR Jun 27, 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
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
},
"ignoreDependencies": [
"@expensify/react-native-hybrid-app",
"focus-trap",
"group-ib-fp",
"react-native-image-size",
"react-native-picker-select",
Expand Down
40 changes: 35 additions & 5 deletions src/components/DialogLabelContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, {createContext, useContext, useRef} from 'react';
import React, {createContext, useContext, useEffect, useLayoutEffect, useRef} from 'react';
import type {View} from 'react-native';
import isHTMLElement from '@libs/isHTMLElement';

type LabelEntry = {id: number; text: string};

Expand Down Expand Up @@ -27,18 +28,34 @@ const DialogLabelActionsContext = createContext<DialogLabelActions>({

type DialogLabelProviderProps = {
children: React.ReactNode;
containerRef: React.RefObject<View | null>;
/** Pass via `useState`/callback-ref so the provider observes node identity changes; a `RefObject` would pin the MutationObserver to the original node across Animated.View remounts. */
containerNode: View | HTMLElement | null;
};

function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps) {
// Title-stack and initial-focus claim are co-located: each pushLabel re-arms the focus claim so a sub-screen re-receives initial focus.
function DialogLabelProvider({children, containerNode}: DialogLabelProviderProps) {
const nextIdRef = useRef(0);
const labelStackRef = useRef<LabelEntry[]>([]);
const initialFocusClaimedRef = useRef(false);
// Stable RefObject for `.current` consumers (e.g. useDialogContainerFocus reads inside a rAF after node swaps).
const containerRef = useRef<View | null>(null);
useLayoutEffect(() => {
containerRef.current = (containerNode as View | null) ?? null;
}, [containerNode]);

const updateContainerLabel = () => {
if (typeof document === 'undefined') {
return;
}
const top = labelStackRef.current.at(-1);
const node = containerRef.current as unknown as HTMLElement | null;
if (!node || typeof node.setAttribute !== 'function') {
const node = containerRef.current;
if (!isHTMLElement(node)) {
return;
}
// aria-label on a container without dialog semantics is ignored; skip the set on mobile where the RHP has no dialog role.
const hasDialogSemantics = node.getAttribute('role') === 'dialog' || node.getAttribute('aria-modal') === 'true';
if (!hasDialogSemantics) {
node.removeAttribute('aria-label');
return;
}
if (top?.text) {
Expand All @@ -61,6 +78,19 @@ function DialogLabelProvider({children, containerRef}: DialogLabelProviderProps)
updateContainerLabel();
};

// Observe `role`/`aria-modal` so a viewport-resize flip re-applies the label; the initial call back-fills labels pushed by child effects before this parent effect ran.
useEffect(() => {
if (typeof MutationObserver === 'undefined' || !isHTMLElement(containerNode)) {
return;
}
const observer = new MutationObserver(updateContainerLabel);
observer.observe(containerNode, {attributes: true, attributeFilter: ['role', 'aria-modal']});
updateContainerLabel();
return () => {
observer.disconnect();
};
}, [containerNode, updateContainerLabel]);

const claimInitialFocus = (): boolean => {
if (initialFocusClaimedRef.current) {
return false;
Expand Down
22 changes: 9 additions & 13 deletions src/components/FocusTrap/FocusTrapForModal/index.web.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {FocusTrap} from 'focus-trap-react';
import React, {useRef} from 'react';
import sharedTrapStack from '@components/FocusTrap/sharedTrapStack';
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import {scheduleClearActivePopoverLauncher, setActivePopoverLauncher} from '@libs/LauncherStack';
import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import restoreFocusWithModality from '@libs/restoreFocusWithModality';
import sharedTrapStack from '@libs/sharedTrapStack';
import type FocusTrapForModalProps from './FocusTrapForModalProps';

function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true}: FocusTrapForModalProps) {
Expand All @@ -28,24 +29,19 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven
if (!launcher) {
return;
}
// Deferred so popover paths that navigate after modal-hide can still consume.
scheduleClearActivePopoverLauncher(launcher);
// 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)) {
restoreFocusWithModality(launcher, {preventScroll: shouldPreventScroll});
}
},
preventScroll: shouldPreventScroll,
trapStack: sharedTrapStack,
clickOutsideDeactivates: true,
initialFocus,
// Lazy so document.body isn't evaluated at render time (SSR-safe).
fallbackFocus: () => document.body,
setReturnFocus: (element) => {
if (ReportActionComposeFocusManager.isFocused()) {
return false;
}
if (shouldReturnFocus) {
return element;
}
return false;
},
setReturnFocus: false,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {useIsFocused, useRoute} from '@react-navigation/native';
import {FocusTrap} from 'focus-trap-react';
import React, {useMemo} from 'react';
import sharedTrapStack from '@components/FocusTrap/sharedTrapStack';
import TOP_TAB_SCREENS from '@components/FocusTrap/TOP_TAB_SCREENS';
import WIDE_LAYOUT_INACTIVE_SCREENS from '@components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import {isCancellingDndKeyboardDrag} from '@libs/cancelDndKeyboardDrag';
import {isSidebarScreenName} from '@libs/Navigation/helpers/isNavigatorName';
import sharedTrapStack from '@libs/sharedTrapStack';
import CONST from '@src/CONST';
import type FocusTrapProps from './FocusTrapProps';

Expand Down
3 changes: 3 additions & 0 deletions src/components/HeaderWithBackButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import SidePanelButton from '@components/SidePanel/SidePanelButton';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
import Tooltip from '@components/Tooltip';
import useDialogLabelRegistration from '@hooks/useDialogLabelRegistration';
import useInitialFocusRef from '@hooks/useInitialFocusRef';
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
Expand Down Expand Up @@ -90,6 +91,7 @@ function HeaderWithBackButton({
const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState();
const {translate} = useLocalize();
const isInLandscapeMode = useIsInLandscapeMode();
const setBackButtonRef = useInitialFocusRef({shouldSkip: shouldSkipFocusAfterTransition});

const downloadReasonAttributes = useMemo<SkeletonSpanReasonAttributes>(
() => ({
Expand Down Expand Up @@ -244,6 +246,7 @@ function HeaderWithBackButton({
{shouldShowBackButton && (
<Tooltip text={translate('common.back')}>
<PressableWithoutFeedback
ref={setBackButtonRef}
Comment thread
TaduJR marked this conversation as resolved.
Comment thread
TaduJR marked this conversation as resolved.
onPress={() => {
if (Keyboard.isVisible()) {
Keyboard.dismiss();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT
return (
<>
<MenuItemWithTopDescription
key={`${taxRates?.name}${taxRateTitle}`}
key={`${taxRates?.name}_rate`}
pressableTestID={`${taxRates?.name}_rate`}
shouldShowRightIcon={canModifyTaxFields}
title={taxRateTitle}
description={taxRates?.name}
Expand Down Expand Up @@ -160,7 +161,8 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT
</View>
) : (
<MenuItemWithTopDescription
key={`${taxRates?.name}${formattedTaxAmount}`}
key={`${taxRates?.name}_amount`}
pressableTestID={`${taxRates?.name}_amount`}
shouldShowRightIcon={canModifyTaxFields}
title={formattedTaxAmount}
description={translate('iou.taxAmount')}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type {ForwardedRef} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {GestureResponderEvent, View} from 'react-native';
// eslint-disable-next-line no-restricted-imports
import {Pressable} from 'react-native';
import type {ValueOf} from 'type-fest';
import type PressableProps from '@components/Pressable/GenericPressable/types';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useRouteKey from '@hooks/useRouteKey';
import useSingleExecution from '@hooks/useSingleExecution';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import Accessibility from '@libs/Accessibility';
import HapticFeedback from '@libs/HapticFeedback';
import mergeRefs from '@libs/mergeRefs';
import {notifyPressedTrigger, registerPressable} from '@libs/NavigationFocusReturn';
import CONST from '@src/CONST';

function GenericPressable({
Expand Down Expand Up @@ -47,9 +49,23 @@ function GenericPressable({
const StyleUtils = useStyleUtils();
const {isExecuting, singleExecution} = useSingleExecution();
const isScreenReaderActive = Accessibility.useScreenReaderStatus();
const screenReaderState = Accessibility.useScreenReaderState();
const [hitSlop, onLayout] = Accessibility.useAutoHitSlop();
const [isHovered, setIsHovered] = useState(false);
const isRoleButton = [rest.accessibilityRole, rest.role].includes(CONST.ROLE.BUTTON);
Comment thread
TaduJR marked this conversation as resolved.
const internalRef = useRef<View | null>(null);
const composedRef = useMemo(() => mergeRefs(ref, internalRef), [ref]);
const routeKey = useRouteKey();
// `||` so empty strings skip — never key off an empty prop.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const focusIdentifier = rest.id || rest.nativeID || rest.testID || undefined;

useEffect(() => {
if (screenReaderState === 'disabled' || !routeKey || !focusIdentifier) {
return;
}
return registerPressable(routeKey, focusIdentifier, internalRef);
}, [screenReaderState, routeKey, focusIdentifier]);

const isDisabled = useMemo(() => {
let shouldBeDisabledByScreenReader = false;
Expand Down Expand Up @@ -123,9 +139,10 @@ function GenericPressable({
ref.current?.blur();
Accessibility.moveAccessibilityFocus(nextFocusRef);
}
notifyPressedTrigger(internalRef, focusIdentifier);
return onPress(event);
},
[shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive],
[shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled, interactive, focusIdentifier],
);

const voidOnPressHandler = useCallback(
Expand Down Expand Up @@ -176,7 +193,7 @@ function GenericPressable({
<Pressable
hitSlop={shouldUseAutoHitSlop ? hitSlop : undefined}
onLayout={shouldUseAutoHitSlop ? onLayout : undefined}
ref={ref as ForwardedRef<View>}
ref={composedRef}
disabled={fullDisabled || undefined}
onPress={!isDisabled ? singleExecution(onPressHandler) : undefined}
onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined}
Expand Down
8 changes: 2 additions & 6 deletions src/components/Search/SearchRouter/SearchButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useRef} from 'react';
import type {StyleProp, View, ViewStyle} from 'react-native';
import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import Icon from '@components/Icon';
import {PressableWithoutFeedback} from '@components/Pressable';
import Tooltip from '@components/Tooltip';
Expand All @@ -24,13 +24,10 @@ function SearchButton({style, shouldUseAutoHitSlop = false}: SearchButtonProps)
const theme = useTheme();
const {translate} = useLocalize();
const {openSearchRouter} = useSearchRouterActions();
const pressableRef = useRef<View>(null);
const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass']);

const onPress = () => {
callFunctionIfActionIsAllowed(() => {
pressableRef.current?.blur();

startSpan(CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER, {
name: CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER,
op: CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER,
Expand All @@ -48,7 +45,6 @@ function SearchButton({style, shouldUseAutoHitSlop = false}: SearchButtonProps)
return (
<Tooltip text={translate('common.search')}>
<PressableWithoutFeedback
ref={pressableRef}
testID="searchButton"
accessibilityLabel={translate('common.search')}
role={CONST.ROLE.BUTTON}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useCanWriteCardSpendRules from '@hooks/useCanWriteCardSpendRules';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import {skipNextFocusRestore} from '@libs/NavigationFocusReturn';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -76,6 +77,8 @@ function SpendRuleMerchantEditBase({policyID, merchantIndex, merchantMatchTypes,
const updatedMerchantMatchTypes = merchantMatchTypes.filter((_, merchantArrayIndex) => merchantArrayIndex !== index);
onMerchantDataChange(updatedMerchantNames, updatedMerchantMatchTypes);
}
// Skip on every submit-driven goBack — the parent list has its own Save button, and a re-focused row would hijack the next Enter.
skipNextFocusRestore();
goBack();
return;
}
Expand All @@ -89,6 +92,7 @@ function SpendRuleMerchantEditBase({policyID, merchantIndex, merchantMatchTypes,
: merchantMatchTypes.map((type, merchantArrayIndex) => (merchantArrayIndex === index ? matchType : type));

onMerchantDataChange(updatedMerchantNames, updatedMerchantMatchTypes);
skipNextFocusRestore();
goBack();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,19 @@ import CONST from '@src/CONST';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';

type SpendRuleMerchant = {
name: string;
matchType: ValueOf<typeof CONST.SEARCH.SYNTAX_OPERATORS> | undefined;
};

type SpendRuleMerchantsBaseProps = {
policyID: string;
action: string;
merchantNames: string[];
merchantMatchTypes: Array<ValueOf<typeof CONST.SEARCH.SYNTAX_OPERATORS>>;
merchants: SpendRuleMerchant[];
getEditMerchantRoute: (merchantIndex: string) => Route;
};

function SpendRuleMerchantsBase({policyID, action, merchantMatchTypes, merchantNames, getEditMerchantRoute}: SpendRuleMerchantsBaseProps) {
function SpendRuleMerchantsBase({policyID, action, merchants, getEditMerchantRoute}: SpendRuleMerchantsBaseProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']);
Expand Down Expand Up @@ -76,23 +80,27 @@ function SpendRuleMerchantsBase({policyID, action, merchantMatchTypes, merchantN
titleStyle={styles.textStrong}
onPress={addMerchant}
/>
{merchantNames.length > 0 ? (
merchantNames.map((merchantName, index) => (
<MenuItemWithTopDescription
// eslint-disable-next-line react/no-array-index-key
key={`${merchantName}-${merchantMatchTypes.at(index) ?? ''}-${index}`}
description={
merchantMatchTypes.at(index) === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO
? translate('workspace.rules.spendRules.merchantExactlyMatches')
: translate('workspace.rules.spendRules.merchantContains')
}
onPress={() => navigateToMerchantEdit(String(index))}
shouldShowRightIcon
title={merchantName}
titleStyle={styles.flex1}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
/>
))
{merchants.length > 0 ? (
merchants.map(({name, matchType}, index) => {
// `name`/`matchType` are edited on the detail screen — keying by content would remount the row on save and lose the captured focus-return target. No per-merchant backend ID.
const rowId = `merchant-${index}`;
return (
<MenuItemWithTopDescription
key={rowId}
pressableTestID={rowId}
description={
matchType === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO
? translate('workspace.rules.spendRules.merchantExactlyMatches')
: translate('workspace.rules.spendRules.merchantContains')
}
onPress={() => navigateToMerchantEdit(String(index))}
shouldShowRightIcon
title={name}
titleStyle={styles.flex1}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM}
/>
);
})
) : (
<BlockingView
icon={illustrations.FoodTruck}
Expand Down
Loading
Loading