'use client'; import * as React from 'react'; import { View } from 'react-native'; import { usePopoverRootContext } from '../root/PopoverRootContext'; import { usePopoverPortalContext } from '../portal/PopoverPortalContext'; import { useRenderElement } from '../../use-render/useRenderElement'; import { useIsoLayoutEffect } from '../../hooks/useIsoLayoutEffect'; import { useMergedRefs } from '../../hooks/useMergedRefs'; import { useAnchorPositioning, type Align, type PhysicalSide, type UseAnchorPositioningSharedParameters, } from '../../utils/useAnchorPositioning'; import type { ZestUIComponentProps } from '../../types'; import { PopoverPositionerContext } from './PopoverPositionerContext'; import { useStoreState } from '../../store/ReactStore'; /** * Positions the popover against the trigger. * Renders a ``. */ export function PopoverPositioner(componentProps: PopoverPositioner.Props) { const { align = 'center', alignOffset = 0, arrowPadding = 5, className, collisionPadding = 5, render, side = 'bottom', sideOffset = 0, sticky = false, style, ref, ...elementProps } = componentProps; usePopoverPortalContext(); const store = usePopoverRootContext(); const open = useStoreState(store, 'open'); const triggerWidth = useStoreState(store, 'triggerWidth'); const triggerHeight = useStoreState(store, 'triggerHeight'); const triggerNode = useStoreState(store, 'triggerNode'); const positioning = useAnchorPositioning({ align, open, alignOffset, arrowPadding, collisionPadding, side, sideOffset, sticky, }); const { arrowRef, arrowStyles, onArrowLayout, positionerStyles, refs, update } = positioning; // The anchor is measured from the trigger, which lives outside the portal. useIsoLayoutEffect(() => { refs.setReference(triggerNode ?? null); }, [refs, triggerNode]); // Nothing observes layout globally, so the trigger asks for a reposition // through the store when it moves. useIsoLayoutEffect(() => { store.set('update', update); return () => { store.set('update', undefined); }; }, [store, update]); const mergedRef = useMergedRefs(ref, refs.setFloating); const state: PopoverPositionerState = { open, side: positioning.side, align: positioning.align, triggerWidth, triggerHeight, }; const contextValue: PopoverPositionerContext = React.useMemo( () => ({ side: positioning.side, align: positioning.align, arrowRef, arrowStyles, onArrowLayout, }), [positioning.side, positioning.align, arrowRef, arrowStyles, onArrowLayout], ); const element = useRenderElement(View, componentProps, { state, ref: mergedRef, props: [ { style: positionerStyles, onLayout() { // The popup's own size feeds flip/shift, so recompute once it is laid out. update(); }, }, elementProps, ], }); return ( {element} ); } export interface PopoverPositionerState { /** * Whether the popover is currently open. */ open: boolean; /** * The side the popup was actually placed on, after collision handling. */ side: PhysicalSide; /** * The alignment the popup was actually placed with. */ align: Align; /** * The trigger's measured width, available for consumers to size the popup * against its anchor. This is the React Native equivalent of the web's * `--anchor-width` CSS variable. */ triggerWidth: number | undefined; /** * The trigger's measured height. */ triggerHeight: number | undefined; } export interface PopoverPositionerProps extends UseAnchorPositioningSharedParameters, ZestUIComponentProps {} export namespace PopoverPositioner { export type State = PopoverPositionerState; export type Props = PopoverPositionerProps; }