import { Children, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import {useWindowDimensions, View} from 'react-native' import Animated, {Easing, ZoomIn} from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {atoms as a, select, useTheme} from '#/alf' import {useOnGesture} from '#/components/hooks/useOnGesture' import {Portal} from '#/components/Portal' import { ARROW_HALF_SIZE, ARROW_SIZE, BUBBLE_MAX_WIDTH, MIN_EDGE_SPACE, } from '#/components/Tooltip/const' import {Text} from '#/components/Typography' /** * These are native specific values, not shared with web */ const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip type TooltipContextType = { position: 'top' | 'bottom' visible: boolean onVisibleChange: (visible: boolean) => void } type TargetMeasurements = { x: number y: number width: number height: number } type TargetContextType = { targetMeasurements: TargetMeasurements | undefined setTargetMeasurements: (measurements: TargetMeasurements) => void shouldMeasure: boolean } const TooltipContext = createContext({ position: 'bottom', visible: false, onVisibleChange: () => {}, }) TooltipContext.displayName = 'TooltipContext' const TargetContext = createContext({ targetMeasurements: undefined, setTargetMeasurements: () => {}, shouldMeasure: false, }) TargetContext.displayName = 'TargetContext' export function Outer({ children, position = 'bottom', visible: requestVisible, onVisibleChange, }: { children: React.ReactNode position?: 'top' | 'bottom' visible: boolean onVisibleChange: (visible: boolean) => void }) { /** * Lagging state to track the externally-controlled visibility of the * tooltip, which needs to wait for the target to be measured before * actually being shown. */ const [visible, setVisible] = useState(false) const [targetMeasurements, setTargetMeasurements] = useState< | { x: number y: number width: number height: number } | undefined >(undefined) if (requestVisible && !visible && targetMeasurements) { setVisible(true) } else if (!requestVisible && visible) { setVisible(false) setTargetMeasurements(undefined) } const ctx = useMemo( () => ({position, visible, onVisibleChange}), [position, visible, onVisibleChange], ) const targetCtx = useMemo( () => ({ targetMeasurements, setTargetMeasurements, shouldMeasure: requestVisible, }), [requestVisible, targetMeasurements, setTargetMeasurements], ) return ( {children} ) } export function Target({children}: {children: React.ReactNode}) { const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext) const targetRef = useRef(null) useEffect(() => { if (!shouldMeasure) return /* * Once opened, measure the dimensions and position of the target */ targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => { if (pageX !== undefined && pageY !== undefined && width && height) { setTargetMeasurements({x: pageX, y: pageY, width, height}) } }) }, [shouldMeasure, setTargetMeasurements]) return ( {children} ) } export function Content({ children, label, }: { children: React.ReactNode label: string }) { const {position, visible, onVisibleChange} = useContext(TooltipContext) const {targetMeasurements} = useContext(TargetContext) const requestClose = useCallback(() => { onVisibleChange(false) }, [onVisibleChange]) if (!visible || !targetMeasurements) return null return ( {children} ) } function Bubble({ children, label, position, requestClose, targetMeasurements, }: { children: React.ReactNode label: string position: TooltipContextType['position'] requestClose: () => void targetMeasurements: Exclude< TargetContextType['targetMeasurements'], undefined > }) { const t = useTheme() const insets = useSafeAreaInsets() const dimensions = useWindowDimensions() const [bubbleMeasurements, setBubbleMeasurements] = useState< | { width: number height: number } | undefined >(undefined) const coords = useMemo(() => { if (!bubbleMeasurements) return { top: 0, bottom: 0, left: 0, right: 0, tipTop: 0, tipLeft: 0, } const {width: ww, height: wh} = dimensions const maxTop = insets.top const maxBottom = wh - insets.bottom const {width: cw, height: ch} = bubbleMeasurements const minLeft = MIN_EDGE_SPACE const maxLeft = ww - minLeft let computedPosition: 'top' | 'bottom' = position let top = targetMeasurements.y + targetMeasurements.height let left = Math.max( minLeft, targetMeasurements.x + targetMeasurements.width / 2 - cw / 2, ) const tipTranslate = ARROW_HALF_SIZE * -1 let tipTop = tipTranslate if (left + cw > maxLeft) { left -= left + cw - maxLeft } let tipLeft = targetMeasurements.x - left + targetMeasurements.width / 2 - ARROW_HALF_SIZE let bottom = top + ch function positionTop() { top = top - ch - targetMeasurements.height bottom = top + ch tipTop = tipTop + ch computedPosition = 'top' } function positionBottom() { top = targetMeasurements.y + targetMeasurements.height bottom = top + ch tipTop = tipTranslate computedPosition = 'bottom' } if (position === 'top') { positionTop() if (top < maxTop) { positionBottom() } } else { if (bottom > maxBottom) { positionTop() } } if (computedPosition === 'bottom') { top += ARROW_VISUAL_OFFSET bottom += ARROW_VISUAL_OFFSET } else { top -= ARROW_VISUAL_OFFSET bottom -= ARROW_VISUAL_OFFSET } return { computedPosition, top, bottom, left, right: left + cw, tipTop, tipLeft, } }, [position, targetMeasurements, bubbleMeasurements, insets, dimensions]) const requestCloseWrapped = useCallback(() => { setBubbleMeasurements(undefined) requestClose() }, [requestClose]) useOnGesture( useCallback( e => { const {x, y} = e const isInside = x > coords.left && x < coords.right && y > coords.top && y < coords.bottom if (!isInside) { requestCloseWrapped() } }, [coords, requestCloseWrapped], ), ) return ( { setBubbleMeasurements({ width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height, }) }}> {children} ) } function oppposite(position: 'top' | 'bottom') { switch (position) { case 'top': return 'center bottom' case 'bottom': return 'center top' default: return 'center' } } export function TextBubble({children}: {children: React.ReactNode}) { const c = Children.toArray(children) return ( {c.map((child, i) => ( {child} ))} ) }