Ok pretty good on native

This commit is contained in:
Eric Bailey
2025-06-22 17:01:41 -05:00
parent 5c1f21623d
commit 996e550c0a
2 changed files with 72 additions and 48 deletions
+1
View File
@@ -0,0 +1 @@
export const TIP_SIZE = 12
+71 -48
View File
@@ -12,10 +12,14 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useOnInteract} from '#/state/shell/GlobalGestureEvents' import {useOnInteract} from '#/state/shell/GlobalGestureEvents'
import {TIP_SIZE} from '#/components/Tooltip/const'
type Context = { type TooltipContextType = {
visible: boolean ready: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}
type TargetContextType = {
targetMeasurements: targetMeasurements:
| { | {
x: number x: number
@@ -27,9 +31,12 @@ type Context = {
targetRef: React.RefObject<View> targetRef: React.RefObject<View>
} }
const TooltipContext = createContext<Context>({ const TooltipContext = createContext<TooltipContextType>({
visible: false, ready: false,
onVisibleChange: () => {}, onVisibleChange: () => {},
})
const TargetContext = createContext<TargetContextType>({
targetMeasurements: undefined, targetMeasurements: undefined,
targetRef: {current: null}, targetRef: {current: null},
}) })
@@ -43,12 +50,22 @@ export function Outer({
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}) { }) {
const targetRef = useRef<View>(null) /**
* Whether we have measured the target and are ready to show the tooltip.
*/
const [ready, setReady] = useState(false)
/**
* Lagging state to track the externally-controlled visibility of the
* tooltip.
*/
const [prevRequestVisible, setPrevRequestVisible] = useState< const [prevRequestVisible, setPrevRequestVisible] = useState<
boolean | undefined boolean | undefined
>() >()
const [visible, setVisible] = useState(false) /**
const [measurements, setMeasurements] = useState< * Needs to reference the element this Tooltip is attached to.
*/
const targetRef = useRef<View>(null)
const [targetMeasurements, setTargetMeasurements] = useState<
| { | {
x: number x: number
y: number y: number
@@ -59,89 +76,100 @@ export function Outer({
>(undefined) >(undefined)
if (requestVisible && !prevRequestVisible) { if (requestVisible && !prevRequestVisible) {
setPrevRequestVisible(requestVisible) setPrevRequestVisible(true)
if (targetRef.current) { if (targetRef.current) {
/*
* Once opened, measure the dimensions and position of the target
*/
targetRef.current.measure((_x, _y, width, height, pageX, pageY) => { targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
setMeasurements({x: pageX, y: pageY, width, height}) setTargetMeasurements({x: pageX, y: pageY, width, height})
setVisible(true) setReady(true)
}) })
} }
} else if (!requestVisible && prevRequestVisible) { } else if (!requestVisible && prevRequestVisible) {
setPrevRequestVisible(requestVisible) setPrevRequestVisible(false)
setMeasurements(undefined) setTargetMeasurements(undefined)
setVisible(false) setReady(false)
} }
const ctx = useMemo( const ctx = useMemo(
() => ({ () => ({ready, onVisibleChange}),
visible, [ready, onVisibleChange],
onVisibleChange, )
targetMeasurements: measurements, const targetCtx = useMemo(
targetRef, () => ({targetMeasurements, targetRef}),
}), [targetMeasurements, targetRef],
[visible, onVisibleChange, measurements, targetRef],
) )
return ( return (
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> <TooltipContext.Provider value={ctx}>
<TargetContext.Provider value={targetCtx}>
{children}
</TargetContext.Provider>
</TooltipContext.Provider>
) )
} }
export function Target({ export function Target({
children, children,
}: { }: {
children: (props: {ref: Context['targetRef']}) => React.ReactNode children: (props: {ref: TargetContextType['targetRef']}) => React.ReactNode
}) { }) {
const {targetRef} = useContext(TooltipContext) const {targetRef} = useContext(TargetContext)
return children({ return children({
ref: targetRef, ref: targetRef,
}) })
} }
const TIP_SIZE = 12
export function Content({children}: {children: React.ReactNode}) { export function Content({children}: {children: React.ReactNode}) {
const {visible, onVisibleChange, targetMeasurements} = const {ready, onVisibleChange} = useContext(TooltipContext)
useContext(TooltipContext) const {targetMeasurements} = useContext(TargetContext)
const requestClose = useCallback(() => { const requestClose = useCallback(() => {
onVisibleChange(false) onVisibleChange(false)
}, [onVisibleChange]) }, [onVisibleChange])
if (!visible || !targetMeasurements) return null if (!ready || !targetMeasurements) return null
return ( return (
<Portal> <Portal>
<Tooltip <Bubble
/*
* Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
* the context providers.
*/
targetMeasurements={targetMeasurements} targetMeasurements={targetMeasurements}
requestClose={requestClose}> requestClose={requestClose}>
{children} {children}
</Tooltip> </Bubble>
</Portal> </Portal>
) )
} }
function Tooltip({ function Bubble({
children, children,
requestClose, requestClose,
targetMeasurements, targetMeasurements,
}: { }: {
children: React.ReactNode children: React.ReactNode
requestClose: () => void requestClose: () => void
targetMeasurements: Context['targetMeasurements'] targetMeasurements: Exclude<
TargetContextType['targetMeasurements'],
undefined
>
}) { }) {
const t = useTheme() const t = useTheme()
const [ready, setReady] = useState(false) const insets = useSafeAreaInsets()
const [measurements, setMeasurements] = useState< const [bubbleMeasurements, setBubbleMeasurements] = useState<
| { | {
width: number width: number
height: number height: number
} }
| undefined | undefined
>(undefined) >(undefined)
const safe = useSafeAreaInsets()
const coords = useMemo(() => { const coords = useMemo(() => {
if (!targetMeasurements || !measurements) if (!bubbleMeasurements)
return { return {
top: 0, top: 0,
bottom: 0, bottom: 0,
@@ -153,9 +181,9 @@ function Tooltip({
const win = Dimensions.get('window') const win = Dimensions.get('window')
const {width: ww, height: wh} = win const {width: ww, height: wh} = win
const maxTop = safe.top const maxTop = insets.top
const maxBottom = wh - safe.bottom const maxBottom = wh - insets.bottom
const {width: cw, height: ch} = measurements const {width: cw, height: ch} = bubbleMeasurements
const minLeft = a.px_xl.paddingLeft const minLeft = a.px_xl.paddingLeft
const maxLeft = ww - minLeft const maxLeft = ww - minLeft
@@ -183,11 +211,10 @@ function Tooltip({
tipTop, tipTop,
tipLeft, tipLeft,
} }
}, [targetMeasurements, measurements, safe]) }, [targetMeasurements, bubbleMeasurements, insets])
const requestCloseWrapped = useCallback(() => { const requestCloseWrapped = useCallback(() => {
setReady(false) setBubbleMeasurements(undefined)
setMeasurements(undefined)
requestClose() requestClose()
}, [requestClose]) }, [requestClose])
@@ -216,12 +243,11 @@ function Tooltip({
a.align_start, a.align_start,
{ {
width: 200, width: 200,
opacity: ready ? 1 : 0, opacity: !!bubbleMeasurements ? 1 : 0,
top: coords.top, top: coords.top,
left: coords.left, left: coords.left,
}, },
]}> ]}>
{/* The triangle "tip" */}
<View <View
style={[ style={[
a.absolute, a.absolute,
@@ -238,16 +264,13 @@ function Tooltip({
}, },
]} ]}
/> />
{/* The content itself */}
<View <View
style={[a.px_md, a.py_sm, a.rounded_sm, t.atoms.bg, t.atoms.shadow_sm]} style={[a.px_md, a.py_sm, a.rounded_sm, t.atoms.bg, t.atoms.shadow_sm]}
onLayout={e => { onLayout={e => {
setMeasurements({ setBubbleMeasurements({
width: e.nativeEvent.layout.width, width: e.nativeEvent.layout.width,
height: e.nativeEvent.layout.height, height: e.nativeEvent.layout.height,
}) })
setReady(true)
}}> }}>
{children} {children}
</View> </View>