Skip to content

Commit

Permalink
reset tooltip position on scroll
Browse files Browse the repository at this point in the history
  • Loading branch information
mohit6789 committed Feb 1, 2025
1 parent 9689e56 commit e2c6159
Show file tree
Hide file tree
Showing 9 changed files with 145 additions and 15 deletions.
6 changes: 5 additions & 1 deletion src/components/LHNOptionsList/LHNOptionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
() => (
<View style={[styles.alignItemsCenter, styles.flexRow, styles.justifyContentCenter, styles.flexWrap, styles.textAlignCenter]}>
Expand Down Expand Up @@ -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(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/components/LHNOptionsList/OptionRowLHN.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Check failure on line 199 in src/components/LHNOptionsList/OptionRowLHN.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Value must be omitted for boolean attributes

Check failure on line 199 in src/components/LHNOptionsList/OptionRowLHN.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Value must be omitted for boolean attributes
>
<View>
<Hoverable>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,76 @@
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';

Check failure on line 3 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Imports "NativeMethods" are only used as type

Check failure on line 3 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Prefer using a top-level type-only import instead of inline type specifiers

Check failure on line 3 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Prefer using a top-level type-only import instead of inline type specifiers

Check failure on line 3 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Imports "NativeMethods" are only used as type

Check failure on line 3 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Prefer using a top-level type-only import instead of inline type specifiers

Check failure on line 3 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Prefer using a top-level type-only import instead of inline type specifiers
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}>;

/**
* 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<GenericTooltipState>();
const tooltipElRef = useRef<React.Component & Readonly<NativeMethods>>();

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;

Check failure on line 29 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Expected { after 'if' condition

Check failure on line 29 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Expected { after 'if' condition

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;

Check failure on line 62 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Expected { after 'if' condition

Check failure on line 62 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Expected { after 'if' condition
setTooltipPosition(false, name);
const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, ({isScrolling, tooltipName} = {}) => {
setTooltipPosition(isScrolling, tooltipName);

Check failure on line 65 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Unsafe argument of type `any` assigned to a parameter of type `boolean`

Check failure on line 65 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

Unsafe argument of type `any` assigned to a parameter of type `string`

Check failure on line 65 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unsafe argument of type `any` assigned to a parameter of type `boolean`

Check failure on line 65 in src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Unsafe argument of type `any` assigned to a parameter of type `string`
});

return () => scrollingListener.remove();
}, [shouldRender, name, shouldHideOnEdge, setTooltipPosition]);

useEffect(() => {
return () => {
hideTooltipRef.current?.();
genericTooltipStateRef.current?.hideTooltip();
};
}, []);

Expand All @@ -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.
Expand All @@ -50,7 +99,7 @@ function BaseEducationalTooltip({children, shouldRender = false, shouldHideOnNav
if (!shouldHideOnNavigate) {
return;
}
hideTooltipRef.current?.();
genericTooltipStateRef.current?.hideTooltip();
});
return unsubscribe;
}, [navigator, shouldHideOnNavigate]);
Expand All @@ -63,16 +112,21 @@ 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) {
setShouldMeasure(true);
}
// 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);
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export default function measureTooltipCoordinate(target: React.Component & Reado
showTooltip();
});
}

export function getTooltipCoordiate(target: React.Component & Readonly<NativeMethods>, callback: (rect: LayoutRectangle) => void) {

Check failure on line 11 in src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Do not inline named exports

Check failure on line 11 in src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.android.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Do not inline named exports
return target?.measure((x, y, width, height, px, py) => {
callback({height, width, x: px, y: py});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export default function measureTooltipCoordinate(target: React.Component & Reado
showTooltip();
});
}

export function getTooltipCoordiate(target: React.Component & Readonly<NativeMethods>, callback: (rect: LayoutRectangle) => void) {

Check failure on line 11 in src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Do not inline named exports

Check failure on line 11 in src/components/Tooltip/EducationalTooltip/measureTooltipCoordinate/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Do not inline named exports
return target?.measureInWindow((x, y, width, height) => {
callback({height, width, x, y});
});
}
7 changes: 6 additions & 1 deletion src/components/Tooltip/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) & {
Expand All @@ -95,4 +100,4 @@ type TooltipExtendedProps = (EducationalTooltipProps | TooltipProps) & {
};

export default TooltipProps;
export type {EducationalTooltipProps, GenericTooltipProps, SharedTooltipProps, TooltipExtendedProps};
export type {EducationalTooltipProps, GenericTooltipProps, SharedTooltipProps, TooltipExtendedProps, GenericTooltipState};
42 changes: 42 additions & 0 deletions src/hooks/useScrollEventEmitter.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends Object>(additinalEmitData: T) => {
const [lastScrollEvent, setLastScrollEvent] = useState<number | null>(null);
const isScrollingRef = useRef<boolean>(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;
13 changes: 11 additions & 2 deletions src/pages/Search/SearchPageBottomTab.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
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';
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';
Expand All @@ -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';
Expand All @@ -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;
Expand All @@ -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)();
},
});

Expand Down
2 changes: 2 additions & 0 deletions src/pages/Search/SearchTypeMenuNarrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
<Button
icon={Expensicons.Filters}
Expand Down

0 comments on commit e2c6159

Please sign in to comment.