import { forwardRef, useCallback, useEffect, useMemo, useState, type FC, type PropsWithChildren, type ReactNode, } from 'react'; import { StyleSheet, type LayoutChangeEvent, type PressableStateCallbackType, type StyleProp, type ViewStyle, } from 'react-native'; import Animated, { useAnimatedStyle, withTiming, } from 'react-native-reanimated'; import { useThemeColor } from '../../helpers/external/hooks'; import { ChevronRightIcon, ThemeBackground, } from '../../helpers/internal/components'; import { AnimationSettingsProvider, useAnimationSettings, } from '../../helpers/internal/contexts'; import type { ViewRef } from '../../helpers/internal/types'; import { useRootContext as useMenu } from '../../primitives/menu'; import * as SubMenuPrimitives from '../../primitives/sub-menu'; import type * as SubMenuPrimitivesTypes from '../../primitives/sub-menu/sub-menu.types'; import { SubMenuAnimationProvider, useRootContentContainerAnimation, useSubMenuAnimation, useSubMenuRootAnimation, useSubMenuTriggerIndicatorAnimation, } from './sub-menu.animation'; import { DEFAULT_INDICATOR_ICON_SIZE, DISPLAY_NAME, } from './sub-menu.constants'; import { subMenuClassNames, subMenuStyleSheet } from './sub-menu.styles'; import type { SubMenuBackgroundProps, SubMenuContentProps, SubMenuRootAnimation, SubMenuRootProps, SubMenuTriggerIndicatorProps, SubMenuTriggerProps, } from './sub-menu.types'; // -------------------------------------------------- const AnimatedContent = Animated.createAnimatedComponent( SubMenuPrimitives.Content ); const useSubMenu = SubMenuPrimitives.useSubMenuContext; // -------------------------------------------------- const SubMenuRoot = forwardRef< SubMenuPrimitivesTypes.RootRef, SubMenuRootProps >( ( { children, isOpen: isOpenProp, isDefaultOpen, animation, background, className, style, ...props }, ref ) => { const { presentation } = useMenu(); if (__DEV__) { if (presentation === 'bottom-sheet') { throw new Error( 'SubMenu cannot be used inside a Menu with presentation="bottom-sheet". Use presentation="popover" instead.' ); } } const animationSettingsContext = useAnimationSettings(); const { isAllAnimationsDisabled, triggerHeight, contentHeight, contentPaddingTop, } = useSubMenuRootAnimation({ animation }); const animationContextValue = useMemo( () => ({ triggerHeight, contentHeight, contentPaddingTop }), [triggerHeight, contentHeight, contentPaddingTop] ); return ( {children} ); } ); // -------------------------------------------------- /** * Absolute-fill background container rendered behind the open sub-menu * surface. It paints the sub-menu surface itself (the `--color-overlay` * coat) and, with no `children`, lets the active library theme decide the * layer above it: `glass` renders a `GlassView` blur; other themes render * nothing. Pass `children` to host arbitrary content (gradients, images) * with the container's positioning and clipping applied. * * The coat lives here rather than on the root so it exists only while the * sub-menu is presented — under themes with a translucent `--color-overlay` * a permanent coat would tint the collapsed trigger row twice and make it * read differently from its sibling menu items. */ const SubMenuBackground = forwardRef( ({ className, ...props }, ref) => { const backgroundClassName = subMenuClassNames.background({ className }); return ( ); } ); // -------------------------------------------------- const RootContentContainer: FC< PropsWithChildren<{ animation?: SubMenuRootAnimation; background?: ReactNode; className?: string; style?: StyleProp; }> > = ({ children, animation, background, className }) => { const [isMounted, setIsMounted] = useState(false); const { isOpen, onOpenChange, nativeID } = useSubMenu(); const { openSubMenuId, openSubMenu, closeSubMenu } = useMenu(); const { rOuterContainerStyle, rInnerContentStyle, backgroundExiting } = useRootContentContainerAnimation({ animation }); const rootClassName = subMenuClassNames.root({ isOpen, className }); useEffect(() => { const timer = setTimeout(() => { setIsMounted(true); }, 0); return () => clearTimeout(timer); }, []); /** Register / unregister this sub-menu with the parent Menu context. */ useEffect(() => { if (isOpen) { openSubMenu(nativeID); } else { closeSubMenu(nativeID); } return () => { closeSubMenu(nativeID); }; }, [isOpen, nativeID, openSubMenu, closeSubMenu]); /** Close this sub-menu when it's no longer the active one (e.g. backdrop press). */ useEffect(() => { if (openSubMenuId !== nativeID && isOpen) { onOpenChange(false); } // Only react to openSubMenuId changes (not isOpen) to avoid // a race condition where isOpen=true fires before registration sets the ID. // eslint-disable-next-line react-hooks/exhaustive-deps }, [openSubMenuId]); /** * Background layer rendered behind the open sub-menu surface. `undefined` * falls back to the theme-aware default: mounted without a transition when * the sub-menu opens, then faded out so the collapsing root dissolves into * the menu surface instead of exposing the items behind it in one frame. * `null` removes the layer; a custom node is rendered as provided. */ const backgroundElement = background === undefined ? isOpen && ( ) : background; return ( {isMounted ? ( {backgroundElement} {children} ) : ( children )} ); }; // -------------------------------------------------- const SubMenuTrigger = forwardRef< SubMenuPrimitivesTypes.TriggerRef, SubMenuTriggerProps >( ( { children, className, style, onLayout: onLayoutProp, isDisabled = false, ...props }, ref ) => { const { triggerHeight } = useSubMenuAnimation(); const { openSubMenuId } = useMenu(); const subMenuContext = useSubMenu(); const isOtherSubMenuOpen = openSubMenuId !== null && openSubMenuId !== subMenuContext.nativeID; const triggerClassName = subMenuClassNames.trigger({ className, isDisabled, isOtherSubMenuOpen, }); const handleLayout = useCallback( (event: LayoutChangeEvent) => { triggerHeight.value = event.nativeEvent.layout.height; onLayoutProp?.(event); }, [triggerHeight, onLayoutProp] ); return ( [subMenuStyleSheet.borderCurve, style(state)] : [subMenuStyleSheet.borderCurve, style] } isDisabled={isDisabled || isOtherSubMenuOpen} onLayout={handleLayout} {...props} > {children} ); } ); // -------------------------------------------------- const SubMenuTriggerIndicator = forwardRef< ViewRef, SubMenuTriggerIndicatorProps >( ( { children, className, iconProps, animation, isAnimatedStyleActive = true, style, ...restProps }, ref ) => { const { isOpen } = useSubMenu(); const themeColorMuted = useThemeColor('muted'); const indicatorClassName = subMenuClassNames.triggerIndicator({ className, }); const { rContainerStyle } = useSubMenuTriggerIndicatorAnimation({ animation, isOpen, }); const indicatorStyle = isAnimatedStyleActive ? [rContainerStyle, style] : style; if (children) { return ( {children} ); } return ( ); } ); // -------------------------------------------------- const SubMenuContent = forwardRef< SubMenuPrimitivesTypes.ContentRef, SubMenuContentProps >(({ children, className, style, onLayout: onLayoutProp, ...props }, ref) => { const { isOpen } = useSubMenu(); const contentClassName = subMenuClassNames.content({ className }); const { triggerHeight, contentHeight, contentPaddingTop } = useSubMenuAnimation(); const rContainerStyle = useAnimatedStyle(() => ({ top: triggerHeight.get() + contentPaddingTop.get(), opacity: withTiming(isOpen ? 1 : 0, { duration: 150 }), })); const handleLayout = useCallback( (event: LayoutChangeEvent) => { contentHeight.value = event.nativeEvent.layout.height; onLayoutProp?.(event); }, [contentHeight, onLayoutProp] ); return ( [ rContainerStyle, style(state), ] : [rContainerStyle, style] } pointerEvents={isOpen ? 'auto' : 'none'} onLayout={handleLayout} forceMount {...props} > {children} ); }); // -------------------------------------------------- SubMenuRoot.displayName = DISPLAY_NAME.ROOT; SubMenuBackground.displayName = DISPLAY_NAME.BACKGROUND; SubMenuTrigger.displayName = DISPLAY_NAME.TRIGGER; SubMenuTriggerIndicator.displayName = DISPLAY_NAME.TRIGGER_INDICATOR; SubMenuContent.displayName = DISPLAY_NAME.CONTENT; // -------------------------------------------------- /** * Compound SubMenu component with sub-components. * * @component SubMenu - Root container that manages open/close state and * provides animation settings context to children. * * @component SubMenu.Background - Absolute-fill background container behind * the open sub-menu surface, painting the surface coat and, with no children, * the layer chosen by the active library theme (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 SubMenu. * * @component SubMenu.Trigger - Pressable item that toggles the submenu, * styled like a menu item. * * @component SubMenu.TriggerIndicator - Animated indicator (default: chevron-right) * that rotates when the submenu opens/closes. Place inside SubMenu.Trigger. * * @component SubMenu.Content - Absolutely positioned content that animates * its height when the submenu opens/closes. */ const SubMenu = Object.assign(SubMenuRoot, { Background: SubMenuBackground, Trigger: SubMenuTrigger, TriggerIndicator: SubMenuTriggerIndicator, Content: SubMenuContent, }); export { useSubMenu, useSubMenuAnimation }; export default SubMenu;