diff --git a/src/CONST.ts b/src/CONST.ts index 6d1195ff5c7..f9229d5185b 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -864,6 +864,11 @@ const CONST = { RIGHT: 'right', }, POPOVER_MENU_PADDING: 8, + RESTORE_FOCUS_TYPE: { + DEFAULT: 'default', + DELETE: 'delete', + PRESERVE: 'preserve', + }, }, TIMING: { CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action', diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index fbfa4563d70..410d600dcce 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -213,6 +213,8 @@ const EmojiPicker = forwardRef((props, ref) => { anchorDimensions={emojiAnchorDimension.current} avoidKeyboard shoudSwitchPositionIfOverflow + shouldEnableNewFocusManagement + restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} > , ) { @@ -56,6 +59,14 @@ function BaseModal( const isVisibleRef = useRef(isVisible); const wasVisible = usePrevious(isVisible); + const modalId = useMemo(() => ComposerFocusManager.getId(), []); + const saveFocusState = () => { + if (shouldEnableNewFocusManagement) { + ComposerFocusManager.saveFocusState(modalId); + } + ComposerFocusManager.resetReadyToFocus(modalId); + }; + /** * Hides modal * @param callHideCallback - Should we call the onModalHide callback @@ -70,11 +81,9 @@ function BaseModal( onModalHide(); } Modal.onModalDidClose(); - if (!fullscreen) { - ComposerFocusManager.setReadyToFocus(); - } + ComposerFocusManager.refocusAfterModalFullyClosed(modalId, restoreFocusType); }, - [shouldSetModalVisibility, onModalHide, fullscreen], + [shouldSetModalVisibility, onModalHide, restoreFocusType, modalId], ); useEffect(() => { @@ -126,7 +135,7 @@ function BaseModal( }; const handleDismissModal = () => { - ComposerFocusManager.setReadyToFocus(); + ComposerFocusManager.setReadyToFocus(modalId); }; const { @@ -190,7 +199,7 @@ function BaseModal( onModalShow={handleShowModal} propagateSwipe={propagateSwipe} onModalHide={hideModal} - onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()} + onModalWillShow={saveFocusState} onDismiss={handleDismissModal} onSwipeComplete={() => onClose?.()} swipeDirection={swipeDirection} @@ -214,12 +223,14 @@ function BaseModal( avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} > - - {children} - + + + {children} + + ); } diff --git a/src/components/Modal/ModalContent.tsx b/src/components/Modal/ModalContent.tsx new file mode 100644 index 00000000000..49d3b049220 --- /dev/null +++ b/src/components/Modal/ModalContent.tsx @@ -0,0 +1,23 @@ +import type {ReactNode} from 'react'; +import React from 'react'; + +type ModalContentProps = { + /** Modal contents */ + children: ReactNode; + + /** + * Callback method fired after modal content is unmounted. + * isVisible is not enough to cover all modal close cases, + * such as closing the attachment modal through the browser's back button. + * */ + onDismiss: () => void; +}; + +function ModalContent({children, onDismiss = () => {}}: ModalContentProps) { + // eslint-disable-next-line react-hooks/exhaustive-deps + React.useEffect(() => () => onDismiss?.(), []); + return children; +} +ModalContent.displayName = 'ModalContent'; + +export default ModalContent; diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx index 86a1fd27218..7cb2c608375 100644 --- a/src/components/Modal/index.android.tsx +++ b/src/components/Modal/index.android.tsx @@ -1,17 +1,7 @@ import React from 'react'; -import {AppState} from 'react-native'; -import ComposerFocusManager from '@libs/ComposerFocusManager'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; -AppState.addEventListener('focus', () => { - ComposerFocusManager.setReadyToFocus(); -}); - -AppState.addEventListener('blur', () => { - ComposerFocusManager.resetReadyToFocus(); -}); - // Only want to use useNativeDriver on Android. It has strange flashes issue on IOS // https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating function Modal({useNativeDriver = true, ...rest}: BaseModalProps) { diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 9c394fdf028..6111987e9c8 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -68,6 +68,15 @@ type BaseModalProps = Partial & { /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ shouldUseCustomBackdrop?: boolean; + + /** + * Whether the modal should enable the new focus manager. + * We are attempting to migrate to a new refocus manager, adding this property for gradual migration. + * */ + shouldEnableNewFocusManagement?: boolean; + + /** How to re-focus after the modal is dismissed */ + restoreFocusType?: ValueOf; }; export default BaseModalProps; diff --git a/src/libs/ComposerFocusManager.ts b/src/libs/ComposerFocusManager.ts index b66bbe92599..d793c202d24 100644 --- a/src/libs/ComposerFocusManager.ts +++ b/src/libs/ComposerFocusManager.ts @@ -1,25 +1,244 @@ -let isReadyToFocusPromise = Promise.resolve(); -let resolveIsReadyToFocus: (value: void | PromiseLike) => void; +import type {View} from 'react-native'; +import {TextInput} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import isWindowReadyToFocus from './isWindowReadyToFocus'; -function resetReadyToFocus() { - isReadyToFocusPromise = new Promise((resolve) => { - resolveIsReadyToFocus = resolve; +type ModalId = number | undefined; + +type InputElement = (TextInput & HTMLElement) | null; + +type RestoreFocusType = ValueOf | undefined; + +type ModalContainer = (View & HTMLElement) | undefined | null; + +/** + * So far, modern browsers only support the file cancel event in some newer versions + * (i.e., Chrome: 113+ / Firefox: 91+ / Safari 16.4+), and there is no standard feature detection method available. + * We will introduce the isInUploadingContext field to isolate the impact of the upload modal on the other modals. + */ +type FocusMapValue = { + input: InputElement; + isInUploadingContext?: boolean; +}; + +type PromiseMapValue = { + ready: Promise; + resolve: () => void; +}; + +let focusedInput: InputElement = null; +let uniqueModalId = 1; +const focusMap = new Map(); +const activeModals: ModalId[] = []; +const promiseMap = new Map(); + +/** + * Returns the ref of the currently focused text field, if one exists. + * react-native-web doesn't support `currentlyFocusedInput`, so we need to make it compatible by using `currentlyFocusedField` instead. + */ +function getActiveInput() { + return (TextInput.State.currentlyFocusedInput ? TextInput.State.currentlyFocusedInput() : TextInput.State.currentlyFocusedField()) as InputElement; +} + +/** + * On web platform, if the modal is displayed by a click, the blur event is fired before the modal appears, + * so we need to cache the focused input in the pointerdown handler, which is fired before the blur event. + */ +function saveFocusedInput() { + focusedInput = getActiveInput(); +} + +/** + * If a click does not display the modal, we also should clear the cached value to avoid potential issues. + */ +function clearFocusedInput() { + if (!focusedInput) { + return; + } + + // For the PopoverWithMeasuredContent component, Modal is only mounted after onLayout event is triggered, + // this event is placed within a setTimeout in react-native-web, + // so we can safely clear the cached value only after this event. + setTimeout(() => (focusedInput = null), CONST.ANIMATION_IN_TIMING); +} + +/** + * When a TextInput is unmounted, we also should release the reference here to avoid potential issues. + * + */ +function releaseInput(input: InputElement) { + if (!input) { + return; + } + if (input === focusedInput) { + focusedInput = null; + } + focusMap.forEach((value, key) => { + if (value.input !== input) { + return; + } + focusMap.delete(key); }); } -function setReadyToFocus() { - if (!resolveIsReadyToFocus) { +function getId() { + return uniqueModalId++; +} + +/** + * Save the focus state when opening the modal. + */ +function saveFocusState(id: ModalId, isInUploadingContext = false, shouldClearFocusWithType = false, container: ModalContainer = undefined) { + const activeInput = getActiveInput(); + + // For popoverWithoutOverlay, react calls autofocus before useEffect. + const input = focusedInput ?? activeInput; + focusedInput = null; + if (activeModals.indexOf(id) < 0) { + activeModals.push(id); + } + + if (shouldClearFocusWithType) { + focusMap.forEach((value, key) => { + if (value.isInUploadingContext !== isInUploadingContext) { + return; + } + focusMap.delete(key); + }); + } + + if (container?.contains(input)) { return; } - resolveIsReadyToFocus(); + focusMap.set(id, {input, isInUploadingContext}); + input?.blur(); } -function isReadyToFocus(): Promise { - return isReadyToFocusPromise; +/** + * On web platform, if we intentionally click on another input box, there is no need to restore focus. + * Additionally, if we are closing the RHP, we can ignore the focused input. + */ +function focus(input: InputElement, shouldIgnoreFocused = false) { + const activeInput = getActiveInput(); + if (!input || (activeInput && !shouldIgnoreFocused)) { + return; + } + isWindowReadyToFocus().then(() => input.focus()); } +function tryRestoreTopmostFocus(shouldIgnoreFocused: boolean, isInUploadingContext = false) { + const topmost = [...focusMap].filter(([, v]) => v.input && v.isInUploadingContext === isInUploadingContext).at(-1); + if (topmost === undefined) { + return; + } + const [modalId, {input}] = topmost; + + // This modal is still active + if (activeModals.indexOf(modalId) >= 0) { + return; + } + focus(input, shouldIgnoreFocused); + focusMap.delete(modalId); +} + +/** + * Restore the focus state after the modal is dismissed. + */ +function restoreFocusState(id: ModalId, shouldIgnoreFocused = false, restoreFocusType: RestoreFocusType = CONST.MODAL.RESTORE_FOCUS_TYPE.DEFAULT, isInUploadingContext = false) { + if (!id || !activeModals.length) { + return; + } + const activeModalIndex = activeModals.indexOf(id); + + // This id has been removed from the stack. + if (activeModalIndex < 0) { + return; + } + activeModals.splice(activeModalIndex, 1); + if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE) { + return; + } + + const {input} = focusMap.get(id) ?? {}; + focusMap.delete(id); + if (restoreFocusType === CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE) { + return; + } + + // This modal is not the topmost one, do not restore it. + if (activeModals.length > activeModalIndex) { + if (input) { + const lastId = activeModals.at(-1); + focusMap.set(lastId, {...focusMap.get(lastId), input}); + } + return; + } + if (input) { + focus(input, shouldIgnoreFocused); + return; + } + + // Try to find the topmost one and restore it + tryRestoreTopmostFocus(shouldIgnoreFocused, isInUploadingContext); +} + +function resetReadyToFocus(id: ModalId) { + const promise: PromiseMapValue = { + ready: Promise.resolve(), + resolve: () => {}, + }; + promise.ready = new Promise((resolve) => { + promise.resolve = resolve; + }); + promiseMap.set(id, promise); +} + +/** + * Backward compatibility, for cases without an ModalId param, it's fine to just take the topmost one. + */ +function getTopmostModalId() { + if (promiseMap.size < 1) { + return 0; + } + return [...promiseMap.keys()].at(-1); +} + +function setReadyToFocus(id?: ModalId) { + const key = id ?? getTopmostModalId(); + const promise = promiseMap.get(key); + if (!promise) { + return; + } + promise.resolve?.(); + promiseMap.delete(key); +} + +function isReadyToFocus(id?: ModalId) { + const key = id ?? getTopmostModalId(); + const promise = promiseMap.get(key); + if (!promise) { + return Promise.resolve(); + } + return promise.ready; +} + +function refocusAfterModalFullyClosed(id: ModalId, restoreType: RestoreFocusType, isInUploadingContext?: boolean) { + isReadyToFocus(id)?.then(() => restoreFocusState(id, false, restoreType, isInUploadingContext)); +} + +export type {InputElement}; + export default { + getId, + saveFocusedInput, + clearFocusedInput, + releaseInput, + saveFocusState, + restoreFocusState, resetReadyToFocus, setReadyToFocus, isReadyToFocus, + refocusAfterModalFullyClosed, + tryRestoreTopmostFocus, }; diff --git a/src/libs/focusComposerWithDelay/index.ts b/src/libs/focusComposerWithDelay/index.ts index 75e8f6ca8a6..cbd81b884d1 100644 --- a/src/libs/focusComposerWithDelay/index.ts +++ b/src/libs/focusComposerWithDelay/index.ts @@ -1,4 +1,5 @@ import ComposerFocusManager from '@libs/ComposerFocusManager'; +import isWindowReadyToFocus from '@libs/isWindowReadyToFocus'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import setTextInputSelection from './setTextInputSelection'; import type {FocusComposerWithDelay, InputType} from './types'; @@ -26,7 +27,7 @@ function focusComposerWithDelay(textInput: InputType | null): FocusComposerWithD } return; } - ComposerFocusManager.isReadyToFocus().then(() => { + Promise.all([ComposerFocusManager.isReadyToFocus(), isWindowReadyToFocus()]).then(() => { if (!textInput) { return; } diff --git a/src/libs/isWindowReadyToFocus/index.android.ts b/src/libs/isWindowReadyToFocus/index.android.ts new file mode 100644 index 00000000000..b9cca1b5a29 --- /dev/null +++ b/src/libs/isWindowReadyToFocus/index.android.ts @@ -0,0 +1,27 @@ +import {AppState} from 'react-native'; + +let isWindowReadyPromise = Promise.resolve(); +let resolveWindowReadyToFocus: () => void; + +AppState.addEventListener('focus', () => { + if (!resolveWindowReadyToFocus) { + return; + } + resolveWindowReadyToFocus(); +}); + +AppState.addEventListener('blur', () => { + isWindowReadyPromise = new Promise((resolve) => { + resolveWindowReadyToFocus = resolve; + }); +}); + +/** + * If we want to show the soft keyboard reliably, we need to ensure that the input's window gains focus first. + * Fortunately, we only need to manage the focus of the app window now, + * so we can achieve this by listening to the 'focus' event of the AppState. + * See {@link https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility#ShowReliably} + */ +const isWindowReadyToFocus = () => isWindowReadyPromise; + +export default isWindowReadyToFocus; diff --git a/src/libs/isWindowReadyToFocus/index.ts b/src/libs/isWindowReadyToFocus/index.ts new file mode 100644 index 00000000000..7ae3930c0c1 --- /dev/null +++ b/src/libs/isWindowReadyToFocus/index.ts @@ -0,0 +1,3 @@ +const isWindowReadyToFocus = () => Promise.resolve(); + +export default isWindowReadyToFocus;