diff --git a/src/CONST.ts b/src/CONST.ts index 72a84925f594..9ef19bea8e22 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1387,6 +1387,12 @@ const CONST = { DELETE: 'delete', PRESERVE: 'preserve', }, + ANIMATION_TIMING: { + DEFAULT_IN: 300, + DEFAULT_OUT: 200, + FAB_IN: 350, + FAB_OUT: 200, + }, }, TIMING: { GET_ORDERED_REPORT_IDS: 'get_ordered_report_ids', diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index 687d952c195e..2cb3a25c58c6 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -2,9 +2,8 @@ import type {ForwardedRef} from 'react'; import React, {forwardRef, useEffect, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports import type {GestureResponderEvent, Role, Text, View} from 'react-native'; -import {Platform} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import Animated, {createAnimatedPropAdapter, Easing, interpolateColor, processColor, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {Easing, interpolateColor, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; @@ -21,30 +20,6 @@ import EducationalTooltip from './Tooltip/EducationalTooltip'; const AnimatedPath = Animated.createAnimatedComponent(Path); AnimatedPath.displayName = 'AnimatedPath'; -type AdapterPropsRecord = { - type: number; - payload?: number | null; -}; - -type AdapterProps = { - fill?: string | AdapterPropsRecord; - stroke?: string | AdapterPropsRecord; -}; - -const adapter = createAnimatedPropAdapter( - (props: AdapterProps) => { - if (Object.keys(props).includes('fill')) { - // eslint-disable-next-line no-param-reassign - props.fill = {type: 0, payload: processColor(props.fill)}; - } - if (Object.keys(props).includes('stroke')) { - // eslint-disable-next-line no-param-reassign - props.stroke = {type: 0, payload: processColor(props.stroke)}; - } - }, - ['fill', 'stroke'], -); - type FloatingActionButtonProps = { /* Callback to fire on request to toggle the FloatingActionButton */ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined) => void; @@ -63,7 +38,7 @@ type FloatingActionButtonProps = { }; function FloatingActionButton({onPress, isActive, accessibilityLabel, role, isTooltipAllowed}: FloatingActionButtonProps, ref: ForwardedRef) { - const {success, buttonDefaultBG, textLight, textDark} = useTheme(); + const {success, buttonDefaultBG, textLight} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; const fabPressable = useRef(null); @@ -95,22 +70,9 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role, isTo return { transform: [{rotate: `${sharedValue.get() * 135}deg`}], backgroundColor, - borderRadius, }; }); - const animatedProps = useAnimatedProps( - () => { - const fill = interpolateColor(sharedValue.get(), [0, 1], [textLight, textDark]); - - return { - fill, - }; - }, - undefined, - Platform.OS === 'web' ? undefined : adapter, - ); - const toggleFabAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => { hideProductTrainingTooltip(); // Drop focus to avoid blue focus ring. @@ -145,14 +107,14 @@ function FloatingActionButton({onPress, isActive, accessibilityLabel, role, isTo role={role} shouldUseHapticsOnLongPress={false} > - + diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index e59f8f6453fd..723366608c5a 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -1,7 +1,8 @@ -import {PortalHost} from '@gorhom/portal'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; +import type {ModalProps as ReactNativeModalProps} from 'react-native-modal'; import ReactNativeModal from 'react-native-modal'; +import type {ValueOf} from 'type-fest'; import ColorSchemeWrapper from '@components/ColorSchemeWrapper'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import useKeyboardState from '@hooks/useKeyboardState'; @@ -17,10 +18,26 @@ import Overlay from '@libs/Navigation/AppNavigator/Navigators/Overlay'; import variables from '@styles/variables'; import {areAllModalsHidden, closeTop, onModalDidClose, setCloseModal, setModalVisibility, willAlertModalBecomeVisible} from '@userActions/Modal'; import CONST from '@src/CONST'; +import BottomDockedModal from './BottomDockedModal'; +import type ModalProps from './BottomDockedModal/types'; import ModalContent from './ModalContent'; import ModalContext from './ModalContext'; import type BaseModalProps from './types'; +type ModalComponentProps = (ReactNativeModalProps | ModalProps) & { + type?: ValueOf; + shouldUseNewModal: boolean; +}; + +function ModalComponent({type, shouldUseNewModal, ...props}: ModalComponentProps) { + if (type === CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED && shouldUseNewModal) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; + } + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + function BaseModal( { isVisible, @@ -41,18 +58,22 @@ function BaseModal( hideModalContentWhileAnimating = false, animationInTiming, animationOutTiming, + animationInDelay, statusBarTranslucent = true, navigationBarTranslucent = true, onLayout, avoidKeyboard = false, children, shouldUseCustomBackdrop = false, + shouldUseNewModal = false, onBackdropPress, modalId, shouldEnableNewFocusManagement = false, restoreFocusType, shouldUseModalPaddingStyle = true, initialFocus = false, + swipeThreshold = 150, + swipeDirection, shouldPreventScrollOnFocus = false, }: BaseModalProps, ref: React.ForwardedRef, @@ -78,7 +99,6 @@ function BaseModal( } ComposerFocusManager.resetReadyToFocus(uniqueModalId); }, [shouldEnableNewFocusManagement, uniqueModalId]); - /** * Hides modal * @param callHideCallback - Should we call the onModalHide callback @@ -129,12 +149,12 @@ function BaseModal( [], ); - const handleShowModal = () => { + const handleShowModal = useCallback(() => { if (shouldSetModalVisibility) { setModalVisibility(true); } onModalShow(); - }; + }, [onModalShow, shouldSetModalVisibility]); const handleBackdropPress = (e?: KeyboardEvent) => { if (e?.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { @@ -155,7 +175,6 @@ function BaseModal( const { modalStyle, modalContainerStyle, - swipeDirection, animationIn: modalStyleAnimationIn, animationOut: modalStyleAnimationOut, shouldAddTopSafeAreaMargin, @@ -225,7 +244,7 @@ function BaseModal( collapsable={false} style={[styles.pAbsolute, {zIndex: 1}]} > - e.stopPropagation()} onBackdropPress={handleBackdropPress} @@ -239,6 +258,7 @@ function BaseModal( onDismiss={handleDismissModal} onSwipeComplete={() => onClose?.()} swipeDirection={swipeDirection} + swipeThreshold={swipeThreshold} isVisible={isVisible} backdropColor={theme.overlay} backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity} @@ -249,6 +269,7 @@ function BaseModal( deviceHeight={windowHeight} deviceWidth={windowWidth} animationIn={animationIn ?? modalStyleAnimationIn} + animationInDelay={animationInDelay} animationOut={animationOut ?? modalStyleAnimationOut} useNativeDriver={useNativeDriver} useNativeDriverForBackdrop={useNativeDriverForBackdrop} @@ -260,12 +281,13 @@ function BaseModal( onLayout={onLayout} avoidKeyboard={avoidKeyboard} customBackdrop={shouldUseCustomBackdrop ? : undefined} + type={type} + shouldUseNewModal={shouldUseNewModal} > - - + ); diff --git a/src/components/Modal/BottomDockedModal/Backdrop/index.tsx b/src/components/Modal/BottomDockedModal/Backdrop/index.tsx new file mode 100644 index 000000000000..392ea96a087b --- /dev/null +++ b/src/components/Modal/BottomDockedModal/Backdrop/index.tsx @@ -0,0 +1,92 @@ +import React, {useMemo} from 'react'; +import Animated, {Easing, Keyframe} from 'react-native-reanimated'; +import type {ReanimatedKeyframe} from 'react-native-reanimated/lib/typescript/layoutReanimation/animationBuilder/Keyframe'; +import type {BackdropProps} from '@components/Modal/BottomDockedModal/types'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0); + +/** + * Due to issues with react-native-reanimated Keyframes the easing type doesn't account for bezier functions + * and we also need to use internal .build() function to make the easing apply on each mount. + * + * This causes problems with both eslint & Typescript and is going to be fixed in react-native-reanimated 3.17 with these PRs merged: + * https://github.com/software-mansion/react-native-reanimated/pull/6960 + * https://github.com/software-mansion/react-native-reanimated/pull/6958 + * + * Once that's added we can apply our changes to files in BottomDockedModal/Backdrop/*.tsx and BottomDockedModal/Container/*.tsx + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +function Backdrop({ + style, + customBackdrop, + onBackdropPress, + animationInTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN, + animationOutTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_OUT, +}: BackdropProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const Entering = useMemo(() => { + const FadeIn = new Keyframe({ + from: {opacity: 0}, + to: { + opacity: 0.72, + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + // @ts-expect-error Internal function used to fix easing issue, should to be fixed in 3.17 + return FadeIn.duration(animationInTiming).build() as ReanimatedKeyframe; + }, [animationInTiming]); + + const Exiting = useMemo(() => { + const FadeOut = new Keyframe({ + from: {opacity: 0.72}, + to: { + opacity: 0, + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + // @ts-expect-error Internal function used to fix easing issue, should to be fixed in 3.17 + return FadeOut.duration(animationOutTiming).build() as ReanimatedKeyframe; + }, [animationOutTiming]); + + const BackdropOverlay = useMemo( + () => ( + + {!!customBackdrop && customBackdrop} + + ), + [Entering, Exiting, customBackdrop, style, styles.modalBackdrop], + ); + + if (!customBackdrop) { + return ( + + {BackdropOverlay} + + ); + } + + return BackdropOverlay; +} + +Backdrop.displayName = 'Backdrop'; + +export default Backdrop; diff --git a/src/components/Modal/BottomDockedModal/Backdrop/index.web.tsx b/src/components/Modal/BottomDockedModal/Backdrop/index.web.tsx new file mode 100644 index 000000000000..ff73faaa6d0e --- /dev/null +++ b/src/components/Modal/BottomDockedModal/Backdrop/index.web.tsx @@ -0,0 +1,88 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import Animated, {Easing, Keyframe} from 'react-native-reanimated'; +import type {BackdropProps} from '@components/Modal/BottomDockedModal/types'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0); + +/** + * Due to issues with react-native-reanimated Keyframes the easing type doesn't account for bezier functions + * and we also need to use internal .build() function to make the easing apply on each mount. + * + * This causes problems with both eslint & Typescript and is going to be fixed in react-native-reanimated 3.17 with these PRs merged: + * https://github.com/software-mansion/react-native-reanimated/pull/6960 + * https://github.com/software-mansion/react-native-reanimated/pull/6958 + * + * Once that's added we can apply our changes to files in BottomDockedModal/Backdrop/*.tsx and BottomDockedModal/Container/*.tsx + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +function Backdrop({ + style, + customBackdrop, + onBackdropPress, + animationInTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_IN, + animationOutTiming = CONST.MODAL.ANIMATION_TIMING.DEFAULT_OUT, +}: BackdropProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const Entering = useMemo(() => { + const FadeIn = new Keyframe({ + from: {opacity: 0}, + to: { + opacity: 0.72, + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + return FadeIn.duration(animationInTiming); + }, [animationInTiming]); + + const Exiting = useMemo(() => { + const FadeOut = new Keyframe({ + from: {opacity: 0.72}, + to: { + opacity: 0, + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + return FadeOut.duration(animationOutTiming); + }, [animationOutTiming]); + + if (!customBackdrop) { + return ( + + + + ); + } + + return ( + + {!!customBackdrop && customBackdrop} + + ); +} + +Backdrop.displayName = 'Backdrop'; + +export default Backdrop; diff --git a/src/components/Modal/BottomDockedModal/Container/index.tsx b/src/components/Modal/BottomDockedModal/Container/index.tsx new file mode 100644 index 000000000000..eb0725433929 --- /dev/null +++ b/src/components/Modal/BottomDockedModal/Container/index.tsx @@ -0,0 +1,89 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import Animated, {Easing, Keyframe, runOnJS} from 'react-native-reanimated'; +import type {ReanimatedKeyframe} from 'react-native-reanimated/lib/typescript/layoutReanimation/animationBuilder/Keyframe'; +import type ModalProps from '@components/Modal/BottomDockedModal/types'; +import type {ContainerProps} from '@components/Modal/BottomDockedModal/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0); + +/** + * Due to issues with react-native-reanimated Keyframes the easing type doesn't account for bezier functions + * and we also need to use internal .build() function to make the easing apply on each mount. + * + * This causes problems with both eslint & Typescript and is going to be fixed in react-native-reanimated 3.17 with these PRs merged: + * https://github.com/software-mansion/react-native-reanimated/pull/6960 + * https://github.com/software-mansion/react-native-reanimated/pull/6958 + * + * Once that's added we can apply our changes to files in BottomDockedModal/Backdrop/*.tsx and BottomDockedModal/Container/*.tsx + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +function Container({style, animationInTiming = 300, animationOutTiming = 300, onCloseCallBack, onOpenCallBack, ...props}: Partial & ContainerProps) { + const styles = useThemeStyles(); + + const Entering = useMemo(() => { + const SlideIn = new Keyframe({ + from: {transform: [{translateY: '100%'}]}, + to: { + transform: [{translateY: '0%'}], + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + return ( + SlideIn.duration(animationInTiming) + .withCallback(() => { + 'worklet'; + + runOnJS(onOpenCallBack)(); + }) + // @ts-expect-error Internal function used to fix easing issue, should to be fixed in 3.17 + .build() as ReanimatedKeyframe + ); + }, [animationInTiming, onOpenCallBack]); + + const Exiting = useMemo(() => { + const SlideOut = new Keyframe({ + from: {transform: [{translateY: '0%'}]}, + to: { + transform: [{translateY: '100%'}], + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + return ( + SlideOut.duration(animationOutTiming) + .withCallback(() => { + 'worklet'; + + runOnJS(onCloseCallBack)(); + }) + // @ts-expect-error Internal function used to fix easing issue, should to be fixed in 3.17 + .build() as ReanimatedKeyframe + ); + }, [animationOutTiming, onCloseCallBack]); + + return ( + + + {props.children} + + + ); +} + +Container.displayName = 'ModalContainer'; + +export default Container; diff --git a/src/components/Modal/BottomDockedModal/Container/index.web.tsx b/src/components/Modal/BottomDockedModal/Container/index.web.tsx new file mode 100644 index 000000000000..1f905c5bf210 --- /dev/null +++ b/src/components/Modal/BottomDockedModal/Container/index.web.tsx @@ -0,0 +1,85 @@ +import React, {useEffect, useMemo} from 'react'; +import Animated, {Easing, Keyframe, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import type ModalProps from '@components/Modal/BottomDockedModal/types'; +import type {ContainerProps} from '@components/Modal/BottomDockedModal/types'; +import useThemeStyles from '@hooks/useThemeStyles'; + +const easing = Easing.bezier(0.76, 0.0, 0.24, 1.0); + +/** + * Due to issues with react-native-reanimated Keyframes the easing type doesn't account for bezier functions + * and we also need to use internal .build() function to make the easing apply on each mount. + * + * This causes problems with both eslint & Typescript and is going to be fixed in react-native-reanimated 3.17 with these PRs merged: + * https://github.com/software-mansion/react-native-reanimated/pull/6960 + * https://github.com/software-mansion/react-native-reanimated/pull/6958 + * + * Once that's added we can apply our changes to files in BottomDockedModal/Backdrop/*.tsx and BottomDockedModal/Container/*.tsx + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +function Container({style, animationInTiming = 300, animationOutTiming = 300, onOpenCallBack, onCloseCallBack, ...props}: ModalProps & ContainerProps) { + const styles = useThemeStyles(); + const opacity = useSharedValue(0); + const isInitiated = useSharedValue(false); + + useEffect( + () => () => { + setTimeout(onCloseCallBack, animationOutTiming); + }, + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + [], + ); + + useEffect(() => { + if (isInitiated.get()) { + return; + } + isInitiated.set(true); + opacity.set( + withTiming(1, {duration: animationInTiming}, () => { + 'worklet'; + + runOnJS(onOpenCallBack)(); + }), + ); + }, [animationInTiming, onOpenCallBack, opacity, isInitiated]); + + const animatedStyles = useAnimatedStyle(() => { + 'worklet'; + + return {opacity: opacity.get()}; + }, [opacity]); + + const Exiting = useMemo(() => { + const FadeOut = new Keyframe({ + from: {opacity: 1}, + to: { + opacity: 0, + // @ts-expect-error Types mismatch in reanimated, should to be fixed in 3.17 + easing, + }, + }); + + return FadeOut.duration(animationOutTiming); + }, [animationOutTiming]); + + return ( + + + {props.children} + + + ); +} + +Container.displayName = 'ModalContainer'; + +export default Container; diff --git a/src/components/Modal/BottomDockedModal/index.tsx b/src/components/Modal/BottomDockedModal/index.tsx new file mode 100644 index 000000000000..73e7a4d75b4b --- /dev/null +++ b/src/components/Modal/BottomDockedModal/index.tsx @@ -0,0 +1,227 @@ +import noop from 'lodash/noop'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {ViewStyle} from 'react-native'; +import {BackHandler, DeviceEventEmitter, Dimensions, KeyboardAvoidingView, Modal, View} from 'react-native'; +import {LayoutAnimationConfig} from 'react-native-reanimated'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getPlatform from '@libs/getPlatform'; +import CONST from '@src/CONST'; +import Backdrop from './Backdrop'; +import Container from './Container'; +import type ModalProps from './types'; + +function BottomDockedModal({ + testID, + animationInDelay, + animationInTiming = 300, + animationOutTiming = 300, + avoidKeyboard = false, + coverScreen = true, + children, + hasBackdrop = true, + backdropColor = 'black', + backdropOpacity = 0.72, + customBackdrop = null, + deviceHeight: deviceHeightProp = null, + deviceWidth: deviceWidthProp = null, + isVisible = false, + onModalWillShow = noop, + onModalShow = noop, + onModalWillHide = noop, + onModalHide = noop, + onDismiss, + onBackdropPress = noop, + onBackButtonPress = noop, + style, + statusBarTranslucent = false, + ...props +}: ModalProps) { + const [isVisibleState, setIsVisibleState] = useState(isVisible); + const [isContainerOpen, setIsContainerOpen] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + const [deviceWidth, setDeviceWidth] = useState(() => Dimensions.get('window').width); + const [deviceHeight, setDeviceHeight] = useState(() => Dimensions.get('window').height); + + const styles = useThemeStyles(); + + const handleDimensionsUpdate = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (deviceHeightProp || deviceWidthProp) { + return; + } + + const deviceWidthTemp = Dimensions.get('window').width; + const deviceHeightTemp = Dimensions.get('window').height; + if (deviceWidthTemp !== deviceWidth || deviceHeightTemp !== deviceHeight) { + setDeviceWidth(deviceWidthTemp); + setDeviceHeight(deviceHeightTemp); + } + }, [deviceWidth, deviceWidthProp, deviceHeight, deviceHeightProp]); + + const onBackButtonPressHandler = useCallback(() => { + if (isVisible) { + onBackButtonPress(); + return true; + } + return false; + }, [isVisible, onBackButtonPress]); + + const handleEscape = useCallback( + (e: KeyboardEvent) => { + if (e.key !== 'Escape' || onBackButtonPressHandler() !== true) { + return; + } + e.stopImmediatePropagation(); + }, + [onBackButtonPressHandler], + ); + + useEffect(() => { + const deviceEventListener = DeviceEventEmitter.addListener('didUpdateDimensions', handleDimensionsUpdate); + if (getPlatform() === CONST.PLATFORM.WEB) { + document.body.addEventListener('keyup', handleEscape, {capture: true}); + } else { + BackHandler.addEventListener('hardwareBackPress', onBackButtonPressHandler); + } + + return () => { + if (getPlatform() === CONST.PLATFORM.WEB) { + document.body.removeEventListener('keyup', handleEscape, {capture: true}); + } else { + BackHandler.removeEventListener('hardwareBackPress', onBackButtonPressHandler); + } + deviceEventListener.remove(); + }; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); + + useEffect( + () => () => { + onModalWillHide(); + + setIsVisibleState(false); + setIsContainerOpen(false); + }, + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + [], + ); + + useEffect(() => { + if (isVisible && !isContainerOpen && !isTransitioning) { + onModalWillShow(); + + setIsVisibleState(true); + setIsTransitioning(true); + } else if (!isVisible && isContainerOpen && !isTransitioning) { + onModalWillHide(); + + setIsVisibleState(false); + setIsTransitioning(true); + } + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isVisible, isContainerOpen, isTransitioning]); + + const backdropStyle: ViewStyle = useMemo(() => { + return { + width: deviceWidthProp ?? deviceWidth, + height: deviceHeightProp ?? deviceHeight, + backgroundColor: backdropColor, + opacity: getPlatform() === CONST.PLATFORM.WEB ? backdropOpacity : 1, + }; + }, [deviceHeightProp, deviceWidthProp, deviceWidth, deviceHeight, backdropColor, backdropOpacity]); + + const onOpenCallBack = useCallback(() => { + setIsTransitioning(false); + setIsContainerOpen(true); + onModalShow(); + }, [onModalShow]); + + const onCloseCallBack = useCallback(() => { + setIsTransitioning(false); + setIsContainerOpen(false); + if (getPlatform() !== CONST.PLATFORM.IOS) { + onModalHide(); + } + }, [onModalHide]); + + const containerView = ( + + {children} + + ); + + const backdropView = ( + + ); + + if (!coverScreen && isVisibleState) { + return ( + + {isVisibleState && ( + <> + {hasBackdrop && backdropView} + {containerView} + + )} + + ); + } + + return ( + + { + onDismiss?.(); + if (getPlatform() === CONST.PLATFORM.IOS) { + onModalHide(); + } + }} + // eslint-disable-next-line react/jsx-props-no-spreading + {...props} + > + {isVisibleState && hasBackdrop && backdropView} + + {avoidKeyboard ? ( + + {isVisibleState && containerView} + + ) : ( + isVisibleState && containerView + )} + + + ); +} + +BottomDockedModal.displayName = 'BottomDockedModal'; + +export default BottomDockedModal; diff --git a/src/components/Modal/BottomDockedModal/types.ts b/src/components/Modal/BottomDockedModal/types.ts new file mode 100644 index 000000000000..27d4e4298f6c --- /dev/null +++ b/src/components/Modal/BottomDockedModal/types.ts @@ -0,0 +1,150 @@ +import type {ReactNode} from 'react'; +import type {NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native'; +import type {ModalProps as ReactNativeModalProps} from 'react-native-modal'; +import type {SharedValue} from 'react-native-reanimated'; +import type {ValueOf} from 'type-fest'; + +type GestureProps = { + /** Height of the device (used for positioning) */ + deviceHeight?: number | null; + + /** Width of the device (used for positioning) */ + deviceWidth?: number | null; +}; + +type ModalProps = ViewProps & + GestureProps & { + /** Content inside the modal */ + children: ReactNode; + + /** Style applied to the modal container */ + style?: StyleProp; + + /** Callback when the modal is dismissed */ + onDismiss?: () => void; + + /** Callback when the modal is shown */ + onShow?: () => void; + + /** Whether to use hardware acceleration for animations */ + hardwareAccelerated?: boolean; + + /** Callback when device orientation changes */ + onOrientationChange?: ( + orientation: NativeSyntheticEvent<{ + orientation: 'portrait' | 'landscape'; + }>, + ) => void; + + /** The presentation style of the modal */ + presentationStyle?: 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen'; + + /** Default ModalProps Provided */ + /** Whether to use the native driver for the backdrop animation */ + useNativeDriverForBackdrop?: boolean; + + /** Enum for animation type when modal appears */ + animationIn?: ValueOf>; + + /** Duration of the animation when modal appears */ + animationInTiming?: number; + + /** Enum for animation type when modal disappears */ + animationOut?: ValueOf>; + + /** Duration of the animation when modal disappears */ + animationOutTiming?: number; + + /** Duration of the animation delay when modal appears */ + animationInDelay?: number; + + /** Whether to avoid keyboard overlap during modal display */ + avoidKeyboard?: boolean; + + /** Whether the modal should cover the entire screen */ + coverScreen?: boolean; + + /** Whether the modal should have a backdrop */ + hasBackdrop?: boolean; + + /** Color of the backdrop */ + backdropColor?: string; + + /** Opacity of the backdrop */ + backdropOpacity?: number; + + /** Duration of backdrop transition when modal appears */ + backdropTransitionInTiming?: number; + + /** Duration of backdrop transition when modal disappears */ + backdropTransitionOutTiming?: number; + + /** Custom component to use as the backdrop */ + customBackdrop?: ReactNode; + + /** Whether to hide modal content during animations */ + hideModalContentWhileAnimating?: boolean; + + /** Whether the modal is visible */ + isVisible?: boolean; + + /** Callback when modal has fully appeared */ + onModalShow?: () => void; + + /** Callback when modal is about to appear */ + onModalWillShow?: () => void; + + /** Callback when modal has fully disappeared */ + onModalHide?: () => void; + + /** Callback when modal is about to disappear */ + onModalWillHide?: () => void; + + /** Callback when the backdrop is pressed */ + onBackdropPress?: () => void; + + /** Callback when the back button is pressed (on Android) */ + onBackButtonPress?: () => void; + + /** Whether the status bar should be translucent when the modal is visible */ + statusBarTranslucent?: boolean; + + /** List of supported orientations for the modal */ + supportedOrientations?: Array<'portrait' | 'portrait-upside-down' | 'landscape' | 'landscape-left' | 'landscape-right'>; + + navigationBarTranslucent?: boolean; + }; + +type BackdropProps = { + /** Style applied to the modal backdrop */ + style: StyleProp; + + /** Custom backdrop component */ + customBackdrop?: ReactNode; + + /** Callback fired when pressing the backdrop */ + onBackdropPress?: () => void; + + /** Delay set to animation on enter */ + animationInDelay?: number; + + /** Timing of animation on enter */ + animationInTiming?: number; + + /** Timing of animation on exit */ + animationOutTiming?: number; +}; + +type ContainerProps = { + /** This function is called by open animation callback */ + onOpenCallBack: () => void; + + /** This function is called by close animation callback */ + onCloseCallBack: () => void; + + /** Position animated by pan gesture */ + panPosition?: {translateX: SharedValue; translateY: SharedValue}; +}; + +export default ModalProps; +export type {BackdropProps, ContainerProps, GestureProps}; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index 6e8648630156..0ed3c91c7a3c 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -1,8 +1,9 @@ import type {FocusTrapProps} from 'focus-trap-react'; -import type {ViewStyle} from 'react-native'; -import type {ModalProps} from 'react-native-modal'; +import type {GestureResponderEvent, PanResponderGestureState, ViewStyle} from 'react-native'; +import type {Direction, ModalProps as ReactNativeModalProps} from 'react-native-modal'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; +import type BottomDockedModalProps from './BottomDockedModal/types'; type FocusTrapOptions = Exclude; @@ -17,86 +18,99 @@ type WindowState = { shouldGoBack: boolean; }; -type BaseModalProps = Partial & { - /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ - fullscreen?: boolean; +type BaseModalProps = Partial & + Partial & { + /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ + fullscreen?: boolean; - /** Should we close modal on outside click */ - shouldCloseOnOutsideClick?: boolean; + /** Should we close modal on outside click */ + shouldCloseOnOutsideClick?: boolean; - /** Should we announce the Modal visibility changes? */ - shouldSetModalVisibility?: boolean; + /** Should we announce the Modal visibility changes? */ + shouldSetModalVisibility?: boolean; - /** Callback method fired when the user requests to close the modal */ - onClose: () => void; + /** Callback method fired when the user requests to close the modal */ + onClose: () => void; - /** Function to call when the user presses on the modal backdrop */ - onBackdropPress?: () => void; + /** Function to call when the user presses on the modal backdrop */ + onBackdropPress?: () => void; - /** State that determines whether to display the modal or not */ - isVisible: boolean; + /** State that determines whether to display the modal or not */ + isVisible: boolean; - /** Callback method fired when the modal is hidden */ - onModalHide?: () => void; + /** Callback method fired when the modal is hidden */ + onModalHide?: () => void; - /** Callback method fired when the modal is shown */ - onModalShow?: () => void; + /** Callback method fired when the modal is shown */ + onModalShow?: () => void; - /** Style of modal to display */ - type?: ValueOf; + /** Style of modal to display */ + type?: ValueOf; - /** The anchor position of a popover modal. Has no effect on other modal types. */ - popoverAnchorPosition?: PopoverAnchorPosition; + /** The anchor position of a popover modal. Has no effect on other modal types. */ + popoverAnchorPosition?: PopoverAnchorPosition; - outerStyle?: ViewStyle; + outerStyle?: ViewStyle; - /** Whether the modal should go under the system statusbar */ - statusBarTranslucent?: boolean; + /** Whether the modal should go under the system statusbar */ + statusBarTranslucent?: boolean; - /** Whether the modal should go under the system navigation bar */ - navigationBarTranslucent?: boolean; + /** Whether the modal should go under the system navigation bar */ + navigationBarTranslucent?: boolean; - /** Whether the modal should avoid the keyboard */ - avoidKeyboard?: boolean; + /** Whether the modal should avoid the keyboard */ + avoidKeyboard?: boolean; - /** Modal container styles */ - innerContainerStyle?: ViewStyle; + /** Modal container styles */ + innerContainerStyle?: ViewStyle; - /** - * Whether the modal should hide its content while animating. On iOS, set to true - * if `useNativeDriver` is also true, to avoid flashes in the UI. - * - * See: https://github.com/react-native-modal/react-native-modal/pull/116 - * */ - hideModalContentWhileAnimating?: boolean; + /** + * Whether the modal should hide its content while animating. On iOS, set to true + * if `useNativeDriver` is also true, to avoid flashes in the UI. + * + * See: https://github.com/react-native-modal/react-native-modal/pull/116 + * */ + hideModalContentWhileAnimating?: boolean; - /** Whether handle navigation back when modal show. */ - shouldHandleNavigationBack?: boolean; + /** Whether handle navigation back when modal show. */ + shouldHandleNavigationBack?: boolean; - /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ - shouldUseCustomBackdrop?: boolean; + /** Should we use a custom backdrop for the modal? (This prevents focus issues on desktop) */ + shouldUseCustomBackdrop?: boolean; - /** unique id for the modal */ - modalId?: number; + /** unique id for the modal */ + modalId?: number; - /** - * 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; + /** + * 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; + /** How to re-focus after the modal is dismissed */ + restoreFocusType?: ValueOf; - /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ - shouldUseModalPaddingStyle?: boolean; + /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ + shouldUseModalPaddingStyle?: boolean; - /** Used to set the element that should receive the initial focus */ - initialFocus?: FocusTrapOptions['initialFocus']; + /** Whether swipe gestures should propagate to parent components */ + propagateSwipe?: boolean | ((event?: GestureResponderEvent, gestureState?: PanResponderGestureState) => boolean); - /** Whether to prevent the focus trap from scrolling the element into view. */ - shouldPreventScrollOnFocus?: boolean; -}; + /** After swipe more than threshold modal will close */ + swipeThreshold?: number; + + /** In which direction modal will swipe */ + swipeDirection?: Direction; + + /** Whether modals with type CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED should use new modal component */ + shouldUseNewModal?: boolean; + + /** Used to set the element that should receive the initial focus */ + initialFocus?: FocusTrapOptions['initialFocus']; + + /** Whether to prevent the focus trap from scrolling the element into view. */ + shouldPreventScrollOnFocus?: boolean; + }; export default BaseModalProps; export type {PopoverAnchorPosition, WindowState}; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index b8dc71aef515..9cf3d836105a 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -12,8 +12,8 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as Browser from '@libs/Browser'; -import * as Modal from '@userActions/Modal'; +import {isSafari} from '@libs/Browser'; +import {close} from '@userActions/Modal'; import CONST from '@src/CONST'; import type {AnchorPosition} from '@src/styles'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -23,6 +23,7 @@ import FocusTrapForModal from './FocusTrap/FocusTrapForModal'; import * as Expensicons from './Icon/Expensicons'; import type {MenuItemProps} from './MenuItem'; import MenuItem from './MenuItem'; +import type BottomDockedModalProps from './Modal/BottomDockedModal/types'; import type BaseModalProps from './Modal/types'; import OfflineWithFeedback from './OfflineWithFeedback'; import PopoverWithMeasuredContent from './PopoverWithMeasuredContent'; @@ -56,7 +57,11 @@ type PopoverMenuItem = MenuItemProps & { pendingAction?: PendingAction; }; -type PopoverModalProps = Pick; +type PopoverModalProps = Pick & + Pick & { + /** Whether modals with type CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED should use new modal component */ + shouldUseNewModal?: boolean; + }; type PopoverMenuProps = Partial & { /** Callback method fired when the user requests to close the modal */ @@ -162,8 +167,10 @@ function PopoverMenu({ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }, animationIn = 'fadeIn', + animationInDelay, animationOut = 'fadeOut', animationInTiming = CONST.ANIMATED_TRANSITION, + animationOutTiming, disableAnimation = true, withoutOverlay = false, shouldSetModalVisibility = true, @@ -177,6 +184,7 @@ function PopoverMenu({ shouldUseScrollView = false, shouldUpdateFocusedIndex = true, shouldUseModalPaddingStyle, + shouldUseNewModal, testID, }: PopoverMenuProps) { const styles = useThemeStyles(); @@ -202,9 +210,9 @@ function PopoverMenu({ setEnteredSubMenuIndexes([...enteredSubMenuIndexes, index]); const selectedSubMenuItemIndex = selectedItem?.subMenuItems.findIndex((option) => option.isSelected); setFocusedIndex(selectedSubMenuItemIndex); - } else if (selectedItem.shouldCallAfterModalHide && !Browser.isSafari()) { + } else if (selectedItem.shouldCallAfterModalHide && !isSafari()) { onItemSelected?.(selectedItem, index); - Modal.close( + close( () => { selectedItem.onSelected?.(); }, @@ -352,7 +360,9 @@ function PopoverMenu({ onModalShow={onModalShow} animationIn={animationIn} animationOut={animationOut} + animationInDelay={animationInDelay} animationInTiming={animationInTiming} + animationOutTiming={animationOutTiming} disableAnimation={disableAnimation} fromSidebarMediumScreen={fromSidebarMediumScreen} withoutOverlay={withoutOverlay} @@ -363,6 +373,7 @@ function PopoverMenu({ innerContainerStyle={innerContainerStyle} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} testID={testID} + shouldUseNewModal={shouldUseNewModal} > diff --git a/src/components/PopoverWithMeasuredContent.tsx b/src/components/PopoverWithMeasuredContent.tsx index a07f2bd04f7e..7d83d8525b5c 100644 --- a/src/components/PopoverWithMeasuredContent.tsx +++ b/src/components/PopoverWithMeasuredContent.tsx @@ -60,6 +60,7 @@ function PopoverWithMeasuredContent({ shoudSwitchPositionIfOverflow = false, shouldHandleNavigationBack = false, shouldEnableNewFocusManagement, + shouldUseNewModal = false, ...props }: PopoverWithMeasuredContentProps) { const styles = useThemeStyles(); @@ -153,6 +154,7 @@ function PopoverWithMeasuredContent({ // eslint-disable-next-line react/jsx-props-no-spreading {...props} anchorPosition={shiftedAnchorPosition} + shouldUseNewModal={shouldUseNewModal} > {children} diff --git a/src/languages/en.ts b/src/languages/en.ts index 6f556af89b08..8b330a3d1879 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1158,6 +1158,9 @@ const translations = { `Please upload an image larger than ${minHeightInPx}x${minWidthInPx} pixels and smaller than ${maxHeightInPx}x${maxWidthInPx} pixels.`, notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `Profile picture must be one of the following types: ${allowedExtensions.join(', ')}.`, }, + modal: { + backdropLabel: 'Modal Backdrop', + }, profilePage: { profile: 'Profile', preferredPronouns: 'Preferred pronouns', diff --git a/src/languages/es.ts b/src/languages/es.ts index 334b7223c2c5..5dde670e48b4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1156,6 +1156,9 @@ const translations = { `Por favor, elige una imagen más grande que ${minHeightInPx}x${minWidthInPx} píxeles y más pequeña que ${maxHeightInPx}x${maxWidthInPx} píxeles.`, notAllowedExtension: ({allowedExtensions}: NotAllowedExtensionParams) => `La foto de perfil debe ser de uno de los siguientes tipos: ${allowedExtensions.join(', ')}.`, }, + modal: { + backdropLabel: 'Fondo del Modal', + }, profilePage: { profile: 'Perfil', preferredPronouns: 'Pronombres preferidos', diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index 2f61efe871d5..acd63a84cc25 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -537,6 +537,9 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT anchorPosition={styles.createMenuPositionSidebar(windowHeight)} onItemSelected={hideCreateMenu} fromSidebarMediumScreen={!shouldUseNarrowLayout} + animationInTiming={CONST.MODAL.ANIMATION_TIMING.FAB_IN} + animationOutTiming={CONST.MODAL.ANIMATION_TIMING.FAB_OUT} + shouldUseNewModal menuItems={menuItems.map((item) => { return { ...item, diff --git a/src/styles/index.ts b/src/styles/index.ts index 1b0ed63e0a2f..9c4db939d366 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2721,6 +2721,25 @@ const styles = (theme: ThemeColors) => borderColor: theme.transparent, }, + modalContainer: {height: '100%'}, + + modalAnimatedContainer: {width: '100%'}, + + modalContainerBox: { + zIndex: 2, + opacity: 1, + backgroundColor: 'transparent', + }, + + modalBackdrop: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: 'black', + }, + reportActionContextMenuMiniButton: { height: 28, width: 28,