From 4c1515169af81f5eb861e476d2bc07f17c8635fd Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 24 Jun 2025 22:03:23 -0500 Subject: Tooltip (#8555) * Working overlay, WIP * Ok working with no overlay and global gesture handler * Ok pretty good on native * Cleanup * Cleanup * add animation * add transform origin to animation * Some a11y * Improve colors * Explicitly wrap gesture handler * Add easier abstraction * Web * Fix animation * Cleanup and remove provider * Include demo for now * Ok diff interface to avoid collapsed views * Use dimensions hook * Adjust overlap, clarify intent of consts * Revert testing edits --------- Co-authored-by: Samuel Newman --- src/App.native.tsx | 13 +- src/alf/atoms.ts | 4 + src/components/Tooltip/const.ts | 6 + src/components/Tooltip/index.tsx | 411 +++++++++++++++++++++++++ src/components/Tooltip/index.web.tsx | 112 +++++++ src/components/hooks/useOnGesture/index.ts | 24 ++ src/components/hooks/useOnGesture/index.web.ts | 1 + src/state/global-gesture-events/index.tsx | 83 +++++ src/state/global-gesture-events/index.web.tsx | 9 + src/style.css | 35 +++ 10 files changed, 693 insertions(+), 5 deletions(-) create mode 100644 src/components/Tooltip/const.ts create mode 100644 src/components/Tooltip/index.tsx create mode 100644 src/components/Tooltip/index.web.tsx create mode 100644 src/components/hooks/useOnGesture/index.ts create mode 100644 src/components/hooks/useOnGesture/index.web.ts create mode 100644 src/state/global-gesture-events/index.tsx create mode 100644 src/state/global-gesture-events/index.web.tsx (limited to 'src') diff --git a/src/App.native.tsx b/src/App.native.tsx index 81d4a870e..c42b11746 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -33,6 +33,7 @@ import { ensureGeolocationResolved, Provider as GeolocationProvider, } from '#/state/geolocation' +import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' import {Provider as HomeBadgeProvider} from '#/state/home-badge' import {Provider as InvitesStateProvider} from '#/state/invites' import {Provider as LightboxStateProvider} from '#/state/lightbox' @@ -154,11 +155,13 @@ function InnerApp() { - - - - - + + + + + + + diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 79ec41679..440ac16ac 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -983,6 +983,10 @@ export const atoms = { transition_none: web({ transitionProperty: 'none', }), + transition_timing_default: web({ + transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', + transitionDuration: '100ms', + }), transition_all: web({ transitionProperty: 'all', transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)', diff --git a/src/components/Tooltip/const.ts b/src/components/Tooltip/const.ts new file mode 100644 index 000000000..cad7e0106 --- /dev/null +++ b/src/components/Tooltip/const.ts @@ -0,0 +1,6 @@ +import {atoms as a} from '#/alf' + +export const BUBBLE_MAX_WIDTH = 240 +export const ARROW_SIZE = 12 +export const ARROW_HALF_SIZE = ARROW_SIZE / 2 +export const MIN_EDGE_SPACE = a.px_lg.paddingLeft diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx new file mode 100644 index 000000000..446cf18fc --- /dev/null +++ b/src/components/Tooltip/index.tsx @@ -0,0 +1,411 @@ +import { + Children, + createContext, + useCallback, + useContext, + 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' + ready: boolean + onVisibleChange: (visible: boolean) => void +} + +type TargetContextType = { + targetMeasurements: + | { + x: number + y: number + width: number + height: number + } + | undefined + targetRef: React.RefObject +} + +const TooltipContext = createContext({ + position: 'bottom', + ready: false, + onVisibleChange: () => {}, +}) + +const TargetContext = createContext({ + targetMeasurements: undefined, + targetRef: {current: null}, +}) + +export function Outer({ + children, + position = 'bottom', + visible: requestVisible, + onVisibleChange, +}: { + children: React.ReactNode + position?: 'top' | 'bottom' + visible: boolean + onVisibleChange: (visible: boolean) => void +}) { + /** + * 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< + boolean | undefined + >() + /** + * Needs to reference the element this Tooltip is attached to. + */ + const targetRef = useRef(null) + const [targetMeasurements, setTargetMeasurements] = useState< + | { + x: number + y: number + width: number + height: number + } + | undefined + >(undefined) + + if (requestVisible && !prevRequestVisible) { + setPrevRequestVisible(true) + + if (targetRef.current) { + /* + * 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}) + setReady(true) + } + }) + } + } else if (!requestVisible && prevRequestVisible) { + setPrevRequestVisible(false) + setTargetMeasurements(undefined) + setReady(false) + } + + const ctx = useMemo( + () => ({position, ready, onVisibleChange}), + [position, ready, onVisibleChange], + ) + const targetCtx = useMemo( + () => ({targetMeasurements, targetRef}), + [targetMeasurements, targetRef], + ) + + return ( + + + {children} + + + ) +} + +export function Target({children}: {children: React.ReactNode}) { + const {targetRef} = useContext(TargetContext) + + return ( + + {children} + + ) +} + +export function Content({ + children, + label, +}: { + children: React.ReactNode + label: string +}) { + const {position, ready, onVisibleChange} = useContext(TooltipContext) + const {targetMeasurements} = useContext(TargetContext) + const requestClose = useCallback(() => { + onVisibleChange(false) + }, [onVisibleChange]) + + if (!ready || !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} + + ))} + + + ) +} diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx new file mode 100644 index 000000000..739a714cd --- /dev/null +++ b/src/components/Tooltip/index.web.tsx @@ -0,0 +1,112 @@ +import {Children, createContext, useContext, useMemo} from 'react' +import {View} from 'react-native' +import {Popover} from 'radix-ui' + +import {atoms as a, flatten, select, useTheme} from '#/alf' +import {transparentifyColor} from '#/alf/util/colorGeneration' +import { + ARROW_SIZE, + BUBBLE_MAX_WIDTH, + MIN_EDGE_SPACE, +} from '#/components/Tooltip/const' +import {Text} from '#/components/Typography' + +type TooltipContextType = { + position: 'top' | 'bottom' +} + +const TooltipContext = createContext({ + position: 'bottom', +}) + +export function Outer({ + children, + position = 'bottom', + visible, + onVisibleChange, +}: { + children: React.ReactNode + position?: 'top' | 'bottom' + visible: boolean + onVisibleChange: (visible: boolean) => void +}) { + const ctx = useMemo(() => ({position}), [position]) + return ( + + {children} + + ) +} + +export function Target({children}: {children: React.ReactNode}) { + return ( + + {children} + + ) +} + +export function Content({ + children, + label, +}: { + children: React.ReactNode + label: string +}) { + const t = useTheme() + const {position} = useContext(TooltipContext) + return ( + + + + + {children} + + + + ) +} + +export function TextBubble({children}: {children: React.ReactNode}) { + const c = Children.toArray(children) + return ( + + + {c.map((child, i) => ( + + {child} + + ))} + + + ) +} diff --git a/src/components/hooks/useOnGesture/index.ts b/src/components/hooks/useOnGesture/index.ts new file mode 100644 index 000000000..6f0560661 --- /dev/null +++ b/src/components/hooks/useOnGesture/index.ts @@ -0,0 +1,24 @@ +import {useEffect} from 'react' + +import { + type GlobalGestureEvents, + useGlobalGestureEvents, +} from '#/state/global-gesture-events' + +/** + * Listen for global gesture events. Callback should be wrapped with + * `useCallback` or otherwise memoized to avoid unnecessary re-renders. + */ +export function useOnGesture( + onGestureCallback: (e: GlobalGestureEvents['begin']) => void, +) { + const ctx = useGlobalGestureEvents() + useEffect(() => { + ctx.register() + ctx.events.on('begin', onGestureCallback) + return () => { + ctx.unregister() + ctx.events.off('begin', onGestureCallback) + } + }, [ctx, onGestureCallback]) +} diff --git a/src/components/hooks/useOnGesture/index.web.ts b/src/components/hooks/useOnGesture/index.web.ts new file mode 100644 index 000000000..6129fde10 --- /dev/null +++ b/src/components/hooks/useOnGesture/index.web.ts @@ -0,0 +1 @@ +export function useOnGesture() {} diff --git a/src/state/global-gesture-events/index.tsx b/src/state/global-gesture-events/index.tsx new file mode 100644 index 000000000..f8d87a8f9 --- /dev/null +++ b/src/state/global-gesture-events/index.tsx @@ -0,0 +1,83 @@ +import {createContext, useContext, useMemo, useRef, useState} from 'react' +import {View} from 'react-native' +import { + Gesture, + GestureDetector, + type GestureStateChangeEvent, + type GestureUpdateEvent, + type PanGestureHandlerEventPayload, +} from 'react-native-gesture-handler' +import EventEmitter from 'eventemitter3' + +export type GlobalGestureEvents = { + begin: GestureStateChangeEvent + update: GestureUpdateEvent + end: GestureStateChangeEvent + finalize: GestureStateChangeEvent +} + +const Context = createContext<{ + events: EventEmitter + register: () => void + unregister: () => void +}>({ + events: new EventEmitter(), + register: () => {}, + unregister: () => {}, +}) + +export function GlobalGestureEventsProvider({ + children, +}: { + children: React.ReactNode +}) { + const refCount = useRef(0) + const events = useMemo(() => new EventEmitter(), []) + const [enabled, setEnabled] = useState(false) + const ctx = useMemo( + () => ({ + events, + register() { + refCount.current += 1 + if (refCount.current === 1) { + setEnabled(true) + } + }, + unregister() { + refCount.current -= 1 + if (refCount.current === 0) { + setEnabled(false) + } + }, + }), + [events, setEnabled], + ) + const gesture = Gesture.Pan() + .runOnJS(true) + .enabled(enabled) + .simultaneousWithExternalGesture() + .onBegin(e => { + events.emit('begin', e) + }) + .onUpdate(e => { + events.emit('update', e) + }) + .onEnd(e => { + events.emit('end', e) + }) + .onFinalize(e => { + events.emit('finalize', e) + }) + + return ( + + + {children} + + + ) +} + +export function useGlobalGestureEvents() { + return useContext(Context) +} diff --git a/src/state/global-gesture-events/index.web.tsx b/src/state/global-gesture-events/index.web.tsx new file mode 100644 index 000000000..5d6d53369 --- /dev/null +++ b/src/state/global-gesture-events/index.web.tsx @@ -0,0 +1,9 @@ +export function GlobalGestureEventsProvider(_props: { + children: React.ReactNode +}) { + throw new Error('GlobalGestureEventsProvider is not supported on web.') +} + +export function useGlobalGestureEvents() { + throw new Error('useGlobalGestureEvents is not supported on web.') +} diff --git a/src/style.css b/src/style.css index a4a2c455f..35ffe0d3a 100644 --- a/src/style.css +++ b/src/style.css @@ -334,3 +334,38 @@ input[type='range'][orient='vertical']::-moz-range-thumb { min-width: var(--radix-select-trigger-width); max-height: var(--radix-select-content-available-height); } + +/* + * #/components/Tooltip/index.web.tsx + */ +.radix-popover-content { + animation-duration: 300ms; + animation-timing-function: cubic-bezier(0.17, 0.73, 0.14, 1); + will-change: transform, opacity; +} +.radix-popover-content[data-state='open'][data-side='top'] { + animation-name: radixPopoverSlideUpAndFade; +} +.radix-popover-content[data-state='open'][data-side='bottom'] { + animation-name: radixPopoverSlideDownAndFade; +} +@keyframes radixPopoverSlideUpAndFade { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes radixPopoverSlideDownAndFade { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} -- cgit 1.4.1