diff --git a/src/CONST.ts b/src/CONST.ts index 9aede5c263e4..d33cf174cf48 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -63,6 +63,8 @@ const CONST = { // Note: Group and Self-DM excluded as these are not tied to a Workspace WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], ANDROID_PACKAGE_NAME, + ANIMATED_HIGHLIGHT_DELAY: 500, + ANIMATED_HIGHLIGHT_DURATION: 500, ANIMATED_TRANSITION: 300, ANIMATED_TRANSITION_FROM_VALUE: 100, ANIMATION_IN_TIMING: 100, diff --git a/src/components/HighlightableMenuItem.tsx b/src/components/HighlightableMenuItem.tsx new file mode 100644 index 000000000000..7b8a431003ee --- /dev/null +++ b/src/components/HighlightableMenuItem.tsx @@ -0,0 +1,37 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef} from 'react'; +import type {View} from 'react-native'; +import {StyleSheet} from 'react-native'; +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; +import useThemeStyles from '@hooks/useThemeStyles'; +import MenuItem from './MenuItem'; +import type {MenuItemProps} from './MenuItem'; + +type Props = MenuItemProps & { + /** Should the menu item be highlighted? */ + highlighted?: boolean; +}; + +function HighlightableMenuItem({wrapperStyle, highlighted, ...restOfProps}: Props, ref: ForwardedRef) { + const styles = useThemeStyles(); + + const flattenedWrapperStyles = StyleSheet.flatten(wrapperStyle); + const animatedHighlightStyle = useAnimatedHighlightStyle({ + shouldHighlight: highlighted ?? false, + height: flattenedWrapperStyles?.height ? Number(flattenedWrapperStyles.height) : styles.sectionMenuItem.height, + borderRadius: flattenedWrapperStyles?.borderRadius ? Number(flattenedWrapperStyles.borderRadius) : styles.sectionMenuItem.borderRadius, + }); + + return ( + + ); +} + +HighlightableMenuItem.displayName = 'HighlightableMenuItem'; + +export default forwardRef(HighlightableMenuItem); diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 46a0766c1057..4d6f79bd0196 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -71,6 +71,9 @@ type MenuItemBaseProps = { /** Used to apply offline styles to child text components */ style?: StyleProp; + /** Outer wrapper styles */ + outerWrapperStyle?: StyleProp; + /** Any additional styles to apply */ wrapperStyle?: StyleProp; @@ -257,6 +260,7 @@ function MenuItem( badgeText, style, wrapperStyle, + outerWrapperStyle, containerStyle, titleStyle, hoverAndPressStyle, @@ -426,6 +430,7 @@ function MenuItem( onPressIn={() => shouldBlockSelection && isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={ControlSelection.unblock} onSecondaryInteraction={onSecondaryInteraction} + wrapperStyle={outerWrapperStyle} style={({pressed}) => [ containerStyle, diff --git a/src/components/PressableWithSecondaryInteraction/index.tsx b/src/components/PressableWithSecondaryInteraction/index.tsx index 5e2de765f733..cbcf8523d9a4 100644 --- a/src/components/PressableWithSecondaryInteraction/index.tsx +++ b/src/components/PressableWithSecondaryInteraction/index.tsx @@ -13,6 +13,7 @@ function PressableWithSecondaryInteraction( children, inline = false, style, + wrapperStyle, enableLongPressWithHover = false, withoutFocusOnSecondaryInteraction = false, needsOffscreenAlphaCompositing = false, @@ -96,7 +97,7 @@ function PressableWithSecondaryInteraction( // ESLint is disabled here to propagate all the props, enhancing PressableWithSecondaryInteraction's versatility across different use cases. // eslint-disable-next-line react/jsx-props-no-spreading {...rest} - wrapperStyle={StyleUtils.combineStyles(DeviceCapabilities.canUseTouchScreen() ? [styles.userSelectNone, styles.noSelect] : [], inlineStyle)} + wrapperStyle={[StyleUtils.combineStyles(DeviceCapabilities.canUseTouchScreen() ? [styles.userSelectNone, styles.noSelect] : [], inlineStyle), wrapperStyle]} onLongPress={onSecondaryInteraction ? executeSecondaryInteraction : undefined} pressDimmingValue={activeOpacity} ref={pressableRef} diff --git a/src/hooks/useAnimatedHighlightStyle/config.native.ts b/src/hooks/useAnimatedHighlightStyle/config.native.ts new file mode 100644 index 000000000000..a62d3a33039e --- /dev/null +++ b/src/hooks/useAnimatedHighlightStyle/config.native.ts @@ -0,0 +1,5 @@ +const DELAY_FACTOR = 1.85; + +export default {}; + +export {DELAY_FACTOR}; diff --git a/src/hooks/useAnimatedHighlightStyle/config.ts b/src/hooks/useAnimatedHighlightStyle/config.ts new file mode 100644 index 000000000000..6010c8c33aa7 --- /dev/null +++ b/src/hooks/useAnimatedHighlightStyle/config.ts @@ -0,0 +1,8 @@ +import {isMobile} from '@libs/Browser'; + +// It takes varying amount of time to navigate to a new page on mobile and desktop +// This variable takes that into account +const DELAY_FACTOR = isMobile() ? 1 : 0.2; +export default {}; + +export {DELAY_FACTOR}; diff --git a/src/hooks/useAnimatedHighlightStyle/index.ts b/src/hooks/useAnimatedHighlightStyle/index.ts new file mode 100644 index 000000000000..e438bd2473fa --- /dev/null +++ b/src/hooks/useAnimatedHighlightStyle/index.ts @@ -0,0 +1,64 @@ +import React from 'react'; +import {InteractionManager} from 'react-native'; +import {Easing, interpolate, interpolateColor, runOnJS, useAnimatedStyle, useSharedValue, withDelay, withSequence, withTiming} from 'react-native-reanimated'; +import useTheme from '@hooks/useTheme'; +import CONST from '@src/CONST'; +import {DELAY_FACTOR} from './config'; + +type Props = { + /** Border radius of the wrapper */ + borderRadius: number; + + /** Height of the item that is to be faded */ + height: number; + + /** Whether the item should be highlighted */ + shouldHighlight: boolean; + + /** Duration of the highlight animation */ + highlightDuration?: number; + + /** Delay before the highlight animation starts */ + delay?: number; +}; + +/** + * Returns a highlight style that interpolates the colour, height and opacity giving a fading effect. + */ +export default function useAnimatedHighlightStyle({ + borderRadius, + shouldHighlight, + highlightDuration = CONST.ANIMATED_HIGHLIGHT_DURATION, + delay = CONST.ANIMATED_HIGHLIGHT_DELAY, + height, +}: Props) { + const actualDelay = delay * DELAY_FACTOR; + const repeatableProgress = useSharedValue(0); + const nonRepeatableProgress = useSharedValue(shouldHighlight ? 0 : 1); + const theme = useTheme(); + + const highlightBackgroundStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor(repeatableProgress.value, [0, 1], ['rgba(0, 0, 0, 0)', theme.border]), + height: interpolate(nonRepeatableProgress.value, [0, 1], [0, height]), + opacity: interpolate(nonRepeatableProgress.value, [0, 1], [0, 1]), + borderRadius, + })); + + React.useEffect(() => { + if (!shouldHighlight) { + return; + } + + InteractionManager.runAfterInteractions(() => { + runOnJS(() => { + nonRepeatableProgress.value = withDelay(actualDelay, withTiming(1, {duration: highlightDuration, easing: Easing.inOut(Easing.ease)})); + repeatableProgress.value = withSequence( + withDelay(actualDelay, withTiming(1, {duration: highlightDuration, easing: Easing.inOut(Easing.ease)})), + withDelay(actualDelay, withTiming(0, {duration: highlightDuration, easing: Easing.inOut(Easing.ease)})), + ); + })(); + }); + }, [shouldHighlight, highlightDuration, actualDelay, repeatableProgress, nonRepeatableProgress]); + + return highlightBackgroundStyle; +} diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 08c493e0c0aa..977a5c4087d0 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -3688,9 +3688,10 @@ function openPolicyDistanceRatesPage(policyID?: string) { function navigateWhenEnableFeature(policyID: string, featureRoute: Route) { const isNarrowLayout = getIsNarrowLayout(); - if (isNarrowLayout) { - Navigation.goBack(ROUTES.WORKSPACE_INITIAL.getRoute(policyID)); + setTimeout(() => { + Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID)); + }, 1000); return; } diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 512b637f7f46..6d67b4549f29 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -8,8 +8,8 @@ import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import HighlightableMenuItem from '@components/HighlightableMenuItem'; import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -225,6 +225,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r ]; const prevPolicy = usePrevious(policy); + const prevProtectedMenuItems = usePrevious(protectedCollectPolicyMenuItems); + const enabledItem = protectedCollectPolicyMenuItems.find((curItem) => !prevProtectedMenuItems.some((prevItem) => curItem.routeName === prevItem.routeName)); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = @@ -276,7 +278,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, r In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. */} {menuItems.map((item) => ( -