import React, { forwardRef, useContext, useEffect, useId, useState, } from 'react'; import { BackHandler, Pressable, StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent, type LayoutRectangle, } from 'react-native'; import { useAugmentedRef, useControllableState, useRelativePosition, type LayoutPosition, } from '../../helpers/hooks'; import { Portal as PrimitivePortal } from '../portal'; import * as Slot from '../slot'; import type { CloseProps, CloseRef, ContentProps, ContentRef, IRootContext, OverlayProps, OverlayRef, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef, } from './popover.types'; const RootContext = React.createContext(null); const useRootContext = () => { const context = useContext(RootContext); if (!context) { throw new Error( 'Popover compound components cannot be rendered outside the Popover component' ); } return context; }; const Root = forwardRef( ( { asChild, isOpen: isOpenProp, isDefaultOpen, onOpenChange: onOpenChangeProp, closeDelay, isDisabled, ...viewProps }, ref ) => { const [isOpen = false, onOpenChange] = useControllableState({ prop: isOpenProp, defaultProp: isDefaultOpen, onChange: onOpenChangeProp, }); const [triggerPosition, setTriggerPosition] = useState(null); const [contentLayout, setContentLayout] = useState( null ); const nativeID = useId(); const Component = asChild ? Slot.View : View; return ( ); } ); // -------------------------------------------------- const Trigger = forwardRef( ({ asChild, onPress: onPressProp, isDisabled = false, ...props }, ref) => { const { onOpenChange, isOpen, isDisabled: isDisabledRoot, setTriggerPosition, closeDelay, isDefaultOpen, triggerPosition, } = useRootContext(); const isDisabledValue = isDisabled ?? isDisabledRoot ?? undefined; const augmentedRef = useAugmentedRef({ ref, methods: { open: () => { onOpenChange(true); augmentedRef.current?.measure( (_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); } ); }, close: () => { onOpenChange(false); setTimeout(() => { setTriggerPosition(null); }, closeDelay); }, }, }); // Open popover on mount if isDefaultOpen is true useEffect(() => { if (isDefaultOpen && !triggerPosition) { // Use setTimeout to ensure the component is mounted and can be measured const timeoutId = setTimeout(() => { augmentedRef.current?.measure( (_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); onOpenChange(true); } ); }, 0); return () => clearTimeout(timeoutId); } return undefined; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function onPress(ev: GestureResponderEvent) { if (isDisabledValue) return; augmentedRef.current?.measure((_x, _y, width, height, pageX, pageY) => { setTriggerPosition({ width, pageX, pageY: pageY, height }); }); onOpenChange(!isOpen); onPressProp?.(ev); } const Component = asChild ? Slot.Pressable : Pressable; return ( ); } ); // -------------------------------------------------- /** * @warning when using a custom ``, you might have to adjust the Content's offset to account for nav elements like headers. */ function Portal({ forceMount, hostName, children }: PortalProps) { const value = useRootContext(); if (!value.triggerPosition) { return null; } if (!forceMount) { if (!value.isOpen) { return null; } } return ( {children} ); } // -------------------------------------------------- const Overlay = forwardRef( ( { asChild, forceMount, onPress: OnPressProp, closeOnPress = true, ...props }, ref ) => { const { isOpen, onOpenChange, setTriggerPosition, setContentLayout, closeDelay, } = useRootContext(); function onPress(ev: GestureResponderEvent) { if (closeOnPress) { setTimeout(() => { setTriggerPosition(null); setContentLayout(null); }, closeDelay); onOpenChange(false); } OnPressProp?.(ev); } if (!forceMount) { if (!isOpen) { return null; } } const Component = asChild ? Slot.Pressable : Pressable; return ; } ); // -------------------------------------------------- /** * @info `position`, `top`, `left`, and `maxWidth` style properties are controlled internally. Opt out of this behavior by setting `disablePositioningStyle` to `true`. */ const Content = forwardRef( ( { asChild = false, forceMount, align = 'start', placement = 'bottom', offset = 0, alignOffset = 0, avoidCollisions = true, onLayout: onLayoutProp, insets, style, disablePositioningStyle, width = 'content-fit', ...props }, ref ) => { const { isOpen, onOpenChange, contentLayout, nativeID, setContentLayout, setTriggerPosition, triggerPosition, closeDelay, } = useRootContext(); useEffect(() => { const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { setTimeout(() => { setTriggerPosition(null); setContentLayout(null); }, closeDelay); onOpenChange(false); return true; } ); return () => { setContentLayout(null); backHandler.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const positionStyle = useRelativePosition({ align, avoidCollisions, triggerPosition, contentLayout, alignOffset, insets, offset, placement, disablePositioningStyle, }); // Calculate content width based on width prop const widthStyle: { width?: number | `${number}%` } = {}; if (width === 'trigger' && triggerPosition) { widthStyle.width = triggerPosition.width; } else if (width === 'full') { widthStyle.width = '100%'; } else if (typeof width === 'number') { widthStyle.width = width; } // 'content-fit' is default - no explicit width set const flatStyle = StyleSheet.flatten([positionStyle, widthStyle, style]); function onLayout(event: LayoutChangeEvent) { setContentLayout(event.nativeEvent.layout); onLayoutProp?.(event); } if (!forceMount) { if (!isOpen) { return null; } } const Component = asChild ? Slot.View : View; return ( ); } ); // -------------------------------------------------- const Close = forwardRef( ({ asChild, onPress: onPressProp, disabled = false, ...props }, ref) => { const { onOpenChange, setContentLayout, setTriggerPosition, closeDelay } = useRootContext(); function onPress(ev: GestureResponderEvent) { if (disabled) return; setTimeout(() => { setTriggerPosition(null); setContentLayout(null); }, closeDelay); onOpenChange(false); onPressProp?.(ev); } const Component = asChild ? Slot.Pressable : Pressable; return ( ); } ); // -------------------------------------------------- Root.displayName = 'HeroUINative.Popover.Root'; Trigger.displayName = 'HeroUINative.Popover.Trigger'; Overlay.displayName = 'HeroUINative.Popover.Overlay'; Content.displayName = 'HeroUINative.Popover.Content'; Close.displayName = 'HeroUINative.Popover.Close'; export { Close, Content, Overlay, Portal, Root, Trigger, useRootContext };