import type BottomSheet from '@gorhom/bottom-sheet'; import { createContext, forwardRef, use, useMemo } from 'react'; import type { GestureResponderEvent, StyleProp, ViewStyle } from 'react-native'; import { StyleSheet, useWindowDimensions, View } from 'react-native'; import Animated from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useThemeColor } from '../../helpers/external/hooks'; import { cn } from '../../helpers/external/utils'; import { BottomSheetContent, FullWindowOverlay, HeroText, ThemeBackground, } from '../../helpers/internal/components'; import { AnimationSettingsProvider, useAnimationSettings, } from '../../helpers/internal/contexts'; import { usePopupOverlayAnimation, usePopupPopoverContentAnimation, usePopupRootAnimation, } from '../../helpers/internal/hooks'; import type { PressableRef, TextRef, ViewRef, } from '../../helpers/internal/types'; import * as PopoverPrimitives from '../../primitives/popover'; import * as PopoverPrimitivesTypes from '../../primitives/popover/popover.types'; import { CloseButton } from '../close-button'; import { ArrowSvg } from './arrow-svg'; import { PopoverAnimationProvider, usePopoverAnimation, } from './popover.animation'; import { DEFAULT_ALIGN_OFFSET, DEFAULT_INSETS, DEFAULT_OFFSET, DISPLAY_NAME, } from './popover.constants'; import { popoverClassNames, popoverStyleSheet } from './popover.styles'; import type { PopoverArrowProps, PopoverCloseProps, PopoverContentBackgroundProps, PopoverContentBottomSheetProps, PopoverContentContextValue, PopoverContentPopoverProps, PopoverContentProps, PopoverDescriptionProps, PopoverOverlayProps, PopoverPortalProps, PopoverRootProps, PopoverTitleProps, PopoverTriggerProps, } from './popover.types'; const AnimatedOverlay = Animated.createAnimatedComponent( PopoverPrimitives.Overlay ); const AnimatedContent = Animated.createAnimatedComponent( PopoverPrimitives.Content ); const usePopover = PopoverPrimitives.useRootContext; const PopoverContentContext = createContext({ placement: undefined, }); // -------------------------------------------------- const PopoverRoot = forwardRef< PopoverPrimitivesTypes.RootRef, PopoverRootProps >( ( { children, isOpen: isOpenProp, isDefaultOpen, onOpenChange: onOpenChangeProp, presentation = 'popover', animation, ...props }, ref ) => { const { isAllAnimationsDisabled, progress, isDragging } = usePopupRootAnimation({ animation, }); const animationContextValue = useMemo( () => ({ progress, isDragging, }), [progress, isDragging] ); const animationSettingsContextValue = useMemo( () => ({ isAllAnimationsDisabled, }), [isAllAnimationsDisabled] ); return ( {children} ); } ); // -------------------------------------------------- const PopoverTrigger = forwardRef< PopoverPrimitivesTypes.TriggerRef, PopoverTriggerProps >((props, ref) => { return ; }); // -------------------------------------------------- const PopoverPortal = ({ className, children, disableFullWindowOverlay = false, unstable_accessibilityContainerViewIsModal, ...props }: PopoverPortalProps) => { const animationSettingsContext = useAnimationSettings(); const animationContext = usePopoverAnimation(); const portalClassName = popoverClassNames.portal({ className }); return ( {children} ); }; // -------------------------------------------------- const PopoverOverlay = forwardRef< PopoverPrimitivesTypes.OverlayRef, PopoverOverlayProps >( ( { className, style, animation, isAnimatedStyleActive = true, ...props }, ref ) => { const { isOpen, presentation } = usePopover(); const { progress, isDragging } = usePopoverAnimation(); const overlayClassName = popoverClassNames.overlay({ className }); const { rContainerStyle, entering, exiting } = usePopupOverlayAnimation({ progress: presentation === 'bottom-sheet' ? progress : undefined, isDragging: presentation === 'bottom-sheet' ? isDragging : undefined, animation, }); const overlayStyle = isAnimatedStyleActive ? [rContainerStyle, style] : style; return ( ); } ); // -------------------------------------------------- /** * Generic absolute-fill background container rendered behind the popover * content. With no `children`, the active library theme decides the default * content: `glass` renders a `GlassView` blur layer; other themes render * nothing. Pass `children` to host arbitrary content (gradients, images) * with the container's positioning and clipping applied. */ const PopoverContentBackground = forwardRef< ViewRef, PopoverContentBackgroundProps >(({ className, ...props }, ref) => { const contentBackgroundClassName = popoverClassNames.contentBackground({ className, }); return ( ); }); // -------------------------------------------------- const PopoverContentPopover = forwardRef< PopoverPrimitivesTypes.ContentRef, PopoverContentPopoverProps >( ( { placement = 'bottom', align = 'center', avoidCollisions = true, offset = DEFAULT_OFFSET, alignOffset = DEFAULT_ALIGN_OFFSET, className, children, background, style, animation, ...props }, ref ) => { const { contentLayout } = usePopover(); const safeAreaInsets = useSafeAreaInsets(); const { height: screenHeight } = useWindowDimensions(); // Initially useRelativePosition returns { position: 'absolute', opacity: 0, top: dimensions.height } // So we need to wait for the content to be ready before showing it const isReady = Boolean(contentLayout?.y && contentLayout.y < screenHeight); const insets = { top: DEFAULT_INSETS.top + safeAreaInsets.top, bottom: DEFAULT_INSETS.bottom + safeAreaInsets.bottom, left: DEFAULT_INSETS.left + safeAreaInsets.left, right: DEFAULT_INSETS.right + safeAreaInsets.right, }; const contentClassName = popoverClassNames.content({ className, }); const { isDrivenEntering, rEnteringStyle, entering, exiting } = usePopupPopoverContentAnimation({ placement, offset, animation, isReady, }); /** * Background layer rendered behind the popover content. `undefined` * falls back to the theme-aware default; `null` removes the layer. */ const backgroundElement = background === undefined ? : background; const contentChildren = ( <> {backgroundElement} {children} ); // Single-mount path: the content subtree is rendered once and animated in // via a shared value once it has been measured and positioned (`isReady`). if (isDrivenEntering) { return ( {/* The wrapper must fill the portal: Android only dispatches native touches to children inside their parent's bounds, so a zero-height wrapper breaks native scrolling in the content. */} {contentChildren} ); } // Fallback path: a custom entering Keyframe was provided, which must fire // on mount, so a hidden probe measures the content before the visible node // mounts and plays the Keyframe. return ( {isReady && ( {contentChildren} )} {children} ); } ); // -------------------------------------------------- const PopoverContentBottomSheet = forwardRef< BottomSheet, PopoverContentBottomSheetProps >( ( { children, index: initialIndex, backgroundClassName, handleIndicatorClassName, contentContainerClassName: contentContainerClassNameProp, contentContainerProps, animation, animationConfigs, ...restProps }, ref ) => { const { isOpen, onOpenChange } = usePopover(); const { progress, isDragging } = usePopoverAnimation(); return ( {children} ); } ); // -------------------------------------------------- const PopoverContent = forwardRef< PopoverPrimitivesTypes.ContentRef | BottomSheet, PopoverContentProps >((props, ref) => { const { presentation: contextPresentation } = usePopover(); if (__DEV__) { if (props.presentation !== contextPresentation) { throw new Error( `Popover.Content presentation prop ("${props.presentation}") does not match Popover.Root presentation prop ("${contextPresentation}"). They must be the same.` ); } } if (props.presentation === 'bottom-sheet') { return ( } {...(props as PopoverContentBottomSheetProps)} /> ); } return ( } {...(props as PopoverContentPopoverProps)} /> ); }); // -------------------------------------------------- const PopoverClose = forwardRef( (props, ref) => { const { onPress: onPressProp, ...restProps } = props; const { onOpenChange } = usePopover(); const onPress = (ev: GestureResponderEvent) => { onOpenChange(false); if (typeof onPressProp === 'function') { onPressProp(ev); } }; return ; } ); // -------------------------------------------------- const PopoverTitle = forwardRef( ({ className, children, ...props }, ref) => { const titleClassName = popoverClassNames.label({ className }); return ( {children} ); } ); // -------------------------------------------------- const PopoverDescription = forwardRef( ({ className, children, ...props }, ref) => { const descriptionClassName = popoverClassNames.description({ className, }); return ( {children} ); } ); // -------------------------------------------------- const PopoverArrow = forwardRef( ( { children, style, className, height = 12, width = 20, fill, stroke, strokeWidth = 1, placement: placementLocal, strokeBaselineInset = 1, }, ref ) => { const [themeColorOverlay, themeColorBorder] = useThemeColor([ 'overlay', 'border', ]); const { triggerPosition, contentLayout } = usePopover(); const { placement: placementContext } = use(PopoverContentContext); const placement = placementLocal || placementContext; const arrowClassName = popoverClassNames.arrow({ className }); if ( !triggerPosition || !contentLayout || contentLayout.x === 0 || contentLayout.y === 0 || !placement ) { return null; } const arrowFill = fill || themeColorOverlay; const arrowStroke = stroke || themeColorBorder; const getArrowPosition = (): StyleProp => { const triggerCenterX = triggerPosition.pageX + triggerPosition.width / 2; const triggerCenterY = triggerPosition.pageY + triggerPosition.height / 2; const baseStyle: ViewStyle = { position: 'absolute', }; switch (placement) { case 'top': return { ...baseStyle, bottom: -height + strokeBaselineInset, left: Math.min( Math.max(12, triggerCenterX - contentLayout.x - width / 2), contentLayout.width - width - 12 ), }; case 'bottom': return { ...baseStyle, top: -height + strokeBaselineInset, left: Math.min( Math.max(12, triggerCenterX - contentLayout.x - width / 2), contentLayout.width - width - 12 ), }; case 'left': return { ...baseStyle, right: -height + strokeBaselineInset, top: Math.min( Math.max(12, triggerCenterY - contentLayout.y - width / 2), contentLayout.height - width - 12 ), }; case 'right': return { ...baseStyle, left: -height + strokeBaselineInset, top: Math.min( Math.max(12, triggerCenterY - contentLayout.y - width / 2), contentLayout.height - width - 12 ), }; default: return baseStyle; } }; const arrowPositionStyle = getArrowPosition(); return ( {children ? ( children ) : ( )} ); } ); // -------------------------------------------------- PopoverRoot.displayName = DISPLAY_NAME.ROOT; PopoverTrigger.displayName = DISPLAY_NAME.TRIGGER; PopoverPortal.displayName = DISPLAY_NAME.PORTAL; PopoverOverlay.displayName = DISPLAY_NAME.OVERLAY; PopoverContent.displayName = DISPLAY_NAME.CONTENT; PopoverContentBackground.displayName = DISPLAY_NAME.CONTENT_BACKGROUND; PopoverClose.displayName = DISPLAY_NAME.CLOSE; PopoverTitle.displayName = DISPLAY_NAME.TITLE; PopoverDescription.displayName = DISPLAY_NAME.DESCRIPTION; PopoverArrow.displayName = DISPLAY_NAME.ARROW; /** * Compound Popover component with sub-components * * @component Popover - Main container that manages open/close state, positioning, * and provides context to child components. Handles placement, alignment, and collision detection. * * @component Popover.Trigger - Clickable element that toggles the popover visibility. * Wraps any child element with press handlers. * * @component Popover.Portal - Renders popover content in a portal layer above other content. * Ensures proper stacking and positioning. * * @component Popover.Overlay - Optional background overlay. Can be transparent or * semi-transparent to capture outside clicks. * * @component Popover.Content - Container for popover content with two presentation modes: * default floating popover with positioning and collision detection, or bottom sheet modal. * Supports arrow indicators and custom animations. * * @component Popover.ContentBackground - Absolute-fill background container behind * the popover content. With no children, the active library theme decides the content * (glass theme renders a blur layer). Accepts children to host custom content such * as gradients with the container's positioning and clipping applied. Replaceable * via the `background` prop on Popover.Content. * * @component Popover.Arrow - Optional arrow indicator pointing to the trigger element. * Automatically positions itself based on popover placement. * * @component Popover.Close - Close button for the popover. * Can accept custom children or uses default close icon. * * @component Popover.Title - Optional title text with pre-styled typography. * * @component Popover.Description - Optional description text with muted styling. * * Props flow from Popover to sub-components via context (placement, align, offset, etc.). * The popover automatically positions itself relative to the trigger element. * * @see Full documentation: https://heroui.com/docs/native/components/popover */ const Popover = Object.assign(PopoverRoot, { Trigger: PopoverTrigger, Portal: PopoverPortal, Overlay: PopoverOverlay, Content: PopoverContent, ContentBackground: PopoverContentBackground, Arrow: PopoverArrow, Close: PopoverClose, Title: PopoverTitle, Description: PopoverDescription, }); export { usePopover, usePopoverAnimation }; export default Popover;