import * as React from 'react'; import { Dimensions, View, LayoutChangeEvent, StyleSheet, Platform, Pressable, ViewStyle, } from 'react-native'; import type { ThemeProp } from 'src/types'; import { getTooltipPosition, Measurement, TooltipChildProps } from './utils'; import { useInternalTheme } from '../../core/theming'; import { addEventListener } from '../../utils/addEventListener'; import Portal from '../Portal/Portal'; import Text from '../Typography/Text'; export type Props = { /** * Tooltip reference element. Needs to be able to hold a ref. */ children: React.ReactElement; /** * The number of milliseconds a user must touch the element before showing the tooltip. */ enterTouchDelay?: number; /** * The number of milliseconds after the user stops touching an element before hiding the tooltip. */ leaveTouchDelay?: number; /** * Tooltip title */ title: string; /** * Specifies the largest possible scale a title font can reach. */ titleMaxFontSizeMultiplier?: number; /** * @optional */ theme?: ThemeProp; }; /** * Tooltips display informative text when users hover over, focus on, or tap an element. * * Plain tooltips, when activated, display a text label identifying an element, such as a description of its function. Tooltips should include only short, descriptive text and avoid restating visible UI text. * * ## Usage * ```js * import * as React from 'react'; * import { IconButton, Tooltip } from 'react-native-paper'; * * const MyComponent = () => ( * * {}} /> * * ); * * export default MyComponent; * ``` */ const Tooltip = ({ children, enterTouchDelay = 500, leaveTouchDelay = 1500, title, theme: themeOverrides, titleMaxFontSizeMultiplier, ...rest }: Props) => { const isWeb = Platform.OS === 'web'; const theme = useInternalTheme(themeOverrides); const [visible, setVisible] = React.useState(false); const [measurement, setMeasurement] = React.useState({ children: {}, tooltip: {}, measured: false, }); const showTooltipTimer = React.useRef([]); const hideTooltipTimer = React.useRef([]); const childrenWrapperRef = React.useRef(null); const touched = React.useRef(false); const isValidChild = React.useMemo( () => React.isValidElement(children), [children] ); React.useEffect(() => { return () => { if (showTooltipTimer.current.length) { showTooltipTimer.current.forEach((t) => clearTimeout(t)); showTooltipTimer.current = []; } if (hideTooltipTimer.current.length) { hideTooltipTimer.current.forEach((t) => clearTimeout(t)); hideTooltipTimer.current = []; } }; }, []); React.useEffect(() => { const subscription = addEventListener(Dimensions, 'change', () => setVisible(false) ); return () => subscription.remove(); }, []); const handleTouchStart = React.useCallback(() => { if (hideTooltipTimer.current.length) { hideTooltipTimer.current.forEach((t) => clearTimeout(t)); hideTooltipTimer.current = []; } if (isWeb) { let id = setTimeout(() => { touched.current = true; setVisible(true); }, enterTouchDelay) as unknown as NodeJS.Timeout; showTooltipTimer.current.push(id); } else { touched.current = true; setVisible(true); } }, [isWeb, enterTouchDelay]); const handleTouchEnd = React.useCallback(() => { touched.current = false; if (showTooltipTimer.current.length) { showTooltipTimer.current.forEach((t) => clearTimeout(t)); showTooltipTimer.current = []; } let id = setTimeout(() => { setVisible(false); setMeasurement({ children: {}, tooltip: {}, measured: false }); }, leaveTouchDelay) as unknown as NodeJS.Timeout; hideTooltipTimer.current.push(id); }, [leaveTouchDelay]); const handlePress = React.useCallback(() => { if (touched.current) { return null; } if (!isValidChild) return null; const props = children.props as TooltipChildProps; if (props.disabled) return null; return props.onPress?.(); }, [children.props, isValidChild]); const handleHoverIn = React.useCallback(() => { handleTouchStart(); if (isValidChild) { (children.props as TooltipChildProps).onHoverIn?.(); } }, [children.props, handleTouchStart, isValidChild]); const handleHoverOut = React.useCallback(() => { handleTouchEnd(); if (isValidChild) { (children.props as TooltipChildProps).onHoverOut?.(); } }, [children.props, handleTouchEnd, isValidChild]); const handleOnLayout = ({ nativeEvent: { layout } }: LayoutChangeEvent) => { childrenWrapperRef.current?.measure( (_x, _y, width, height, pageX, pageY) => { setMeasurement({ children: { pageX, pageY, height, width }, tooltip: { ...layout }, measured: true, }); } ); }; const mobilePressProps = { onPress: handlePress, onLongPress: () => handleTouchStart(), onPressOut: () => handleTouchEnd(), delayLongPress: enterTouchDelay, }; const webPressProps = { onHoverIn: handleHoverIn, onHoverOut: handleHoverOut, }; return ( <> {visible && ( ), borderRadius: theme.roundness, ...(measurement.measured ? styles.visible : styles.hidden), }, ]} testID="tooltip-container" > {title} )} {React.cloneElement(children, { ...rest, ...(isWeb ? webPressProps : mobilePressProps), })} ); }; Tooltip.displayName = 'Tooltip'; const styles = StyleSheet.create({ tooltip: { alignSelf: 'flex-start', justifyContent: 'center', paddingHorizontal: 16, height: 32, maxHeight: 32, }, visible: { opacity: 1, }, hidden: { opacity: 0, }, pressContainer: { ...(Platform.OS === 'web' && { cursor: 'default' }), } as ViewStyle, }); export default Tooltip;