Skip to content

Commit

Permalink
try to refactor the modal's refocus logic
Browse files Browse the repository at this point in the history
  • Loading branch information
ntdiary committed Nov 29, 2023
1 parent 1234b0d commit 4952dce
Show file tree
Hide file tree
Showing 32 changed files with 476 additions and 185 deletions.
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,11 @@ const CONST = {
RIGHT: 'right',
},
POPOVER_MENU_PADDING: 8,
RESTORE_TYPE: {
DEFAULT: 'default',
DELETE: 'delete',
PRESERVE: 'preserve',
},
},
TIMING: {
CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action',
Expand Down
8 changes: 7 additions & 1 deletion src/components/AttachmentPicker/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ const getDataForUpload = (fileData) => {
function AttachmentPicker({type, children, shouldHideCameraOption}) {
const styles = useThemeStyles();
const [isVisible, setIsVisible] = useState(false);
const [restoreFocusType, setRestoreFocusType] = useState();

const completeAttachmentSelection = useRef();
const onModalHide = useRef();
Expand Down Expand Up @@ -305,12 +306,14 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
return (
<>
<Popover
restoreFocusType={restoreFocusType}
onClose={() => {
close();
onCanceled.current();
}}
isVisible={isVisible}
anchorPosition={styles.createMenuPosition}
onModalShow={() => setRestoreFocusType(CONST.MODAL.RESTORE_TYPE.DEFAULT)}
onModalHide={onModalHide.current}
>
<View style={!isSmallScreenWidth && styles.createMenuContainer}>
Expand All @@ -319,7 +322,10 @@ function AttachmentPicker({type, children, shouldHideCameraOption}) {
key={item.textTranslationKey}
icon={item.icon}
title={translate(item.textTranslationKey)}
onPress={() => selectItem(item)}
onPress={() => {
setRestoreFocusType(CONST.MODAL.RESTORE_TYPE.PRESERVE);
selectItem(item);
}}
focused={focusedIndex === menuIndex}
/>
))}
Expand Down
12 changes: 3 additions & 9 deletions src/components/Composer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,16 +452,10 @@ function Composer({
disabled={isDisabled}
onKeyPress={handleKeyPress}
onFocus={(e) => {
ReportActionComposeFocusManager.onComposerFocus(() => {
if (!textInput.current) {
return;
}

textInput.current.focus();
});
if (props.onFocus) {
props.onFocus(e);
if (!props.onFocus) {
return;
}
props.onFocus(e);
}}
/>
{shouldCalculateCaretPosition && renderElementForCaretPosition}
Expand Down
43 changes: 28 additions & 15 deletions src/components/Modal/BaseModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef} from 'react';
import {View} from 'react-native';
import ReactNativeModal from 'react-native-modal';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
Expand All @@ -13,7 +13,8 @@ import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import * as Modal from '@userActions/Modal';
import CONST from '@src/CONST';
import BaseModalProps from './types';
import ModalContent from './ModalContent';
import BaseModalProps, {ModalRef} from './types';

function BaseModal(
{
Expand All @@ -36,10 +37,11 @@ function BaseModal(
animationOutTiming,
statusBarTranslucent = true,
onLayout,
restoreFocusType,
avoidKeyboard = false,
children,
}: BaseModalProps,
ref: React.ForwardedRef<View>,
ref: React.ForwardedRef<ModalRef>,
) {
const theme = useTheme();
const styles = useThemeStyles();
Expand All @@ -50,6 +52,13 @@ function BaseModal(
const isVisibleRef = useRef(isVisible);
const wasVisible = usePrevious(isVisible);

const modalId = useMemo(() => ComposerFocusManager.getId(), []);

const saveFocusState = () => {
ComposerFocusManager.saveFocusState(modalId);
ComposerFocusManager.resetReadyToFocus(modalId);
};

/**
* Hides modal
* @param callHideCallback - Should we call the onModalHide callback
Expand All @@ -64,11 +73,9 @@ function BaseModal(
onModalHide();
}
Modal.onModalDidClose();
if (!fullscreen) {
ComposerFocusManager.setReadyToFocus();
}
ComposerFocusManager.tryRestoreFocusAfterClosedCompletely(modalId, restoreFocusType);
},
[shouldSetModalVisibility, onModalHide, fullscreen],
[shouldSetModalVisibility, onModalHide, restoreFocusType, modalId],
);

useEffect(() => {
Expand Down Expand Up @@ -116,9 +123,18 @@ function BaseModal(
};

const handleDismissModal = () => {
ComposerFocusManager.setReadyToFocus();
ComposerFocusManager.setReadyToFocus(modalId);
};

useImperativeHandle(
ref,
() => ({
removePromise: () => ComposerFocusManager.removePromise(modalId),
setReadyToFocus: () => ComposerFocusManager.setReadyToFocus(modalId),
}),
[modalId],
);

const {
modalStyle,
modalContainerStyle,
Expand Down Expand Up @@ -178,7 +194,7 @@ function BaseModal(
onModalShow={handleShowModal}
propagateSwipe={propagateSwipe}
onModalHide={hideModal}
onModalWillShow={() => ComposerFocusManager.resetReadyToFocus()}
onModalWillShow={saveFocusState}
onDismiss={handleDismissModal}
onSwipeComplete={onClose}
swipeDirection={swipeDirection}
Expand All @@ -201,12 +217,9 @@ function BaseModal(
onLayout={onLayout}
avoidKeyboard={avoidKeyboard}
>
<View
style={[styles.defaultModalContainer, modalContainerStyle, modalPaddingStyles, !isVisible && styles.pointerEventsNone]}
ref={ref}
>
{children}
</View>
<ModalContent onDismiss={handleDismissModal}>
<View style={[styles.defaultModalContainer, modalContainerStyle, modalPaddingStyles, !isVisible && styles.pointerEventsNone]}>{children}</View>
</ModalContent>
</ReactNativeModal>
);
}
Expand Down
18 changes: 18 additions & 0 deletions src/components/Modal/ModalContent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';

const propTypes = {
/** Modal contents */
children: PropTypes.node.isRequired,

/** called after modal content is dismissed */
onDismiss: PropTypes.func,
};

function ModalContent({children}) {
return children;
}

ModalContent.propTypes = propTypes;
ModalContent.displayName = 'ModalContent';

export default ModalContent;
21 changes: 21 additions & 0 deletions src/components/Modal/ModalContent.web.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import PropTypes from 'prop-types';
import React from 'react';

const propTypes = {
/** Modal contents */
children: PropTypes.node.isRequired,

/** called after modal content is dismissed */
onDismiss: PropTypes.func,
};

function ModalContent({children, onDismiss = () => {}}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(() => () => onDismiss(), []);
return children;
}

ModalContent.propTypes = propTypes;
ModalContent.displayName = 'ModalContent';

export default ModalContent;
34 changes: 22 additions & 12 deletions src/components/Modal/index.android.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import React from 'react';
import React, {useRef} from 'react';
import {AppState} from 'react-native';
import withWindowDimensions from '@components/withWindowDimensions';
import ComposerFocusManager from '@libs/ComposerFocusManager';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import BaseModalProps from './types';

AppState.addEventListener('focus', () => {
ComposerFocusManager.setReadyToFocus();
});

AppState.addEventListener('blur', () => {
ComposerFocusManager.resetReadyToFocus();
});
import BaseModalProps, {ModalRef} from './types';

// 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) {
function Modal({useNativeDriver = true, restoreFocusType, onModalHide, ...rest}: BaseModalProps) {
const modalRef = useRef<ModalRef>(null);
const hideModal = () => {
onModalHide();
if (restoreFocusType && restoreFocusType !== CONST.MODAL.RESTORE_TYPE.DEFAULT) {
modalRef?.current?.removePromise();
return;
}
const listener = AppState.addEventListener('focus', () => {
// TODO:del
console.debug('android is ready to focus');
listener.remove();
modalRef?.current?.setReadyToFocus();
});
};

return (
<BaseModal
useNativeDriver={useNativeDriver}
onModalHide={hideModal}
restoreFocusType={restoreFocusType}
// eslint-disable-next-line react/jsx-props-no-spreading
{...rest}
ref={modalRef}
>
{rest.children}
</BaseModal>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Modal/modalPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ const propTypes = {
* */
hideModalContentWhileAnimating: PropTypes.bool,

/** how to re-focus after the modal is dismissed */
restoreFocusType: PropTypes.oneOf(_.values(CONST.MODAL.RESTORE_TYPE)),

...windowDimensionsPropTypes,
};

Expand Down
9 changes: 9 additions & 0 deletions src/components/Modal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ type BaseModalProps = WindowDimensionsProps &
* See: https://github.com/react-native-modal/react-native-modal/pull/116
* */
hideModalContentWhileAnimating?: boolean;

/** how to re-focus after the modal is dismissed */
restoreFocusType?: ValueOf<typeof CONST.MODAL.RESTORE_TYPE>;
};

type ModalRef = {
removePromise: () => void;
setReadyToFocus: () => void;
};

export type {ModalRef};
export default BaseModalProps;
2 changes: 2 additions & 0 deletions src/components/PopoverMenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,13 @@ function PopoverMenu(props) {

return (
<PopoverWithMeasuredContent
restoreFocusType={props.restoreFocusType}
anchorPosition={props.anchorPosition}
anchorRef={props.anchorRef}
anchorAlignment={props.anchorAlignment}
onClose={props.onClose}
isVisible={props.isVisible}
onModalShow={props.onModalShow}
onModalHide={() => {
setFocusedIndex(-1);
if (selectedItemIndex.current !== null) {
Expand Down
50 changes: 38 additions & 12 deletions src/components/PopoverWithoutOverlay/index.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import React from 'react';
import React, {useMemo, useRef} from 'react';
import {View} from 'react-native';
import {SafeAreaInsetsContext} from 'react-native-safe-area-context';
import {isFunction, isObject} from 'underscore';
import ModalContent from '@components/Modal/ModalContent';
import {defaultProps, propTypes} from '@components/Popover/popoverPropTypes';
import {PopoverContext} from '@components/PopoverProvider';
import withWindowDimensions from '@components/withWindowDimensions';
import ComposerFocusManager from '@libs/ComposerFocusManager';
import getModalStyles from '@styles/getModalStyles';
import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import * as Modal from '@userActions/Modal';

function Popover(props) {
const styles = useThemeStyles();
const modalId = useMemo(() => ComposerFocusManager.getId(), []);
const containerRef = useRef();
const {onOpen, close} = React.useContext(PopoverContext);
const {modalStyle, modalContainerStyle, shouldAddTopSafeAreaMargin, shouldAddBottomSafeAreaMargin, shouldAddTopSafeAreaPadding, shouldAddBottomSafeAreaPadding} = getModalStyles(
'popover',
Expand All @@ -34,6 +39,8 @@ function Popover(props) {
anchorRef: props.anchorRef,
});
removeOnClose = Modal.setCloseModal(() => props.onClose(props.anchorRef));
ComposerFocusManager.saveFocusState(modalId, containerRef.current);
ComposerFocusManager.resetReadyToFocus(modalId);
} else {
props.onModalHide();
close(props.anchorRef);
Expand All @@ -51,14 +58,31 @@ function Popover(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isVisible]);

const restoreFocusTypeRef = useRef();
restoreFocusTypeRef.current = props.restoreFocusType;
const handleDismissContent = () => {
ComposerFocusManager.tryRestoreFocusAfterClosedCompletely(modalId, restoreFocusTypeRef.current);
// PopoverWithMeasuredContent delays the mounting of this popover, so here we also need to defer the restoration.
// In a follow-up PR, we can also consider how to improve the former.
setImmediate(() => ComposerFocusManager.setReadyToFocus(modalId));
};

if (!props.isVisible) {
return null;
}

return (
<View
style={[modalStyle, {zIndex: 1}]}
ref={props.withoutOverlayRef}
ref={(el) => {
containerRef.current = el;
if (isFunction(props.withoutOverlayRef)) {
props.withoutOverlayRef(el);
} else if (isObject(props.withoutOverlayRef)) {
// eslint-disable-next-line no-param-reassign
props.withoutOverlayRef.current = el;
}
}}
>
<SafeAreaInsetsContext.Consumer>
{(insets) => {
Expand All @@ -85,16 +109,18 @@ function Popover(props) {
insets,
});
return (
<View
style={{
...styles.defaultModalContainer,
...modalContainerStyle,
...modalPaddingStyles,
}}
ref={props.forwardedRef}
>
{props.children}
</View>
<ModalContent onDismiss={handleDismissContent}>
<View
style={{
...styles.defaultModalContainer,
...modalContainerStyle,
...modalPaddingStyles,
}}
ref={props.forwardedRef}
>
{props.children}
</View>
</ModalContent>
);
}}
</SafeAreaInsetsContext.Consumer>
Expand Down
Loading

0 comments on commit 4952dce

Please sign in to comment.