import { JSXElementConstructor, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dimensions, LayoutChangeEvent, Modal, StyleSheet, TouchableWithoutFeedback, View } from "react-native"; import Animated, { Easing, FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIOTheme, useIOThemeContext } from "../../context"; import { IOColors } from "../../core/IOColors"; import { IconButton } from "../buttons"; import { Body, H6 } from "../typography"; import { BottomArrow, LeftArrow, RightArrow, TopArrow } from "./Arrows"; import { ARROW_HEIGHT, EMPTY_SPACE, getArrowBoxByPlacement, getArrowCoords, getArrowVerticalAlignment, getDisplayInsets, getTooltipCoords, getTooltipVerticalAlignment, isDefined, isNotZero } from "./utils"; import { ChildrenCoords, DisplayInsets, Placement, TooltipLayout } from "./utils/types"; const screenDimensions = Dimensions.get("window"); const INITIAL_COORDS: ChildrenCoords = { x: 0, y: 0, width: 0, height: 0 }; const ARROWS_BY_PLACEMENT: Record< Placement, JSXElementConstructor<{ color: string }> > = { top: TopArrow, bottom: BottomArrow, left: LeftArrow, right: RightArrow }; type CommonProps = { /** * The title text displayed at the top of the tooltip. */ title: string; /** * The tooltip text content. */ content: string; /** * Controls the visibility of the tooltip. */ isVisible: boolean; /** * Initial tooltip position; can be 'top', 'bottom', 'left', or 'right'. * @default top */ placement?: Placement; /** * Insets for adjusting tooltip position within screen boundaries. * @default {} */ displayInsets?: Partial; /** * Accessibility label for the close icon button. */ closeIconAccessibilityLabel: string; /** * Determines whether interactions with the tooltip's children are allowed when `isVisible` is set to true. * @default false */ childrenInteractionsEnabled?: boolean; /** * Callback function triggered when the tooltip is closed. */ onClose: () => void; }; type CloseWithTapOnBackground = { /** * Allows closing the tooltip by tapping outside of it. */ allowCloseOnBackgroundTap: true; /** * Accessibility label for the tooltip background mask. */ backgroundAccessibilityLabel: string; }; type CloseWithBackgroundTapDisabled = { allowCloseOnBackgroundTap?: false; }; type Props = CommonProps & (CloseWithTapOnBackground | CloseWithBackgroundTapDisabled); const styles = StyleSheet.create({ backdrop: { position: "absolute", width: "100%", height: "100%", backgroundColor: IOColors.black, zIndex: 997 }, childrenContainer: { position: "absolute", zIndex: 1000 }, tooltipContainer: { position: "absolute", paddingHorizontal: 16, paddingVertical: 16, borderRadius: 8, zIndex: 2000, overflow: "visible" }, arrowContainer: { position: "absolute", display: "flex", zIndex: 3000 }, closeIcon: { position: "absolute", right: 8, top: 9 // It's been used `9` instead of `8` to fix accessibility focus order. In this way title is read before close icon. } }); const getChildrenPosition = (childrenCoords: ChildrenCoords) => ({ top: childrenCoords.y, left: childrenCoords.x, width: childrenCoords.width, height: childrenCoords.height }); /** * Tooltip component that displays a contextual tooltip around its children. * The tooltip position is controlled by the `placement` prop and can adjust * dynamically if there is insufficient space. * @param {Props} props - The component props * * @returns {ReactElement} A tooltip component rendered around the specified children. */ export const Tooltip = ({ children, title, content, placement: initialPlacement = "top", closeIconAccessibilityLabel, isVisible, displayInsets = {}, allowCloseOnBackgroundTap, childrenInteractionsEnabled = false, onClose }: PropsWithChildren): ReactElement => { const insets = useSafeAreaInsets(); const [currentPlacement, setCurrentPlacement] = useState(initialPlacement); const [childrenCoords, setChildrenCoords] = useState(INITIAL_COORDS); const [tooltipLayout, setTooltipLayout] = useState(); const childRef = useRef(null); const titleRef = useRef(null); const timeoutRef = useRef | undefined>( undefined ); // Theme const theme = useIOTheme(); const { themeType } = useIOThemeContext(); const backdropOpacity = themeType === "light" ? 0.4 : 0.8; const tooltipBackground = IOColors[theme["appBackground-secondary"]]; const Arrow = useMemo( () => ARROWS_BY_PLACEMENT[currentPlacement], [currentPlacement] ); const childrenCoordsValues = Object.values(childrenCoords); const isChildrenMeasurementFinished = childrenCoordsValues.every(isDefined) && childrenCoordsValues.some(isNotZero); const isTooltipMeasurementCompleted = isDefined(tooltipLayout); const tooltipVisibility = { opacity: isTooltipMeasurementCompleted ? 1 : 0 }; /** * This function sets the `Tooltip` children coordinates */ const measureChildrenCoords = useCallback(() => { if (childRef.current && typeof childRef.current.measure === "function") { childRef.current.measure((_, __, width, height, px, py) => { const coords = { x: px, y: py, width, height }; if (Object.values(coords).every(isDefined)) { setChildrenCoords(coords); } }); } }, []); useEffect(() => { if (isVisible) { // A new measure is executed every time the `Tooltip` is visible // This is required for use within ScrollView components. // eslint-disable-next-line functional/immutable-data timeoutRef.current = setTimeout(measureChildrenCoords, 100); } else { setChildrenCoords(INITIAL_COORDS); setCurrentPlacement(initialPlacement); } return () => { if (isVisible) { clearTimeout(timeoutRef.current); } }; }, [isVisible, initialPlacement, measureChildrenCoords]); /** * This function works with `top` and `bottom` placement and sets the current placement to their opposite value * if in the selected one there is no space to prompt the tooltip */ const invertPlacementIfNeeded = useCallback( (nativeEvent: LayoutChangeEvent["nativeEvent"]) => { if (initialPlacement === "top") { const hasSpace = nativeEvent.layout.y >= insets.top; if (!hasSpace) { setCurrentPlacement("bottom"); } } if (initialPlacement === "bottom") { const remainingSpace = screenDimensions.height - nativeEvent.layout.y - insets.bottom; const tooltipMinHeight = nativeEvent.layout.height + ARROW_HEIGHT + EMPTY_SPACE; const hasSpace = remainingSpace >= tooltipMinHeight; if (!hasSpace) { setCurrentPlacement("top"); } } }, [insets.bottom, insets.top, initialPlacement] ); const handleTooltipOnLayout = useCallback( ({ nativeEvent }: LayoutChangeEvent) => { invertPlacementIfNeeded(nativeEvent); setTooltipLayout(nativeEvent.layout); }, [invertPlacementIfNeeded] ); const handleTapOnBackground = useCallback(() => { if (allowCloseOnBackgroundTap) { onClose(); } }, [allowCloseOnBackgroundTap, onClose]); return ( <> {children} {children}
{title}
{content}
); };