import BottomSheet from '@gorhom/bottom-sheet'; import { createContext, forwardRef, useMemo } from 'react'; import type { GestureResponderEvent, Text as RNText } 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, CheckIcon, FullWindowOverlay, HeroText, } from '../../helpers/internal/components'; import { AnimationSettingsProvider, useAnimationSettings, } from '../../helpers/internal/contexts'; import { usePopupOverlayAnimation, usePopupPopoverContentAnimation, usePopupRootAnimation, } from '../../helpers/internal/hooks'; import type { PressableRef } from '../../helpers/internal/types'; import { childrenToString } from '../../helpers/internal/utils'; import * as MenuPrimitives from '../../primitives/menu'; import * as MenuPrimitivesTypes from '../../primitives/menu/menu.types'; import { CloseButton } from '../close-button'; import { MenuAnimationProvider, useMenuAnimation, useMenuItemAnimation, } from './menu.animation'; import { DEFAULT_ALIGN_OFFSET, DEFAULT_INSETS, DEFAULT_OFFSET, DISPLAY_NAME, } from './menu.constants'; import { menuClassNames, menuStyleSheet } from './menu.styles'; import type { MenuCloseProps, MenuContentBottomSheetProps, MenuContentContextValue, MenuContentPopoverProps, MenuContentProps, MenuGroupProps, MenuItemDescriptionProps, MenuItemIndicatorProps, MenuItemProps, MenuItemTitleProps, MenuLabelProps, MenuOverlayProps, MenuPortalProps, MenuRootProps, MenuTriggerProps, } from './menu.types'; const AnimatedOverlay = Animated.createAnimatedComponent( MenuPrimitives.Overlay ); const AnimatedContent = Animated.createAnimatedComponent( MenuPrimitives.Content ); const AnimatedItem = Animated.createAnimatedComponent(MenuPrimitives.Item); const useMenu = MenuPrimitives.useRootContext; const useMenuItem = MenuPrimitives.useItemContext; const MenuContentContext = createContext({ placement: undefined, }); // -------------------------------------------------- const MenuRoot = forwardRef( ( { 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 MenuTrigger = forwardRef< MenuPrimitivesTypes.TriggerRef, MenuTriggerProps >((props, ref) => { return ; }); // -------------------------------------------------- const MenuPortal = ({ className, children, disableFullWindowOverlay = false, ...props }: MenuPortalProps) => { const animationSettingsContext = useAnimationSettings(); const animationContext = useMenuAnimation(); const portalClassName = menuClassNames.portal({ className }); return ( {children} ); }; // -------------------------------------------------- const MenuOverlay = forwardRef< MenuPrimitivesTypes.OverlayRef, MenuOverlayProps >( ( { className, style, animation, isAnimatedStyleActive = true, ...props }, ref ) => { const { isOpen, presentation } = useMenu(); const { progress, isDragging } = useMenuAnimation(); const overlayClassName = menuClassNames.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 ( ); } ); // -------------------------------------------------- const MenuContentPopover = forwardRef< MenuPrimitivesTypes.ContentRef, MenuContentPopoverProps >( ( { placement = 'bottom', align = 'center', avoidCollisions = true, offset = DEFAULT_OFFSET, alignOffset = DEFAULT_ALIGN_OFFSET, className, children, style, animation, ...props }, ref ) => { const { contentLayout } = useMenu(); const safeAreaInsets = useSafeAreaInsets(); const { height: screenHeight } = useWindowDimensions(); 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 = menuClassNames.content({ className, }); const { entering, exiting } = usePopupPopoverContentAnimation({ placement, offset, animation, }); return ( {isReady && ( {children} )} {children} ); } ); // -------------------------------------------------- const MenuContentBottomSheet = forwardRef< BottomSheet, MenuContentBottomSheetProps >( ( { children, index: initialIndex, backgroundClassName, handleIndicatorClassName, contentContainerClassName: contentContainerClassNameProp, contentContainerProps, animation, animationConfigs, ...restProps }, ref ) => { const { isOpen, onOpenChange } = useMenu(); const { progress, isDragging } = useMenuAnimation(); const contentContainerClassName = menuClassNames.contentBottomSheet({ className: contentContainerClassNameProp, }); return ( {children} ); } ); // -------------------------------------------------- const MenuContent = forwardRef< MenuPrimitivesTypes.ContentRef | BottomSheet, MenuContentProps >((props, ref) => { const { presentation: contextPresentation } = useMenu(); if (__DEV__) { if (props.presentation !== contextPresentation) { throw new Error( `Menu.Content presentation prop ("${props.presentation}") does not match Menu.Root presentation prop ("${contextPresentation}"). They must be the same.` ); } } if (props.presentation === 'bottom-sheet') { return ( } {...(props as MenuContentBottomSheetProps)} /> ); } return ( } {...(props as MenuContentPopoverProps)} /> ); }); // -------------------------------------------------- const MenuClose = forwardRef((props, ref) => { const { onPress: onPressProp, ...restProps } = props; const { onOpenChange } = useMenu(); const onPress = (ev: GestureResponderEvent) => { onOpenChange(false); if (typeof onPressProp === 'function') { onPressProp(ev); } }; return ; }); // -------------------------------------------------- const MenuLabel = forwardRef( ({ className, children, ...props }, ref) => { const labelClassName = menuClassNames.label({ className }); return ( {children} ); } ); // -------------------------------------------------- const MenuGroup = forwardRef( ({ className, children, ...props }, ref) => { const groupClassName = menuClassNames.group({ className }); return ( {children} ); } ); // -------------------------------------------------- const MenuItemComponent = forwardRef< MenuPrimitivesTypes.ItemRef, MenuItemProps >( ( { children, className, style, isDisabled = false, variant = 'default', animation, isAnimatedStyleActive = true, onPressIn, onPressOut, ...props }, ref ) => { const { rItemStyle, isPressed, animationOnPressIn, animationOnPressOut } = useMenuItemAnimation({ animation, variant }); const itemClassName = menuClassNames.item({ className, isDisabled }); const isSelected = props.isSelected ?? false; const handlePressIn = (event: GestureResponderEvent) => { animationOnPressIn(); onPressIn?.(event); }; const handlePressOut = (event: GestureResponderEvent) => { animationOnPressOut(); onPressOut?.(event); }; const resolvedChildren = typeof children === 'function' ? children({ isSelected, isDisabled, isPressed, variant, }) : children; const stringifiedChildren = typeof children !== 'function' ? childrenToString(children) : null; const content = stringifiedChildren ? ( {stringifiedChildren} ) : ( resolvedChildren ); const itemStyle = isAnimatedStyleActive ? [menuStyleSheet.borderCurve, rItemStyle, style] : [menuStyleSheet.borderCurve, style]; return ( {content} ); } ); // -------------------------------------------------- const MenuItemTitle = forwardRef( ({ className, children, ...props }, ref) => { const { variant } = useMenuItem(); const itemTitleClassName = menuClassNames.itemTitle({ className, variant }); return ( {children} ); } ); // -------------------------------------------------- const MenuItemDescription = forwardRef( ({ className, children, ...props }, ref) => { const itemDescriptionClassName = menuClassNames.itemDescription({ className, }); return ( {children} ); } ); // -------------------------------------------------- const MenuItemIndicator = forwardRef< MenuPrimitivesTypes.ItemIndicatorRef, MenuItemIndicatorProps >( ( { className, children, variant = 'checkmark', iconProps, forceMount = true, ...props }, ref ) => { const { isSelected } = useMenuItem(); const themeColorMuted = useThemeColor('muted'); const iconSize = iconProps?.size ?? (variant === 'dot' ? 8 : 16); const iconColor = iconProps?.color ?? themeColorMuted; const itemIndicatorClassName = menuClassNames.itemIndicator({ className }); const defaultContent = variant === 'dot' ? ( ) : ( ); return ( {isSelected && (children ?? defaultContent)} ); } ); // -------------------------------------------------- MenuRoot.displayName = DISPLAY_NAME.ROOT; MenuTrigger.displayName = DISPLAY_NAME.TRIGGER; MenuPortal.displayName = DISPLAY_NAME.PORTAL; MenuOverlay.displayName = DISPLAY_NAME.OVERLAY; MenuContent.displayName = DISPLAY_NAME.CONTENT; MenuClose.displayName = DISPLAY_NAME.CLOSE; MenuGroup.displayName = DISPLAY_NAME.GROUP; MenuLabel.displayName = DISPLAY_NAME.LABEL; MenuItemComponent.displayName = DISPLAY_NAME.ITEM; MenuItemTitle.displayName = DISPLAY_NAME.ITEM_TITLE; MenuItemDescription.displayName = DISPLAY_NAME.ITEM_DESCRIPTION; MenuItemIndicator.displayName = DISPLAY_NAME.ITEM_INDICATOR; /** * Compound Menu component with sub-components * * @component Menu - Main container that manages open/close state, positioning, * and provides context to child components. * * @component Menu.Trigger - Clickable element that toggles the menu visibility. * * @component Menu.Portal - Renders menu content in a portal layer above other content. * * @component Menu.Overlay - Optional background overlay to capture outside clicks. * * @component Menu.Content - Container for menu content with two presentation modes: * default floating popover with positioning and collision detection, or bottom sheet modal. * * @component Menu.Close - Close button for the menu. * * @component Menu.Group - Groups menu items with optional selection state (none, single, multiple). * * @component Menu.Label - Non-interactive section heading text within the menu. * * @component Menu.Item - Pressable menu item. Standalone or within a Group for selection. * * @component Menu.ItemTitle - Primary label text for a menu item. * * @component Menu.ItemDescription - Secondary description text for a menu item. * * @component Menu.ItemIndicator - Visual selection indicator (e.g. checkmark) for a menu item. */ const Menu = Object.assign(MenuRoot, { Trigger: MenuTrigger, Portal: MenuPortal, Overlay: MenuOverlay, Content: MenuContent, Close: MenuClose, Group: MenuGroup, Label: MenuLabel, Item: MenuItemComponent, ItemTitle: MenuItemTitle, ItemDescription: MenuItemDescription, ItemIndicator: MenuItemIndicator, }); export { useMenu, useMenuAnimation, useMenuItem }; export default Menu;