about summary refs log tree commit diff
path: root/src/components/Tooltip
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-06-24 22:03:23 -0500
committerGitHub <noreply@github.com>2025-06-24 22:03:23 -0500
commit4c1515169af81f5eb861e476d2bc07f17c8635fd (patch)
tree37be0de4769da65b3afde1cd1f890afecde14f3b /src/components/Tooltip
parentcd820709b611a0381222a34d2bc6ff49fa380452 (diff)
downloadvoidsky-4c1515169af81f5eb861e476d2bc07f17c8635fd.tar.zst
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 <mozzius@protonmail.com>
Diffstat (limited to 'src/components/Tooltip')
-rw-r--r--src/components/Tooltip/const.ts6
-rw-r--r--src/components/Tooltip/index.tsx411
-rw-r--r--src/components/Tooltip/index.web.tsx112
3 files changed, 529 insertions, 0 deletions
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<View>
+}
+
+const TooltipContext = createContext<TooltipContextType>({
+  position: 'bottom',
+  ready: false,
+  onVisibleChange: () => {},
+})
+
+const TargetContext = createContext<TargetContextType>({
+  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<View>(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 (
+    <TooltipContext.Provider value={ctx}>
+      <TargetContext.Provider value={targetCtx}>
+        {children}
+      </TargetContext.Provider>
+    </TooltipContext.Provider>
+  )
+}
+
+export function Target({children}: {children: React.ReactNode}) {
+  const {targetRef} = useContext(TargetContext)
+
+  return (
+    <View collapsable={false} ref={targetRef}>
+      {children}
+    </View>
+  )
+}
+
+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 (
+    <Portal>
+      <Bubble
+        label={label}
+        position={position}
+        /*
+         * Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
+         * the context providers.
+         */
+        targetMeasurements={targetMeasurements}
+        requestClose={requestClose}>
+        {children}
+      </Bubble>
+    </Portal>
+  )
+}
+
+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 (
+    <View
+      accessible
+      role="alert"
+      accessibilityHint=""
+      accessibilityLabel={label}
+      // android
+      importantForAccessibility="yes"
+      // ios
+      accessibilityViewIsModal
+      style={[
+        a.absolute,
+        a.align_start,
+        {
+          width: BUBBLE_MAX_WIDTH,
+          opacity: bubbleMeasurements ? 1 : 0,
+          top: coords.top,
+          left: coords.left,
+        },
+      ]}>
+      <Animated.View
+        entering={ZoomIn.easing(Easing.out(Easing.exp))}
+        style={{transformOrigin: oppposite(position)}}>
+        <View
+          style={[
+            a.absolute,
+            a.top_0,
+            a.z_10,
+            t.atoms.bg,
+            select(t.name, {
+              light: t.atoms.bg,
+              dark: t.atoms.bg_contrast_100,
+              dim: t.atoms.bg_contrast_100,
+            }),
+            {
+              borderTopLeftRadius: a.rounded_2xs.borderRadius,
+              borderBottomRightRadius: a.rounded_2xs.borderRadius,
+              width: ARROW_SIZE,
+              height: ARROW_SIZE,
+              transform: [{rotate: '45deg'}],
+              top: coords.tipTop,
+              left: coords.tipLeft,
+            },
+          ]}
+        />
+        <View
+          style={[
+            a.px_md,
+            a.py_sm,
+            a.rounded_sm,
+            select(t.name, {
+              light: t.atoms.bg,
+              dark: t.atoms.bg_contrast_100,
+              dim: t.atoms.bg_contrast_100,
+            }),
+            t.atoms.shadow_md,
+            {
+              shadowOpacity: 0.2,
+              shadowOffset: {
+                width: 0,
+                height:
+                  BUBBLE_SHADOW_OFFSET *
+                  (coords.computedPosition === 'bottom' ? -1 : 1),
+              },
+            },
+          ]}
+          onLayout={e => {
+            setBubbleMeasurements({
+              width: e.nativeEvent.layout.width,
+              height: e.nativeEvent.layout.height,
+            })
+          }}>
+          {children}
+        </View>
+      </Animated.View>
+    </View>
+  )
+}
+
+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 (
+    <Content label={c.join(' ')}>
+      <View style={[a.gap_xs]}>
+        {c.map((child, i) => (
+          <Text key={i} style={[a.text_sm, a.leading_snug]}>
+            {child}
+          </Text>
+        ))}
+      </View>
+    </Content>
+  )
+}
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<TooltipContextType>({
+  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 (
+    <Popover.Root open={visible} onOpenChange={onVisibleChange}>
+      <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
+    </Popover.Root>
+  )
+}
+
+export function Target({children}: {children: React.ReactNode}) {
+  return (
+    <Popover.Trigger asChild>
+      <View collapsable={false}>{children}</View>
+    </Popover.Trigger>
+  )
+}
+
+export function Content({
+  children,
+  label,
+}: {
+  children: React.ReactNode
+  label: string
+}) {
+  const t = useTheme()
+  const {position} = useContext(TooltipContext)
+  return (
+    <Popover.Portal>
+      <Popover.Content
+        className="radix-popover-content"
+        aria-label={label}
+        side={position}
+        sideOffset={4}
+        collisionPadding={MIN_EDGE_SPACE}
+        style={flatten([
+          a.rounded_sm,
+          select(t.name, {
+            light: t.atoms.bg,
+            dark: t.atoms.bg_contrast_100,
+            dim: t.atoms.bg_contrast_100,
+          }),
+          {
+            minWidth: 'max-content',
+            boxShadow: select(t.name, {
+              light: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
+              dark: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
+              dim: `0 0 24px ${transparentifyColor(t.palette.black, 0.2)}`,
+            }),
+          },
+        ])}>
+        <Popover.Arrow
+          width={ARROW_SIZE}
+          height={ARROW_SIZE / 2}
+          fill={select(t.name, {
+            light: t.atoms.bg.backgroundColor,
+            dark: t.atoms.bg_contrast_100.backgroundColor,
+            dim: t.atoms.bg_contrast_100.backgroundColor,
+          })}
+        />
+        <View style={[a.px_md, a.py_sm, {maxWidth: BUBBLE_MAX_WIDTH}]}>
+          {children}
+        </View>
+      </Popover.Content>
+    </Popover.Portal>
+  )
+}
+
+export function TextBubble({children}: {children: React.ReactNode}) {
+  const c = Children.toArray(children)
+  return (
+    <Content label={c.join(' ')}>
+      <View style={[a.gap_xs]}>
+        {c.map((child, i) => (
+          <Text key={i} style={[a.text_sm, a.leading_snug]}>
+            {child}
+          </Text>
+        ))}
+      </View>
+    </Content>
+  )
+}