Skip to content

Commit 48d4268

Browse files
authored
Merge pull request Expensify#87852 from TaduJR/fix-Keyboard-Navigation-Many-Pages-The-focus-is-lost-and-does-not-return-to-triggering-element
fix: Keyboard Navigation: Many Pages: The focus is lost and does not return to triggering element
2 parents be10df6 + c2512e1 commit 48d4268

22 files changed

Lines changed: 4242 additions & 141 deletions

cspell.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@
278278
"FLJZ",
279279
"fname",
280280
"fnames",
281+
"focusability",
281282
"focusvisible",
282283
"fontawesome",
283284
"foreignamount",
@@ -325,6 +326,7 @@
325326
"Highlightable",
326327
"HKBCCATT",
327328
"Hoverable",
329+
"hrefs",
328330
"HRMS",
329331
"HSBCSGS",
330332
"Humpty",
@@ -333,6 +335,7 @@
333335
"iaco",
334336
"IBTA",
335337
"IDEIN",
338+
"idempotently",
336339
"idfa",
337340
"Idology",
338341
"ifdef",

src/components/FocusTrap/FocusTrapForModal/index.web.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
11
import {FocusTrap} from 'focus-trap-react';
2-
import React from 'react';
2+
import React, {useRef} from 'react';
33
import sharedTrapStack from '@components/FocusTrap/sharedTrapStack';
44
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
5+
import {scheduleClearActivePopoverLauncher, setActivePopoverLauncher} from '@libs/LauncherStack';
56
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
67
import type FocusTrapForModalProps from './FocusTrapForModalProps';
78

89
function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true}: FocusTrapForModalProps) {
10+
// Track this trap's own launcher so onPostDeactivate targets the right shared-stack entry.
11+
const cachedLauncherRef = useRef<HTMLElement | null>(null);
912
return (
1013
<FocusTrap
1114
active={active}
1215
focusTrapOptions={{
13-
onActivate: blurActiveElement,
16+
onActivate: () => {
17+
// Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below).
18+
const launcher = document.activeElement;
19+
blurActiveElement();
20+
if (launcher instanceof HTMLElement && launcher !== document.body) {
21+
cachedLauncherRef.current = launcher;
22+
setActivePopoverLauncher(launcher);
23+
}
24+
},
25+
onPostDeactivate: () => {
26+
const launcher = cachedLauncherRef.current;
27+
cachedLauncherRef.current = null;
28+
if (!launcher) {
29+
return;
30+
}
31+
// Deferred so popover paths that navigate after modal-hide can still consume.
32+
scheduleClearActivePopoverLauncher(launcher);
33+
},
1434
preventScroll: shouldPreventScroll,
1535
trapStack: sharedTrapStack,
1636
clickOutsideDeactivates: true,
1737
initialFocus,
18-
fallbackFocus: document.body,
38+
// Lazy so document.body isn't evaluated at render time (SSR-safe).
39+
fallbackFocus: () => document.body,
1940
setReturnFocus: (element) => {
2041
if (ReportActionComposeFocusManager.isFocused()) {
2142
return false;

src/hooks/useAutoFocusInput.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native';
22
import {useCallback, useEffect, useRef, useState} from 'react';
33
import type {RefObject} from 'react';
44
import type {TextInput} from 'react-native';
5-
// eslint-disable-next-line no-restricted-imports
5+
// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions.
66
import {InteractionManager} from 'react-native';
77
import Accessibility from '@libs/Accessibility';
88
import ComposerFocusManager from '@libs/ComposerFocusManager';
99
import {moveSelectionToEnd, scrollToBottom} from '@libs/InputUtils';
1010
import isWindowReadyToFocus from '@libs/isWindowReadyToFocus';
1111
import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types';
1212
import type {RootNavigatorParamList} from '@libs/Navigation/types';
13+
import {shouldSkipAutoFocusDueToExistingFocus} from '@libs/NavigationFocusReturn';
14+
import {Priorities, resetCycle, tryClaim} from '@libs/ScreenFocusArbiter';
1315
import CONST from '@src/CONST';
1416
import ONYXKEYS from '@src/ONYXKEYS';
1517
import {useSplashScreenState} from '@src/SplashScreenStateContext';
@@ -68,7 +70,25 @@ export default function useAutoFocusInput(isMultiline = false): UseAutoFocusInpu
6870
if (inputRef.current && isMultiline) {
6971
moveSelectionToEnd(inputRef.current);
7072
}
71-
isWindowReadyToFocus().then(() => inputRef.current?.focus());
73+
isWindowReadyToFocus().then(() => {
74+
// Null-ref claim would block fallbacks on the destination screen.
75+
const input = inputRef.current;
76+
if (!input) {
77+
return;
78+
}
79+
if (shouldSkipAutoFocusDueToExistingFocus()) {
80+
return;
81+
}
82+
if (!tryClaim(Priorities.AUTO)) {
83+
return;
84+
}
85+
// Silent no-op (RN-Web TextInput hidden/disabled) leaves AUTO claimed; release so INITIAL/RETURN aren't blocked for 2s.
86+
const beforeActive = typeof document !== 'undefined' ? document.activeElement : null;
87+
input.focus();
88+
if (beforeActive !== null && document.activeElement === beforeActive) {
89+
resetCycle();
90+
}
91+
});
7292
setIsScreenTransitionEnded(false);
7393
});
7494

src/hooks/useDialogContainerFocus/index.ts

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,26 @@
11
import {useEffect} from 'react';
2-
// eslint-disable-next-line no-restricted-imports
2+
// eslint-disable-next-line no-restricted-imports -- idiomatic defer primitive past navigation transitions.
33
import {InteractionManager} from 'react-native';
4+
import FOCUSABLE_SELECTOR from '@libs/focusableSelector';
5+
import hasFocusableAttributes from '@libs/focusGuards';
6+
import getHadTabNavigation from '@libs/hadTabNavigation';
7+
import {Priorities, tryClaim} from '@libs/ScreenFocusArbiter';
48
import type UseDialogContainerFocus from './types';
59

6-
const FOCUSABLE_SELECTOR = 'button, [href], input, textarea, select, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])';
7-
8-
// Tracks whether the user is Tab-navigating (vs typing in a form or using mouse).
9-
// Tab sets it, typing keys clear it, Enter/Space preserve it, mousedown clears it.
10-
let hadTabNavigation = false;
11-
if (typeof document !== 'undefined') {
12-
document.addEventListener(
13-
'keydown',
14-
(e: KeyboardEvent) => {
15-
if (e.key === 'Tab') {
16-
hadTabNavigation = true;
17-
} else if (e.key !== 'Enter' && e.key !== ' ') {
18-
hadTabNavigation = false;
19-
}
20-
},
21-
true,
22-
);
23-
document.addEventListener(
24-
'mousedown',
25-
() => {
26-
hadTabNavigation = false;
27-
},
28-
true,
29-
);
30-
}
31-
32-
/** @returns true if an element was focused, false otherwise. */
3310
function focusFirstInteractiveElement(container: HTMLElement | null): boolean {
34-
if (!hadTabNavigation || !container || (document.activeElement && document.activeElement !== document.body)) {
11+
if (!getHadTabNavigation() || !container || (document.activeElement && document.activeElement !== document.body)) {
3512
return false;
3613
}
3714
const targets = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
38-
const target = Array.from(targets).find((el) => !el.closest('[aria-hidden="true"]') && !el.matches(':disabled') && el.getAttribute('aria-disabled') !== 'true');
15+
const target = Array.from(targets).find(hasFocusableAttributes);
3916
if (!target) {
4017
return false;
4118
}
42-
target.focus({preventScroll: true, focusVisible: true} as FocusOptions);
19+
// Arbitrated so a concurrent RETURN restore wins over this dialog's initial focus.
20+
if (!tryClaim(Priorities.INITIAL)) {
21+
return false;
22+
}
23+
target.focus({preventScroll: true, focusVisible: true});
4324
return true;
4425
}
4526

src/libs/LauncherStack.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Stack of popover/modal launcher elements — the element that opened a focus trap. Top is the most recent.
3+
* pickLauncher prefers the topmost active entry, else the most recent deactivated-within-LAUNCHER_CLEAR_DELAY_MS.
4+
*/
5+
6+
// deactivatedAt is set on trap close; entry lives LAUNCHER_CLEAR_DELAY_MS so deferred-nav popovers can still consume it.
7+
type LauncherEntry = {element: HTMLElement; deactivatedAt?: number};
8+
9+
// Covers click → state-listener → captureTriggerForRoute on slow devices.
10+
const LAUNCHER_CLEAR_DELAY_MS = 1000;
11+
const LAUNCHER_STACK_MAX = 8;
12+
13+
// Stack (not slot) so nested + sequential traps retain correct launcher context.
14+
const launcherStack: LauncherEntry[] = [];
15+
let hasWarnedAboutOverflow = false;
16+
17+
// Two passes so nested traps resolve to the outer (active) launcher, not the just-closed inner.
18+
function pickLauncher(): HTMLElement | null {
19+
if (typeof document === 'undefined') {
20+
return null;
21+
}
22+
// Monotonic — Date.now() would misbehave on clock jumps.
23+
const now = performance.now();
24+
for (let i = launcherStack.length - 1; i >= 0; i -= 1) {
25+
const entry = launcherStack.at(i);
26+
if (!entry) {
27+
continue;
28+
}
29+
if (!document.contains(entry.element)) {
30+
launcherStack.splice(i, 1);
31+
continue;
32+
}
33+
if (entry.deactivatedAt === undefined) {
34+
return entry.element;
35+
}
36+
}
37+
for (let i = launcherStack.length - 1; i >= 0; i -= 1) {
38+
const entry = launcherStack.at(i);
39+
if (entry?.deactivatedAt === undefined) {
40+
continue;
41+
}
42+
if (!document.contains(entry.element)) {
43+
launcherStack.splice(i, 1);
44+
continue;
45+
}
46+
if (now - entry.deactivatedAt > LAUNCHER_CLEAR_DELAY_MS) {
47+
launcherStack.splice(i, 1);
48+
continue;
49+
}
50+
return entry.element;
51+
}
52+
return null;
53+
}
54+
55+
function consumeLauncher(element: HTMLElement): void {
56+
const idx = launcherStack.findIndex((e) => e.element === element);
57+
if (idx >= 0) {
58+
launcherStack.splice(idx, 1);
59+
}
60+
}
61+
62+
function setActivePopoverLauncher(element: HTMLElement): void {
63+
if (typeof document === 'undefined') {
64+
return;
65+
}
66+
// Reactivation must move the entry to the tail — pickLauncher scans end-first, so leaving a reactivated entry mid-stack lets newer (still-active) entries shadow it.
67+
const existingIdx = launcherStack.findIndex((e) => e.element === element);
68+
if (existingIdx >= 0) {
69+
launcherStack.splice(existingIdx, 1);
70+
}
71+
launcherStack.push({element});
72+
if (launcherStack.length > LAUNCHER_STACK_MAX) {
73+
if (!hasWarnedAboutOverflow) {
74+
hasWarnedAboutOverflow = true;
75+
// Once-per-session so a pathological trap loop doesn't spam dev logs.
76+
// eslint-disable-next-line no-console
77+
console.warn('[NavigationFocusReturn] launcherStack overflow — dropping oldest entry');
78+
}
79+
launcherStack.shift();
80+
}
81+
}
82+
83+
/** Mark a launcher (or top-of-stack) as deactivated. pickLauncher lazy-prunes on LAUNCHER_CLEAR_DELAY_MS. */
84+
function scheduleClearActivePopoverLauncher(element?: HTMLElement): void {
85+
if (typeof document === 'undefined') {
86+
return;
87+
}
88+
const index = element ? launcherStack.findIndex((e) => e.element === element) : launcherStack.length - 1;
89+
if (index < 0) {
90+
return;
91+
}
92+
// Splice-then-push so end-first scan returns the most-recently-deactivated (correct for nested-trap close: outer closes after inner).
93+
const [entry] = launcherStack.splice(index, 1);
94+
entry.deactivatedAt = performance.now();
95+
launcherStack.push(entry);
96+
}
97+
98+
function resetLauncherStackForTests(): void {
99+
launcherStack.length = 0;
100+
hasWarnedAboutOverflow = false;
101+
}
102+
103+
export {pickLauncher, consumeLauncher, setActivePopoverLauncher, scheduleClearActivePopoverLauncher, resetLauncherStackForTests, LAUNCHER_CLEAR_DELAY_MS, LAUNCHER_STACK_MAX};

0 commit comments

Comments
 (0)