Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@
"americanexpressfdx",
"bankofamerica",
"Amina",
"anchorless",
"androiddebugkey",
"androidx",
"apksigner",
Expand Down Expand Up @@ -482,6 +483,7 @@
"autocorrection",
"autodocs",
"autofilled",
"autofocused",
"automations",
"autoplay",
"autoreleasepool",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FocusTrapProps['focusTrapOptions'], undefined>;

Expand All @@ -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<View | Text | HTMLElement | null>;
};

export default FocusTrapForModalProps;
78 changes: 55 additions & 23 deletions src/components/FocusTrap/FocusTrapForModal/index.web.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack';
import {hasLauncher, markActivePopoverLauncherDeactivated, pickLauncher, setActivePopoverLauncher} from '@libs/LauncherStack';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import restoreFocusWithModality from '@libs/restoreFocusWithModality';
import sharedTrapStack from '@libs/sharedTrapStack';
Expand All @@ -9,34 +9,66 @@ 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<HTMLElement | null>(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);

const onFocusTrapActive = () => {
trapDepthAtActivateRef.current = sharedTrapStack.length;
// 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});
}
};

return (
<FocusTrap
active={active}
focusTrapOptions={{
onActivate: () => {
// Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below).
const launcher = document.activeElement;
blurActiveElement();
if (launcher instanceof HTMLElement && launcher !== document.body) {
cachedLauncherRef.current = launcher;
setActivePopoverLauncher(launcher);
}
},
onPostDeactivate: () => {
const launcher = cachedLauncherRef.current;
cachedLauncherRef.current = null;
if (!launcher) {
return;
}
// 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});
}
},
onActivate: onFocusTrapActive,
onPostDeactivate: onFocusTrapPostDeactivate,
preventScroll: shouldPreventScroll,
trapStack: sharedTrapStack,
clickOutsideDeactivates: true,
Expand Down
2 changes: 2 additions & 0 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function BaseModal({
modalId,
shouldEnableNewFocusManagement = false,
shouldReturnFocus,
launcherRef,
restoreFocusType,
shouldUseModalPaddingStyle = true,
initialFocus = false,
Expand Down Expand Up @@ -391,6 +392,7 @@ function BaseModal({
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
supportedOrientations={['portrait', 'portrait-upside-down', 'landscape', 'landscape-left', 'landscape-right']}
shouldReturnFocus={shouldReturnFocus}
launcherRef={launcherRef}
>
<Animated.View
onLayout={onViewLayout}
Expand Down
12 changes: 11 additions & 1 deletion src/components/Modal/ReanimatedModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function ReanimatedModal({
shouldIgnoreBackHandlerDuringTransition = false,
shouldEnableNewFocusManagement,
shouldReturnFocus,
launcherRef,
...props
}: ReanimatedModalProps) {
const [isVisibleState, setIsVisibleState] = useState(isVisible);
Expand All @@ -69,6 +70,7 @@ function ReanimatedModal({
const backHandlerListener = useRef<NativeEventSubscription | null>(null);
const handleRef = useRef<number | undefined>(undefined);
const transitionHandleRef = useRef<TransitionHandle | null>(null);
const containerRef = useRef<View | null>(null);

const styles = useThemeStyles();

Expand Down Expand Up @@ -142,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);
}
Expand Down Expand Up @@ -192,6 +200,7 @@ function ReanimatedModal({

const containerView = (
<Container
ref={containerRef}
pointerEvents="box-none"
animationInTiming={animationInTiming}
animationOutTiming={animationOutTiming}
Expand Down Expand Up @@ -268,6 +277,7 @@ function ReanimatedModal({
initialFocus={initialFocus}
shouldReturnFocus={shouldReturnFocus ?? !shouldEnableNewFocusManagement}
shouldPreventScroll={shouldPreventScrollOnFocus}
launcherRef={launcherRef}
>
{isVisibleState && containerView}
</FocusTrapForModal>
Expand Down
14 changes: 12 additions & 2 deletions src/components/Modal/ReanimatedModal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, Ref, 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';

Expand Down Expand Up @@ -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<View | Text | HTMLElement | null>;

/** Whether to ignore the back handler during transition */
shouldIgnoreBackHandlerDuringTransition?: boolean;
};
Expand Down Expand Up @@ -181,6 +188,9 @@ type BackdropProps = {
};

type ContainerProps = {
/** Host node of the modal's content, used to tell whether focus is still inside this modal. */
ref?: Ref<View>;

/** This function is called by open animation callback */
onOpenCallBack: () => void;

Expand Down
4 changes: 4 additions & 0 deletions src/components/Popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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}
/>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/components/PopoverMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ function BasePopoverMenu({
<FocusTrapForModal
active={isVisible}
shouldReturnFocus={!shouldEnableNewFocusManagement}
launcherRef={anchorRef}
>
<CompactMenuContext.Provider value>
<View
Expand Down
5 changes: 4 additions & 1 deletion src/components/PopoverMenu/v2/content/BaseContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ function BaseContentInner({
shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode}
testID={testID}
>
<FocusTrapForModal active={isVisible}>
<FocusTrapForModal
active={isVisible}
launcherRef={activeAnchor.ref}
>
<CompactMenuContext.Provider value>
<ContentNavigationContext.Provider value={navigation}>
<ContentFocusContext.Provider value={focus}>
Expand Down
2 changes: 2 additions & 0 deletions src/components/PopoverWithMeasuredContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
);
}
Expand Down
10 changes: 9 additions & 1 deletion src/libs/LauncherStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,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) {
Expand Down Expand Up @@ -97,4 +105,4 @@ function resetLauncherStackForTests(): void {
hasWarnedAboutOverflow = false;
}

export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests};
export {pickLauncher, consumeLauncher, hasLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests};
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ function FABPopoverMenu({isVisible, onClose, onItemSelected, anchorRef, animatio
<FocusTrapForModal
active={isVisible}
shouldReturnFocus
launcherRef={anchorRef}
>
<CompactMenuContext.Provider value>
<Activity mode={contentActivityMode}>
Expand Down
Loading
Loading