about summary refs log tree commit diff
path: root/src
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
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')
-rw-r--r--src/App.native.tsx13
-rw-r--r--src/alf/atoms.ts4
-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
-rw-r--r--src/components/hooks/useOnGesture/index.ts24
-rw-r--r--src/components/hooks/useOnGesture/index.web.ts1
-rw-r--r--src/state/global-gesture-events/index.tsx83
-rw-r--r--src/state/global-gesture-events/index.web.tsx9
-rw-r--r--src/style.css35
10 files changed, 693 insertions, 5 deletions
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() {
                                                 <HideBottomBarBorderProvider>
                                                   <GestureHandlerRootView
                                                     style={s.h100pct}>
-                                                    <IntentDialogProvider>
-                                                      <TestCtrls />
-                                                      <Shell />
-                                                      <NuxDialogs />
-                                                    </IntentDialogProvider>
+                                                    <GlobalGestureEventsProvider>
+                                                      <IntentDialogProvider>
+                                                        <TestCtrls />
+                                                        <Shell />
+                                                        <NuxDialogs />
+                                                      </IntentDialogProvider>
+                                                    </GlobalGestureEventsProvider>
                                                   </GestureHandlerRootView>
                                                 </HideBottomBarBorderProvider>
                                               </ServiceAccountManager>
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<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>
+  )
+}
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<PanGestureHandlerEventPayload>
+  update: GestureUpdateEvent<PanGestureHandlerEventPayload>
+  end: GestureStateChangeEvent<PanGestureHandlerEventPayload>
+  finalize: GestureStateChangeEvent<PanGestureHandlerEventPayload>
+}
+
+const Context = createContext<{
+  events: EventEmitter<GlobalGestureEvents>
+  register: () => void
+  unregister: () => void
+}>({
+  events: new EventEmitter<GlobalGestureEvents>(),
+  register: () => {},
+  unregister: () => {},
+})
+
+export function GlobalGestureEventsProvider({
+  children,
+}: {
+  children: React.ReactNode
+}) {
+  const refCount = useRef(0)
+  const events = useMemo(() => new EventEmitter<GlobalGestureEvents>(), [])
+  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 (
+    <Context.Provider value={ctx}>
+      <GestureDetector gesture={gesture}>
+        <View collapsable={false}>{children}</View>
+      </GestureDetector>
+    </Context.Provider>
+  )
+}
+
+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);
+  }
+}