import React, { createContext, forwardRef, useContext, useEffect, useId, useState, } from 'react'; import { BackHandler, Pressable, StyleSheet, Text, 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, ContentRef, DialogContentProps, GroupLabelProps, GroupLabelRef, GroupProps, GroupRef, IRootContext, ItemIndicatorProps, ItemIndicatorRef, ItemLabelProps, ItemLabelRef, ItemProps, ItemRef, OverlayProps, OverlayRef, PopoverContentProps, PortalProps, RootProps, RootRef, TriggerProps, TriggerRef, ValueProps, ValueRef, } from './select.types'; const RootContext = createContext(null); const useRootContext = () => { const context = useContext(RootContext); if (!context) { throw new Error( 'Select compound components cannot be rendered outside the Select component' ); } return context; }; const Root = forwardRef( ( { asChild, value: valueProp, defaultValue, onValueChange: onValueChangeProp, isOpen: isOpenProp, isDefaultOpen, onOpenChange: onOpenChangeProp, closeDelay, isDisabled, ...viewProps }, ref ) => { const nativeID = useId(); const [value, onValueChange] = useControllableState({ prop: valueProp, defaultProp: defaultValue, onChange: onValueChangeProp, }); const [isOpen = false, onOpenChange] = useControllableState({ prop: isOpenProp, defaultProp: isDefaultOpen, onChange: onOpenChangeProp, }); const [triggerPosition, setTriggerPosition] = useState(null); const [contentLayout, setContentLayout] = useState( null ); 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; 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 ( ); } ); // -------------------------------------------------- const Value = React.forwardRef( ({ asChild, placeholder, ...props }, ref) => { const { value } = useRootContext(); const Component = asChild ? Slot.Text : Text; return ( {value?.label ?? placeholder} ); } ); // -------------------------------------------------- /** * @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 PopoverContent = 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 DialogContent = forwardRef( ({ asChild, forceMount, ...props }, ref) => { const { isOpen, nativeID, onOpenChange } = useRootContext(); useEffect(() => { const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { onOpenChange(false); return true; } ); return () => { backHandler.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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 ( ); } ); // -------------------------------------------------- const ItemContext = createContext<{ itemValue: string; label: string; } | null>(null); function useItemContext() { const context = useContext(ItemContext); if (!context) { throw new Error( 'Item compound components cannot be rendered outside of an Item component' ); } return context; } // -------------------------------------------------- const Item = React.forwardRef( ( { asChild, value: itemValue, label, onPress: onPressProp, disabled = false, closeOnPress = true, ...props }, ref ) => { const { onOpenChange, value, onValueChange, setTriggerPosition, setContentLayout, closeDelay, } = useRootContext(); const baseOnCloseDelay = 150; // This delay is needed to see change of indicator position first function onPress(ev: GestureResponderEvent) { onValueChange({ value: itemValue, label }); if (closeOnPress) { setTimeout( () => { setTriggerPosition(null); setContentLayout(null); }, baseOnCloseDelay + (closeDelay ?? 0) ); setTimeout(() => { onOpenChange(false); }, baseOnCloseDelay); } onPressProp?.(ev); } const Component = asChild ? Slot.Pressable : Pressable; return ( ); } ); // -------------------------------------------------- const ItemLabel = React.forwardRef( ({ asChild, ...props }, ref) => { const { label } = useItemContext(); const Component = asChild ? Slot.Text : Text; return ( {label} ); } ); // -------------------------------------------------- const ItemIndicator = React.forwardRef( ({ asChild, forceMount, ...props }, ref) => { const { itemValue } = useItemContext(); const { value } = useRootContext(); if (!forceMount) { if (value?.value !== itemValue) { return null; } } const Component = asChild ? Slot.View : View; return ; } ); // -------------------------------------------------- const Group = React.forwardRef( ({ asChild, ...props }, ref) => { const Component = asChild ? Slot.View : View; return ; } ); // -------------------------------------------------- const GroupLabel = React.forwardRef( ({ asChild, ...props }, ref) => { const Component = asChild ? Slot.Text : Text; return ; } ); // -------------------------------------------------- Root.displayName = 'HeroUINative.Primitive.Select.Root'; Trigger.displayName = 'HeroUINative.Primitive.Select.Trigger'; Value.displayName = 'HeroUINative.Primitive.Select.Value'; Overlay.displayName = 'HeroUINative.Primitive.Select.Overlay'; PopoverContent.displayName = 'HeroUINative.Primitive.Select.PopoverContent'; DialogContent.displayName = 'HeroUINative.Primitive.Select.DialogContent'; Close.displayName = 'HeroUINative.Primitive.Select.Close'; Item.displayName = 'HeroUINative.Primitive.Select.Item'; ItemLabel.displayName = 'HeroUINative.Primitive.Select.ItemLabel'; ItemIndicator.displayName = 'HeroUINative.Primitive.Select.ItemIndicator'; Group.displayName = 'HeroUINative.Primitive.Select.Group'; GroupLabel.displayName = 'HeroUINative.Primitive.Select.GroupLabel'; export { Close, DialogContent, Group, GroupLabel, Item, ItemIndicator, ItemLabel, Overlay, PopoverContent, Portal, Root, Trigger, useItemContext, useRootContext, Value, };