import type BottomSheet from '@gorhom/bottom-sheet'; import { createContext, forwardRef, useMemo } from 'react'; import type { GestureResponderEvent } from 'react-native'; import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native'; import Animated, { FadeOut } 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, 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 { 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 { useSubMenu } from '../sub-menu'; import { MenuAnimationProvider, useMenuAnimation, useMenuContentPopoverAnimation, 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, MenuContentBackgroundProps, 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, unstable_accessibilityContainerViewIsModal, ...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 ( ); } ); // -------------------------------------------------- /** * Generic absolute-fill background container rendered behind the menu * 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 MenuContentBackground = forwardRef( ({ className, ...props }, ref) => { const contentBackgroundClassName = menuClassNames.contentBackground({ className, }); return ( ); } ); // -------------------------------------------------- const MenuContentPopover = forwardRef< MenuPrimitivesTypes.ContentRef, MenuContentPopoverProps >( ( { placement = 'bottom', align = 'center', avoidCollisions = true, offset = DEFAULT_OFFSET, alignOffset = DEFAULT_ALIGN_OFFSET, className, children, background, style, animation, ...props }, ref ) => { const { contentLayout, isSubMenuOpen, openSubMenuId, closeSubMenu } = 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({ isSubMenuOpen, className, }); const { isDrivenEntering, rEnteringStyle, entering, exiting } = usePopupPopoverContentAnimation({ placement, offset, animation, isReady, }); const rContainerStyle = useMenuContentPopoverAnimation({ isSubMenuOpen, animation, }); /** * Background layer rendered behind the menu content. `undefined` falls * back to the theme-aware default; `null` removes the layer. The default * layer is suppressed while a sub-menu is open to avoid a stacked * double-blur seam under the sub-menu's own background layer. */ const backgroundElement = background === undefined ? !isSubMenuOpen && : background; // Single-mount path: the content subtree is rendered once and animated in // via a shared value once it has been measured and positioned (`isReady`). // `rEnteringStyle` precedes `rContainerStyle` so the sub-menu scale still // wins once the enter transition has settled, while opacity comes from the // entering style. 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. */} {backgroundElement} {children} {isSubMenuOpen && ( { if (openSubMenuId !== null) { closeSubMenu(openSubMenuId); } }} /> )} ); } // 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 && ( // The `entering`/`exiting` layout animations live on a wrapper so they // never share transform with the `rContainerStyle` animated style on // the content node (avoids the Reanimated layout-animation override // warning). {backgroundElement} {children} {isSubMenuOpen && ( { if (openSubMenuId !== null) { closeSubMenu(openSubMenuId); } }} /> )} )} {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 { isSubMenuOpen } = useMenu(); const subMenuContext = useSubMenu(); const isInsideSubMenu = subMenuContext !== undefined; const isOutsideSubMenuOnOpen = isSubMenuOpen && !isInsideSubMenu; const { rItemStyle, isPressed, animationOnPressIn, animationOnPressOut } = useMenuItemAnimation({ animation, variant, isInsideSubMenu }); const itemClassName = menuClassNames.item({ isDisabled, isOutsideSubMenuOnOpen, className, }); 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; MenuContentBackground.displayName = DISPLAY_NAME.CONTENT_BACKGROUND; 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.ContentBackground - Absolute-fill background container behind * the menu 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 Menu.Content. * * @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, ContentBackground: MenuContentBackground, Close: MenuClose, Group: MenuGroup, Label: MenuLabel, Item: MenuItemComponent, ItemTitle: MenuItemTitle, ItemDescription: MenuItemDescription, ItemIndicator: MenuItemIndicator, }); export { useMenu, useMenuAnimation, useMenuItem }; export default Menu;