Popover

PreviousNext

A popover component for React Native applications.

Docs
nativeuiui

Preview

Loading preview…
registry/popover/popover.tsx
import * as React from "react"
import {
  View,
  Text,
  Pressable,
  Modal,
  TouchableWithoutFeedback,
  Platform,
  Animated,
} from "react-native"
import { cn } from "@/lib/utils"

interface PopoverProps {
  children: React.ReactNode
  className?: string
}

interface PopoverTriggerProps {
  children: React.ReactNode
  className?: string
  disabled?: boolean
  asChild?: boolean
}

interface PopoverAnchorProps {
  children: React.ReactNode
  className?: string
}

interface PopoverContentProps {
  children: React.ReactNode
  className?: string
  align?: "start" | "center" | "end"
  side?: "top" | "right" | "bottom" | "left"
  sideOffset?: number
}

const PopoverContext = React.createContext<{
  open: boolean
  setOpen: React.Dispatch<React.SetStateAction<boolean>>
  triggerRef: React.RefObject<View>
  triggerLayout: { x: number; y: number; width: number; height: number } | null
  setTriggerLayout: React.Dispatch<React.SetStateAction<{ x: number; y: number; width: number; height: number } | null>>
  contentLayout: { width: number; height: number } | null
  setContentLayout: React.Dispatch<React.SetStateAction<{ width: number; height: number } | null>>
  isAnimating: boolean
  setIsAnimating: React.Dispatch<React.SetStateAction<boolean>>
}>({
  open: false,
  setOpen: () => { },
  triggerRef: { current: null },
  triggerLayout: null,
  setTriggerLayout: () => { },
  contentLayout: null,
  setContentLayout: () => { },
  isAnimating: false,
  setIsAnimating: () => { },
})

const Popover = React.forwardRef<View, PopoverProps>(
  ({ children, className, ...props }, ref) => {
    const [open, setOpen] = React.useState(false)
    const triggerRef = React.useRef<View>(null)
    const [triggerLayout, setTriggerLayout] = React.useState<{ x: number; y: number; width: number; height: number } | null>(null)
    const [contentLayout, setContentLayout] = React.useState<{ width: number; height: number } | null>(null)
    const [isAnimating, setIsAnimating] = React.useState(false)

    return (
      <PopoverContext.Provider
        value={{
          open,
          setOpen,
          triggerRef,
          triggerLayout,
          setTriggerLayout,
          contentLayout,
          setContentLayout,
          isAnimating,
          setIsAnimating,
        }}
      >
        <View ref={ref} className={cn("", className)} {...props}>
          {children}
        </View>
      </PopoverContext.Provider>
    )
  }
)

Popover.displayName = "Popover"

const PopoverTrigger = React.forwardRef<View, PopoverTriggerProps>(
  ({ children, className, disabled = false, ...props }, ref) => {
    const { setOpen, open, triggerRef, setTriggerLayout, isAnimating } = React.useContext(PopoverContext)

    const measureTrigger = () => {
      if (triggerRef.current) {
        triggerRef.current.measureInWindow((x, y, width, height) => {
          setTriggerLayout({ x, y, width, height })
        })
      }
    }

    return (
      <Pressable
        ref={triggerRef as any}
        className={cn("", className)}
        disabled={disabled || isAnimating}
        onPress={() => {
          if (open) {
            setOpen(false)
          } else {
            measureTrigger()
            setOpen(true)
          }
        }}
        accessibilityRole="button"
        {...props}
      >
        {children}
      </Pressable>
    )
  }
)

PopoverTrigger.displayName = "PopoverTrigger"

const PopoverAnchor = React.forwardRef<View, PopoverAnchorProps>(
  ({ children, className, ...props }, ref) => {
    return (
      <View ref={ref} className={cn("", className)} {...props}>
        {children}
      </View>
    )
  }
)

PopoverAnchor.displayName = "PopoverAnchor"

const PopoverContent = React.forwardRef<View, PopoverContentProps>(
  ({ children, className, align = "center", side = "bottom", sideOffset = 8, ...props }, ref) => {
    const {
      open,
      setOpen,
      triggerLayout,
      contentLayout,
      setContentLayout,
      setIsAnimating,
    } = React.useContext(PopoverContext)

    const contentRef = React.useRef<View>(null)
    const fadeAnim = React.useRef(new Animated.Value(0)).current

    React.useEffect(() => {
      if (open) {
        setIsAnimating(true)
        if (contentRef.current) {
          setTimeout(() => {
            contentRef.current?.measure((_x, _y, width, height) => {
              setContentLayout({ width, height })
              Animated.timing(fadeAnim, {
                toValue: 1,
                duration: 150, // Animation duration
                useNativeDriver: true,
              }).start(() => {
                setIsAnimating(false)
              })
            })
          }, 10)
        }
      } else {
        // Reset fadeAnim when closed
        fadeAnim.setValue(0)
      }

      // Cleanup animation when component unmounts
      return () => {
        fadeAnim.setValue(0)
      }
    }, [open, setContentLayout, fadeAnim, setIsAnimating])

    const closePopover = React.useCallback(() => {
      setIsAnimating(true)
      Animated.timing(fadeAnim, {
        toValue: 0,
        duration: 100,
        useNativeDriver: true,
      }).start(() => {
        setOpen(false)
        setIsAnimating(false)
      })
    }, [fadeAnim, setOpen, setIsAnimating])

    if (!open) return null

    const getPosition = () => {
      if (!triggerLayout || !contentLayout) return {}

      let left = 0
      let top = 0

      // Handle horizontal alignment
      if (align === "start") {
        left = triggerLayout.x
      } else if (align === "center") {
        left = triggerLayout.x + (triggerLayout.width / 2) - (contentLayout.width / 2)
      } else if (align === "end") {
        left = triggerLayout.x + triggerLayout.width - contentLayout.width
      }

      // Handle vertical positioning
      if (side === "top") {
        top = triggerLayout.y - contentLayout.height - sideOffset
      } else if (side === "bottom") {
        top = triggerLayout.y + triggerLayout.height + sideOffset
      } else if (side === "left") {
        left = triggerLayout.x - contentLayout.width - sideOffset
        top = triggerLayout.y + (triggerLayout.height / 2) - (contentLayout.height / 2)
      } else if (side === "right") {
        left = triggerLayout.x + triggerLayout.width + sideOffset
        top = triggerLayout.y + (triggerLayout.height / 2) - (contentLayout.height / 2)
      }

      // Ensure the popover stays within screen bounds
      left = Math.max(16, left)
      top = Math.max(50, top)

      return { left, top }
    }

    return (
      <Modal
        visible={open}
        transparent
        animationType="none"
        onRequestClose={closePopover}
      >
        <TouchableWithoutFeedback onPress={closePopover}>
          <View className="flex-1">
            <TouchableWithoutFeedback>
              <Animated.View
                ref={contentRef}
                style={[
                  getPosition(),
                  { opacity: fadeAnim }
                ]}
                className={cn(
                  "absolute rounded-md border border-border bg-popover p-4",
                  "shadow-lg min-w-[200px] max-w-[90%]",
                  Platform.OS === "ios" ? "ios:shadow-lg" : "android:elevation-4",
                  className
                )}
                {...props}
              >
                {children}
              </Animated.View>
            </TouchableWithoutFeedback>
          </View>
        </TouchableWithoutFeedback>
      </Modal>
    )
  }
)

PopoverContent.displayName = "PopoverContent"

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

Installation

npx shadcn@latest add @nativeui/popover

Usage

import { Popover } from "@/components/ui/popover"
<Popover />