Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

prevent initial focus trap while has a focused element. #44932

Merged
merged 16 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
88 changes: 88 additions & 0 deletions patches/focus-trap+7.5.4.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
diff --git a/node_modules/focus-trap/dist/focus-trap.esm.js b/node_modules/focus-trap/dist/focus-trap.esm.js
index 10d56db..a0ed057 100644
--- a/node_modules/focus-trap/dist/focus-trap.esm.js
+++ b/node_modules/focus-trap/dist/focus-trap.esm.js
@@ -100,8 +100,8 @@ var isKeyForward = function isKeyForward(e) {
var isKeyBackward = function isKeyBackward(e) {
return isTabEvent(e) && e.shiftKey;
};
-var delay = function delay(fn) {
- return setTimeout(fn, 0);
+var delay = function delay(fn, delayTime = 0) {
+ return setTimeout(fn, delayTime);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to keep the extra setTimeout here and add our timeout. After testing I found out that document.activeElement does not return the correct focused element.

Suggested change
+ return setTimeout(fn, delayTime);
+ return setTimeout(() => setTimeout(fn, delayTime), 0);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the component unmounts, the focus trap will deactivate, so the default active element becomes the document. So I don't think we need to add another timeout inside the existing timeout

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please elaborate what does this has to do with focus deactivation? What I am referring to is the fact that if you log document.activeElement you will not get the correct focused element as we are doing this too early

Screen.Recording.2024-07-08.at.4.42.36.PM.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a missing delayInitialFocus time; it should equal CONST.ANIMATED_TRANSITION + CONST.ANIMATION_IN_TIMING. I’ll update it with the timing animation in accordingly.

Screen.Recording.2024-07-08.at.23.18.32.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated timing animation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct timing is 300ms (CONST.ANIMATED_TRANSITION). CONST.ANIMATION_IN_TIMING is not related to the screen transition.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct timing is 300ms (CONST.ANIMATED_TRANSITION). CONST.ANIMATION_IN_TIMING is not related to the screen transition.

About this change, we need to extend ANIMATION_IN_TIMING. This is because useAutoFocus also runs after ANIMATED_TRANSITION. In that case, input focusing and getting initial focus from the focus trap will run simultaneously, so sometimes the focused element is not correct

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that this already had it's own setTimeout even when no timeout is actually required, we should just add another setTimeout. The former is to fix whatever the library was trying to fix and the later is for our actual wanted delay.

Copy link
Contributor Author

@suneox suneox Jul 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll try your suggestion

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i have updated

};

// Array.find/findIndex() are not supported on IE; this replicates enough
@@ -283,7 +283,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) {
return node;
};
var getInitialFocusNode = function getInitialFocusNode() {
- var node = getNodeForOption('initialFocus');
+ var node = getNodeForOption('initialFocus', state.containers);

// false explicitly indicates we want no initialFocus at all
if (node === false) {
@@ -744,7 +744,7 @@ var createFocusTrap = function createFocusTrap(elements, userOptions) {
// that caused the focus trap activation.
state.delayInitialFocusTimer = config.delayInitialFocus ? delay(function () {
tryFocus(getInitialFocusNode());
- }) : tryFocus(getInitialFocusNode());
+ }, typeof config.delayInitialFocus === 'number' ? config.delayInitialFocus : undefined) : tryFocus(getInitialFocusNode());
doc.addEventListener('focusin', checkFocusIn, true);
doc.addEventListener('mousedown', checkPointerDown, {
capture: true,
diff --git a/node_modules/focus-trap/index.d.ts b/node_modules/focus-trap/index.d.ts
index 400db1b..69f4b94 100644
--- a/node_modules/focus-trap/index.d.ts
+++ b/node_modules/focus-trap/index.d.ts
@@ -16,7 +16,7 @@ declare module 'focus-trap' {
* `document.querySelector()` to find the DOM node), `false` to explicitly indicate
* an opt-out, or a function that returns a DOM node or `false`.
*/
- export type FocusTargetOrFalse = FocusTargetValueOrFalse | (() => FocusTargetValueOrFalse);
+ export type FocusTargetOrFalse = FocusTargetValueOrFalse | ((containers?: HTMLElement[]) => FocusTargetValueOrFalse | undefined);

type MouseEventToBoolean = (event: MouseEvent | TouchEvent) => boolean;
type KeyboardEventToBoolean = (event: KeyboardEvent) => boolean;
@@ -185,7 +185,7 @@ declare module 'focus-trap' {
* This prevents elements within the focusable element from capturing
* the event that triggered the focus trap activation.
*/
- delayInitialFocus?: boolean;
+ delayInitialFocus?: boolean | number;
/**
* Default: `window.document`. Document where the focus trap will be active.
* This allows to use FocusTrap in an iFrame context.
diff --git a/node_modules/focus-trap/index.js b/node_modules/focus-trap/index.js
index de8e46a..7ab9d89 100644
--- a/node_modules/focus-trap/index.js
+++ b/node_modules/focus-trap/index.js
@@ -63,8 +63,8 @@ const isKeyBackward = function (e) {
return isTabEvent(e) && e.shiftKey;
};

-const delay = function (fn) {
- return setTimeout(fn, 0);
+const delay = function (fn, delayTime = 0) {
+ return setTimeout(fn, delayTime);
};

// Array.find/findIndex() are not supported on IE; this replicates enough
@@ -267,7 +267,7 @@ const createFocusTrap = function (elements, userOptions) {
};

const getInitialFocusNode = function () {
- let node = getNodeForOption('initialFocus');
+ let node = getNodeForOption('initialFocus', state.containers);

// false explicitly indicates we want no initialFocus at all
if (node === false) {
@@ -817,7 +817,7 @@ const createFocusTrap = function (elements, userOptions) {
state.delayInitialFocusTimer = config.delayInitialFocus
? delay(function () {
tryFocus(getInitialFocusNode());
- })
+ }, typeof config.delayInitialFocus === 'number' ? config.delayInitialFocus : undefined)
: tryFocus(getInitialFocusNode());

doc.addEventListener('focusin', checkFocusIn, true);
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
type FocusTrapForScreenProps = {
children: React.ReactNode;
testID?: string;
suneox marked this conversation as resolved.
Show resolved Hide resolved
};

export default FocusTrapForScreenProps;
31 changes: 7 additions & 24 deletions src/components/FocusTrap/FocusTrapForScreen/index.web.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native';
import {useIsFocused, useRoute} from '@react-navigation/native';
import FocusTrap from 'focus-trap-react';
import React, {useCallback, useMemo} from 'react';
import {useOnyx} from 'react-native-onyx';
import React, {useMemo} from 'react';
import BOTTOM_TAB_SCREENS from '@components/FocusTrap/BOTTOM_TAB_SCREENS';
import getScreenWithAutofocus from '@components/FocusTrap/SCREENS_WITH_AUTOFOCUS';
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 useWindowDimensions from '@hooks/useWindowDimensions';
import ONYXKEYS from '@src/ONYXKEYS';
import CONST from '@src/CONST';
import type FocusTrapProps from './FocusTrapProps';

let activeRouteName = '';
function FocusTrapForScreen({children}: FocusTrapProps) {
const isFocused = useIsFocused();
const route = useRoute();
const {isSmallScreenWidth} = useWindowDimensions();
const [isAuthenticated] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => !!session?.authToken});
const screensWithAutofocus = useMemo(() => getScreenWithAutofocus(!!isAuthenticated), [isAuthenticated]);

const isActive = useMemo(() => {
// Focus trap can't be active on bottom tab screens because it would block access to the tab bar.
Expand All @@ -37,12 +32,6 @@ function FocusTrapForScreen({children}: FocusTrapProps) {
return true;
}, [isFocused, isSmallScreenWidth, route.name]);

useFocusEffect(
useCallback(() => {
activeRouteName = route.name;
}, [route]),
);

return (
<FocusTrap
active={isActive}
Expand All @@ -51,18 +40,12 @@ function FocusTrapForScreen({children}: FocusTrapProps) {
trapStack: sharedTrapStack,
allowOutsideClick: true,
fallbackFocus: document.body,
// We don't want to ovverride autofocus on these screens.
initialFocus: () => {
if (screensWithAutofocus.includes(activeRouteName)) {
return false;
suneox marked this conversation as resolved.
Show resolved Hide resolved
}
return undefined;
},
setReturnFocus: (element) => {
if (screensWithAutofocus.includes(activeRouteName)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not return focus to the initial screen if we already have a focused element, e.g. in ReportScreen after closing RHP the Composer should remain focused

Screen.Recording.2024-07-09.at.12.17.06.AM.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@s77rt I’m still working on setReturnFocus. Do you mean your suggestion also checks if there’s a focused element to skip setReturnFocus?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a component using focus-trap-react unmounts, it calls deactivateTrap, which triggers focusTrap.deactivate. This subsequently calls getReturnFocusNode in focus-trap. Therefore, we can’t get the containers for the next screen because when Screen B unmounts (after navigating from Screen A to Screen B), it sets the return focus node from Screen B’s context.

As a result, we can’t retrieve the containers for Screen A when Screen B unmounts. Instead, we can check if the active element is the default (document body). If it is, we can return false to ensure the focus trap does not interfere with the focus transition to Screen A. Otherwise, we keep the current focused element.

I have updated the code, and it covers the issue you mentioned above.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works for me but let's also check if document.activeElement is null. In that case we want to return the focus as well

delayInitialFocus: CONST.ANIMATED_TRANSITION + CONST.ANIMATION_IN_TIMING,
initialFocus: (focusTrapContainers) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
delayInitialFocus: CONST.ANIMATED_TRANSITION + CONST.ANIMATION_IN_TIMING,
delayInitialFocus: CONST.ANIMATED_TRANSITION,

const hasFocusedElementInsideContainer = focusTrapContainers?.some((container) => container.contains(document.activeElement));
if (hasFocusedElementInsideContainer) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const hasFocusedElementInsideContainer = focusTrapContainers?.some((container) => container.contains(document.activeElement));
const isFocusedElementInsideContainer = focusTrapContainers?.some((container) => container.contains(document.activeElement));

NAB

return false;
}
return element;
},
}}
>
Expand Down
28 changes: 0 additions & 28 deletions src/components/FocusTrap/SCREENS_WITH_AUTOFOCUS.ts

This file was deleted.

9 changes: 8 additions & 1 deletion src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {useIsFocused} from '@react-navigation/native';
import lodashIsEqual from 'lodash/isEqual';
import React, {memo, useCallback, useEffect, useMemo, useState} from 'react';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {TextStyle} from 'react-native';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
Expand Down Expand Up @@ -726,6 +726,12 @@ function MoneyRequestConfirmationList({
],
);

const confirmButtonRef = useRef<View>(null);

useEffect(() => {
confirmButtonRef.current?.focus();
}, []);

suneox marked this conversation as resolved.
Show resolved Hide resolved
const footerContent = useMemo(() => {
if (isReadOnly) {
return;
Expand Down Expand Up @@ -757,6 +763,7 @@ function MoneyRequestConfirmationList({
/>
) : (
<ButtonWithDropdownMenu
buttonRef={confirmButtonRef}
success
pressOnEnter
isDisabled={shouldDisableButton}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ScreenWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ function ScreenWrapper(
}

return (
<FocusTrapForScreens>
<FocusTrapForScreens testID={testID}>
<View
ref={ref}
style={[styles.flex1, {minHeight}]}
Expand Down
Loading