From 19ec70b8f0b5a507c66fd73eef30e9d5689b3732 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sun, 22 Jun 2025 13:27:39 -0500 Subject: [PATCH] Working overlay, WIP --- src/components/Tooltip/index.tsx | 262 ++++++++++++++++++ .../Profile/Header/ProfileHeaderStandard.tsx | 32 ++- 2 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/components/Tooltip/index.tsx diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 0000000000..3e36b5af46 --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -0,0 +1,262 @@ +import { + useMemo, + useRef, + createContext, + useContext, + useState, + isValidElement, +} from 'react' +import {View, Modal, Dimensions, Pressable} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {Portal} from '#/components/Portal' +import {atoms as a, useTheme} from '#/alf' + +type Context = { + visible: boolean + onVisibleChange: (visible: boolean) => void + targetMeasurements: + | { + x: number + y: number + width: number + height: number + } + | undefined + targetRef: React.RefObject + targetElement: any +} + +const TooltipContext = createContext({ + visible: false, + onVisibleChange: () => {}, + targetMeasurements: undefined, + targetRef: {current: null}, + targetElement: null, +}) + +export function Outer({ + children, + visible: requestVisible, + onVisibleChange, +}: { + children: React.ReactNode + visible: boolean + onVisibleChange: (visible: boolean) => void +}) { + const targetRef = useRef(null) + const [prevRequestVisible, setPrevRequestVisible] = useState< + boolean | undefined + >() + const [visible, setVisible] = useState(false) + const [measurements, setMeasurements] = useState< + | { + x: number + y: number + width: number + height: number + } + | undefined + >(undefined) + + if (requestVisible && !prevRequestVisible) { + setPrevRequestVisible(requestVisible) + if (targetRef.current) { + targetRef.current.measure((_x, _y, width, height, pageX, pageY) => { + setMeasurements({x: pageX, y: pageY, width, height}) + setVisible(true) + }) + } + } else if (!requestVisible && prevRequestVisible) { + setPrevRequestVisible(requestVisible) + setMeasurements(undefined) + setVisible(false) + } + + const ctx = useMemo( + () => ({ + visible, + onVisibleChange, + targetMeasurements: measurements, + targetRef, + targetElement: flattenReactChildren(children).find(child => { + if (isValidElement(child) && child.type === Target) { + return true + } + }), + }), + [visible, onVisibleChange, measurements, targetRef], + ) + + return ( + {children} + ) +} + +export function Target({ + children, +}: { + children: (props: {ref: Context['targetRef']}) => React.ReactNode +}) { + const {targetRef} = useContext(TooltipContext) + + return children({ + ref: targetRef, + }) +} + +const TIP_SIZE = 12 + +export function Content({children}: {children: React.ReactNode}) { + const t = useTheme() + const {visible, onVisibleChange, targetMeasurements, targetElement} = + useContext(TooltipContext) + const [ready, setReady] = useState(false) + const [measurements, setMeasurements] = useState< + | { + width: number + height: number + } + | undefined + >(undefined) + const safe = useSafeAreaInsets() + + const requestClose = () => { + onVisibleChange(false) + setReady(false) + setMeasurements(undefined) + } + + const {positionTop, contentLeft, tipTop, tipLeft} = useMemo(() => { + if (!targetMeasurements || !measurements) + return { + positionTop: 0, + contentLeft: 0, + tipTop: 0, + tipLeft: 0, + } + + const win = Dimensions.get('window') + const {width: ww, height: wh} = win + const maxTop = safe.top + const maxBottom = wh - safe.bottom + const {width: cw, height: ch} = measurements + const minLeft = a.px_xl.paddingLeft + const maxLeft = ww - minLeft + + let positionTop = targetMeasurements.y + targetMeasurements.height + let contentLeft = Math.max( + minLeft, + targetMeasurements.x + targetMeasurements.width / 2 - cw / 2, + ) + let tipTop = (TIP_SIZE / 2) * -1 + let tipLeft = + targetMeasurements.x + targetMeasurements.width / 2 - TIP_SIZE / 2 + + if (contentLeft + cw > maxLeft) { + contentLeft -= contentLeft + cw - maxLeft + } + + positionTop += TIP_SIZE / 3 + + return { + positionTop, + contentLeft, + tipTop, + tipLeft, + } + }, [targetMeasurements, measurements, safe]) + + if (!visible || !targetMeasurements) return null + + return ( + + {}} // TODO + style={[a.absolute, a.inset_0]}> + {/* Backdrop */} + + + {/* Replica of the target element */} + + {targetElement} + + + {/* Outer content container */} + + {/* The triangle "tip" */} + + + {/* The content itself */} + { + setReady(true) + setMeasurements({ + width: e.nativeEvent.layout.width, + height: e.nativeEvent.layout.height, + }) + }}> + {children} + + + + + ) +} diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 1639abaf0c..c3eebe2551 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -40,6 +40,7 @@ import {EditProfileDialog} from './EditProfileDialog' import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderShell} from './Shell' +import * as Tooltip from '#/components/Tooltip' interface Props { profile: AppBskyActorDefs.ProfileViewDetailed @@ -141,6 +142,8 @@ let ProfileHeaderStandard = ({ const {isActive: live} = useActorStatus(profile) + const [visible, setVisible] = React.useState(false) + return ( - {hasSession && } + + + + {hasSession && ( + <> + + + {({ref}) => ( + + + + )} + + + The quick brown fox jumps over the lazy dog + + + + )} ) : null}