diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index e48646204f34..84c7bfa36be0 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -15,6 +15,7 @@ import useLHNEstimatedListSize from '@hooks/useLHNEstimatedListSize'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isValidDraftComment} from '@libs/DraftCommentUtils'; @@ -64,6 +65,8 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio onFirstItemRendered(); }, [onFirstItemRendered]); + const triggerScrollEvent = useScrollEventEmitter({tooltipName: CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP}); + const emptyLHNSubtitle = useMemo( () => ( @@ -239,8 +242,9 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio return; } saveScrollOffset(route, e.nativeEvent.contentOffset.y); + triggerScrollEvent(); }, - [route, saveScrollOffset], + [route, saveScrollOffset, triggerScrollEvent], ); const onLayout = useCallback(() => { diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 1e7a9f796641..99eb5b14c0e3 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -195,6 +195,8 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti shiftVertical={shouldShowWokspaceChatTooltip ? 0 : variables.composerTooltipShiftVertical} wrapperStyle={styles.productTrainingTooltipWrapper} onTooltipPress={onOptionPress} + name={CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP} + shouldHideOnEdge={true} > diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index 5033cf977e58..69d68d32586e 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -1,9 +1,12 @@ import {NavigationContext} from '@react-navigation/native'; -import React, {memo, useContext, useEffect, useRef, useState} from 'react'; -import type {LayoutRectangle, NativeSyntheticEvent} from 'react-native'; +import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter, Dimensions, type LayoutRectangle, NativeMethods, type NativeSyntheticEvent} from 'react-native'; import GenericTooltip from '@components/Tooltip/GenericTooltip'; -import type {EducationalTooltipProps} from '@components/Tooltip/types'; -import measureTooltipCoordinate from './measureTooltipCoordinate'; +import type {EducationalTooltipProps, GenericTooltipState} from '@components/Tooltip/types'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import measureTooltipCoordinate, {getTooltipCoordiate} from './measureTooltipCoordinate'; type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle; target: HTMLElement}>; @@ -11,17 +14,63 @@ type LayoutChangeEventWithTarget = NativeSyntheticEvent<{layout: LayoutRectangle * A component used to wrap an element intended for displaying a tooltip. * This tooltip would show immediately without user's interaction and hide after 5 seconds. */ -function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNavigate = true, ...props}: EducationalTooltipProps) { - const hideTooltipRef = useRef<() => void>(); +function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNavigate = true, name = '', shouldHideOnEdge = false, ...props}: EducationalTooltipProps) { + const genericTooltipStateRef = useRef(); + const tooltipElRef = useRef>(); const [shouldMeasure, setShouldMeasure] = useState(false); const show = useRef<() => void>(); const navigator = useContext(NavigationContext); + const insets = useSafeAreaInsets(); + + const setTooltipPosition = useCallback( + (isScrolling: boolean, tooltipName: string) => { + if (tooltipName !== name || !genericTooltipStateRef.current || !tooltipElRef.current) return; + + const {hideTooltip, showTooltip, updateTargetBounds} = genericTooltipStateRef.current; + if (isScrolling) { + hideTooltip(); + } else { + getTooltipCoordiate(tooltipElRef.current, (bounds) => { + updateTargetBounds(bounds); + const {y, height} = bounds; + + const offset = 10; // Buffer space + const dimensions = Dimensions.get('window'); + const top = y - (insets.top || 0); + const bottom = y + height + insets.bottom || 0; + + // Calculate the available space at the top, considering the header height and offset + const availableHeightForTop = top - (variables.contentHeaderHeight - offset); + + // Calculate the total height available after accounting for the bottom tab and offset + const availableHeightForBottom = dimensions.height - (bottom + variables.bottomTabHeight - offset); + + if (availableHeightForTop < 0 || availableHeightForBottom < 0) { + hideTooltip(); + } else { + showTooltip(); + } + }); + } + }, + [insets, name], + ); + + useLayoutEffect(() => { + if (!shouldRender || !name || !shouldHideOnEdge) return; + setTooltipPosition(false, name); + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, ({isScrolling, tooltipName} = {}) => { + setTooltipPosition(isScrolling, tooltipName); + }); + + return () => scrollingListener.remove(); + }, [shouldRender, name, shouldHideOnEdge, setTooltipPosition]); useEffect(() => { return () => { - hideTooltipRef.current?.(); + genericTooltipStateRef.current?.hideTooltip(); }; }, []); @@ -30,7 +79,7 @@ function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNav return; } if (!shouldRender) { - hideTooltipRef.current?.(); + genericTooltipStateRef.current?.hideTooltip(); return; } // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. @@ -50,7 +99,7 @@ function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNav if (!shouldHideOnNavigate) { return; } - hideTooltipRef.current?.(); + genericTooltipStateRef.current?.hideTooltip(); }); return unsubscribe; }, [navigator, shouldHideOnNavigate]); @@ -63,9 +112,10 @@ function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNav // eslint-disable-next-line react/jsx-props-no-spreading {...props} > - {({showTooltip, hideTooltip, updateTargetBounds}) => { + {(genericTooltipState) => { // eslint-disable-next-line react-compiler/react-compiler - hideTooltipRef.current = hideTooltip; + const {updateTargetBounds, showTooltip} = genericTooltipState; + genericTooltipStateRef.current = genericTooltipState; return React.cloneElement(children as React.ReactElement, { onLayout: (e: LayoutChangeEventWithTarget) => { if (!shouldMeasure) { @@ -73,6 +123,10 @@ function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNav } // e.target is specific to native, use e.nativeEvent.target on web instead const target = e.target || e.nativeEvent.target; + tooltipElRef.current = target; + if (shouldHideOnEdge) { + return; + } show.current = () => measureTooltipCoordinate(target, updateTargetBounds, showTooltip); }, }); diff --git a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts index 5cc2ab8a74a7..0346e75c387b 100644 --- a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts +++ b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts @@ -7,3 +7,9 @@ export default function measureTooltipCoordinate(target: React.Component & Reado showTooltip(); }); } + +export function getTooltipCoordiate(target: React.Component & Readonly, callback: (rect: LayoutRectangle) => void) { + return target?.measure((x, y, width, height, px, py) => { + callback({height, width, x: px, y: py}); + }); +} diff --git a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts index 72cc75115e21..788999504f65 100644 --- a/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts +++ b/src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts @@ -7,3 +7,9 @@ export default function measureTooltipCoordinate(target: React.Component & Reado showTooltip(); }); } + +export function getTooltipCoordiate(target: React.Component & Readonly, callback: (rect: LayoutRectangle) => void) { + return target?.measureInWindow((x, y, width, height) => { + callback({height, width, x, y}); + }); +} diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index b9770b9f83c7..6fc8f49aaf00 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -87,6 +87,11 @@ type EducationalTooltipProps = ChildrenProps & /** Whether the tooltip should hide when navigating */ shouldHideOnNavigate?: boolean; + + /** This name can be used to distinguish between different tooltips */ + name?: string; + + shouldHideOnEdge?: boolean; }; type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & { @@ -95,4 +100,4 @@ type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & { }; export default TooltipProps; -export type {EducationalTooltipProps, GenericTooltipProps, SharedTooltipProps, TooltipExtendedProps}; +export type {EducationalTooltipProps, GenericTooltipProps, SharedTooltipProps, TooltipExtendedProps, GenericTooltipState}; diff --git a/src/hooks/useScrollEventEmitter.ts b/src/hooks/useScrollEventEmitter.ts new file mode 100644 index 000000000000..064181fc0518 --- /dev/null +++ b/src/hooks/useScrollEventEmitter.ts @@ -0,0 +1,42 @@ +import {useCallback, useEffect, useRef, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import CONST from '@src/CONST'; + +/** + * This hook tracks scroll events and emits a "scrolling" event when scrolling starts and ends. + */ +const useScrollEventEmitter = (additinalEmitData: T) => { + const [lastScrollEvent, setLastScrollEvent] = useState(null); + const isScrollingRef = useRef(false); + + const triggerScrollEvent = useCallback(() => { + setLastScrollEvent(Date.now()); + }, []); + + useEffect(() => { + const emitScrolling = (isScrolling: boolean) => { + DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, { + isScrolling, + ...additinalEmitData, + }); + }; + + // Start emitting the scrolling event when the scroll begins + if (!isScrollingRef.current) { + emitScrolling(true); + isScrollingRef.current = true; + } + + // End the scroll and emit after a brief timeout to detect the end of scrolling + const timeout = setTimeout(() => { + emitScrolling(false); + isScrollingRef.current = false; + }, 250); + + return () => clearTimeout(timeout); + }, [lastScrollEvent]); + + return triggerScrollEvent; +}; + +export default useScrollEventEmitter; diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index b1e6ebac8f61..b3b8fdfa6849 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -1,7 +1,7 @@ import React, {useCallback} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {clamp, runOnJS, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; @@ -9,6 +9,7 @@ import SearchStatusBar from '@components/Search/SearchStatusBar'; import useActiveCentralPaneRoute from '@hooks/useActiveCentralPaneRoute'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -17,6 +18,7 @@ import type {AuthScreensParamList} from '@libs/Navigation/types'; import {buildCannedSearchQuery, buildSearchQueryJSON, getPolicyIDFromSearchQuery, isCannedSearchQuery} from '@libs/SearchQueryUtils'; import TopBar from '@navigation/AppNavigator/createCustomBottomTabNavigator/TopBar'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -42,6 +44,8 @@ function SearchPageBottomTab() { top: topBarOffset.get(), })); + const triggerScrollEvent = useScrollEventEmitter({tooltipName: CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP}); + const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { const {contentOffset, layoutMeasurement, contentSize} = event; @@ -54,9 +58,14 @@ function SearchPageBottomTab() { if (isScrollingDown && contentOffset.y > TOO_CLOSE_TO_TOP_DISTANCE) { topBarOffset.set(clamp(topBarOffset.get() - distanceScrolled, variables.minimalTopBarOffset, StyleUtils.searchHeaderHeight)); } else if (!isScrollingDown && distanceScrolled < 0 && contentOffset.y + layoutMeasurement.height < contentSize.height - TOO_CLOSE_TO_BOTTOM_DISTANCE) { - topBarOffset.set(withTiming(StyleUtils.searchHeaderHeight, {duration: ANIMATION_DURATION_IN_MS})); + topBarOffset.set( + withTiming(StyleUtils.searchHeaderHeight, {duration: ANIMATION_DURATION_IN_MS}, () => { + runOnJS(triggerScrollEvent)(); + }), + ); } scrollOffset.set(currentOffset); + runOnJS(triggerScrollEvent)(); }, }); diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 30722677cdc8..e8d06527e5cf 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -243,6 +243,8 @@ function SearchTypeMenuNarrow({typeMenuItems, activeItemIndex, queryJSON, title, wrapperStyle={styles.productTrainingTooltipWrapper} renderTooltipContent={renderProductTrainingTooltip} onTooltipPress={onPress} + name={CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SEARCH_FILTER_BUTTON_TOOLTIP} + shouldHideOnEdge={true} >