/** * createNativeMenu - native menu implementation for React Native * * Web: returns empty stub components (withNativeMenu uses the web components instead) * Native: lazily resolves Zeego at render time so importing the package doesn't warn/error */ import { getZeego, NativeMenuContext, unstable_claimExternalPressOwnership, unstable_releaseExternalPressOwnership, } from '@tamagui/native' import { isWeb, withStaticProperties, isIos } from '@tamagui/web' import type { FC } from 'react' import React from 'react' import type { ContextMenuPreviewProps, NativeContextMenuAuxiliaryProps, NativeMenuArrowProps, NativeMenuCheckboxItemProps, NativeMenuContentProps, NativeMenuGroupProps, NativeMenuItemIconProps, NativeMenuItemImageProps, NativeMenuItemIndicatorProps, NativeMenuItemProps, NativeMenuItemSubtitleProps, NativeMenuItemTitleProps, NativeMenuLabelProps, NativeMenuProps, NativeMenuSeparatorProps, NativeMenuSubContentProps, NativeMenuSubProps, NativeMenuSubTriggerProps, MenuTriggerProps, } from './createNativeMenuTypes' // zeego module shape (DropdownMenu / ContextMenu both share this) type ZeegoMenuModule = { Root: FC> Trigger: FC Content: FC Item: FC ItemTitle: FC ItemSubtitle: FC ItemIcon: FC ItemImage: FC ItemIndicator: FC Group: FC Label: FC Separator: FC Sub: FC SubTrigger: FC SubContent: FC CheckboxItem: FC Preview: FC Auxiliary: FC } // component types we recognize via displayName matching type MappedComponentType = | 'SubContent' | 'SubTrigger' | 'Content' | 'Sub' | 'Group' | 'CheckboxItem' const MAPPED_TYPES: MappedComponentType[] = [ 'SubContent', 'SubTrigger', 'Content', 'Sub', 'Group', 'CheckboxItem', ] // types whose children get recursively transformed const CONTAINER_TYPES: MappedComponentType[] = ['SubContent', 'Content', 'Sub', 'Group'] type ComponentMap = Pick< ZeegoMenuModule, 'SubContent' | 'Content' | 'Sub' | 'Group' | 'SubTrigger' > type TriggerPressBoundaryHandlers = { claim(debugName?: string | null): void release(debugName?: string | null): void } export type NativeMenuComponents = { Menu: FC & { Trigger: FC Content: FC Item: FC ItemTitle: FC ItemSubtitle: FC SubTrigger: FC Group: FC ItemIcon: FC Separator: FC CheckboxItem: FC ItemIndicator: FC ItemImage: FC Label: FC Arrow: FC Sub: FC SubContent: FC Preview: FC Portal: FC<{ children: React.ReactNode }> RadioGroup: FC<{ children: React.ReactNode }> RadioItem: FC<{ children: React.ReactNode }> Auxiliary: FC } } // shared helpers (stateless, no need to recreate per call) function getComponentType(displayName: string): MappedComponentType | null { for (const type of MAPPED_TYPES) { if (displayName === type || displayName.includes(`(${type})`)) { return type } } return null } function isItemLike(props: Record, displayName: string): boolean { if (getComponentType(displayName)) return false return 'onSelect' in props || 'textValue' in props } function isPortalLike(displayName: string): boolean { return displayName === 'Portal' || displayName.includes('Portal') } function isTriggerLike(displayName: string): boolean { return displayName === 'Trigger' || displayName.includes('(Trigger)') } function composeHandlers void>(first?: T, second?: T) { return (...args: Parameters) => { first?.(...args) second?.(...args) } } function getTriggerDebugName( menuType: 'ContextMenu' | 'Menu', props: Record ) { const childProps = React.isValidElement(props.children) && props.children.props ? (props.children.props as Record) : null const prefix = menuType === 'ContextMenu' ? 'ContextMenuTrigger' : 'MenuTrigger' const detail = childProps?.testID ?? childProps?.accessibilityLabel ?? (typeof props.textValue === 'string' ? props.textValue : null) return [prefix, detail].filter(Boolean).join(':') || prefix } // stub used for web — never actually rendered, just needs to exist for withNativeMenu fallback const emptyStub = (() => null) as FC function createWebStubs(): NativeMenuComponents { return { Menu: withStaticProperties(emptyStub as FC, { Trigger: emptyStub as FC, Content: emptyStub as FC, Item: emptyStub as FC, ItemTitle: emptyStub as FC, ItemSubtitle: emptyStub as FC, SubTrigger: emptyStub as FC, Group: emptyStub as FC, ItemIcon: emptyStub as FC, Separator: emptyStub as FC, CheckboxItem: emptyStub as FC, ItemIndicator: emptyStub as FC, ItemImage: emptyStub as FC, Label: emptyStub as FC, Arrow: emptyStub as FC, Sub: emptyStub as FC, SubContent: emptyStub as FC, Preview: emptyStub as FC, Portal: emptyStub as FC<{ children: React.ReactNode }>, RadioGroup: emptyStub as FC<{ children: React.ReactNode }>, RadioItem: emptyStub as FC<{ children: React.ReactNode }>, Auxiliary: emptyStub as FC, }), } } export const createNativeMenu = ( MenuType: 'ContextMenu' | 'Menu' ): NativeMenuComponents => { if (isWeb) { return createWebStubs() } // =========================================== // native implementation — lazily resolves zeego // =========================================== const isContextMenu = MenuType === 'ContextMenu' const isAndroid = !isIos && !isWeb // cached after first successful resolve let resolved: { menu: ZeegoMenuModule; componentMap: ComponentMap } | null = null let warned = false function resolve(): typeof resolved { if (resolved) return resolved const zeego = getZeego() if (!zeego.isEnabled) { if (!warned) { warned = true console.warn( `Warning: Must call import '@tamagui/native/setup-zeego' at your app entry point to use native menus` ) } return null } const menu = ( isContextMenu ? zeego.state.ContextMenu : zeego.state.DropdownMenu ) as ZeegoMenuModule resolved = { menu, componentMap: { SubContent: menu.SubContent, Content: menu.Content, Sub: menu.Sub, Group: menu.Group, SubTrigger: menu.SubTrigger, }, } return resolved } type RadioContext = { value?: string onValueChange?: (value: string) => void } // transform children tree for zeego compatibility function transformChildren( menu: ZeegoMenuModule, map: ComponentMap, children: React.ReactNode, shouldReverseOnIos = false, triggerBoundaryHandlers?: TriggerPressBoundaryHandlers, radioContext?: RadioContext ): React.ReactNode { const result: React.ReactNode[] = [] React.Children.forEach(children, (child) => { if (!React.isValidElement(child)) { result.push(child) return } const displayName = (child.type as { displayName?: string })?.displayName || '' const props = child.props as Record // flatten portal wrappers if (isPortalLike(displayName)) { const inner = transformChildren( menu, map, props.children as React.ReactNode, false, triggerBoundaryHandlers, radioContext ) React.Children.forEach(inner, (c) => result.push(c)) return } // flatten ScrollView (native passthrough — children need to be visible to zeego) if (displayName.includes('ScrollView')) { const inner = transformChildren( menu, map, props.children as React.ReactNode, false, triggerBoundaryHandlers, radioContext ) React.Children.forEach(inner, (c) => result.push(c)) return } if (isTriggerLike(displayName)) { const debugName = getTriggerDebugName(MenuType, props) const claim = () => triggerBoundaryHandlers?.claim(debugName) const release = () => triggerBoundaryHandlers?.release(debugName) result.push( React.cloneElement(child, { onTouchStart: composeHandlers(claim, props.onTouchStart), onTouchEnd: composeHandlers(props.onTouchEnd, release), onTouchCancel: composeHandlers(props.onTouchCancel, release), onResponderGrant: composeHandlers(claim, props.onResponderGrant), onResponderRelease: composeHandlers(props.onResponderRelease, release), onResponderTerminate: composeHandlers(props.onResponderTerminate, release), onPressIn: composeHandlers(claim, props.onPressIn), onPressOut: composeHandlers(props.onPressOut, release), } as any) ) return } // RadioGroup: render as a zeego Group and pipe value/onValueChange // down to any RadioItem descendants via radioContext if (displayName.includes('RadioGroup')) { const { value: rgValue, onValueChange: rgOnValueChange, children: rgChildren, ...rest } = props as Record result.push( React.createElement( menu.Group, { ...rest, key: child.key } as any, transformChildren( menu, map, rgChildren as React.ReactNode, false, triggerBoundaryHandlers, { value: rgValue, onValueChange: rgOnValueChange } ) ) ) return } // RadioItem: zeego has no radio primitive, so emit a CheckboxItem whose // 'on'/'off' state is derived from the enclosing RadioGroup's value. if (displayName.includes('RadioItem') && radioContext) { const { value: itemValue, children: rChildren, ...rest } = props as Record const cleanChildren = React.Children.map(rChildren, (c) => { if (!React.isValidElement(c)) return c const dn = (c.type as { displayName?: string })?.displayName || '' if (dn.includes('ItemIndicator')) return null return c }) result.push( React.createElement( menu.CheckboxItem, { ...rest, key: child.key, value: itemValue === radioContext.value ? 'on' : 'off', onValueChange: () => radioContext.onValueChange?.(itemValue), } as any, cleanChildren ) ) return } const componentType = getComponentType(displayName) // normalize checkbox checked/value props if (componentType === 'CheckboxItem') { const { checked, onCheckedChange, value, onValueChange, children: cbChildren, ...rest } = props as Record const finalValue = value ?? (checked ? 'on' : 'off') const finalOnValueChange = onValueChange ?? (onCheckedChange && ((v: string) => onCheckedChange(v === 'on'))) const cleanChildren = React.Children.map(cbChildren, (c) => { if (!React.isValidElement(c)) return c const dn = (c.type as { displayName?: string })?.displayName || '' if (dn.includes('ItemIndicator')) return null return c }) result.push( React.createElement( menu.CheckboxItem, { ...rest, key: child.key, value: finalValue, onValueChange: finalOnValueChange, } as any, cleanChildren ) ) return } if (componentType) { const { children: childChildren, ...restProps } = props const isContainer = (CONTAINER_TYPES as string[]).includes(componentType) const shouldReverse = componentType === 'Content' || componentType === 'SubContent' result.push( React.createElement( map[componentType as keyof ComponentMap], { ...restProps, key: child.key } as any, isContainer ? transformChildren( menu, map, childChildren as React.ReactNode, shouldReverse, triggerBoundaryHandlers, radioContext ) : childChildren ) ) return } // convert Item-like components to zeego Items if (isItemLike(props, displayName)) { const { children: itemChildren, ...itemProps } = props result.push( React.createElement( menu.Item, { ...itemProps, key: child.key } as any, itemChildren ) ) return } result.push(child) }) // iOS DropdownMenu displays items in reverse order if (isIos && shouldReverseOnIos && !isContextMenu) { result.reverse() } return result } // lazy wrapper — resolves the zeego component on first render function lazyZeego

>( name: keyof ZeegoMenuModule, displayName?: string ): FC

{ const Comp: FC

= (props) => { const z = resolve() if (!z) return null return React.createElement(z.menu[name] as FC, props) } Comp.displayName = displayName || name return Comp } const Trigger = lazyZeego('Trigger') const Content = lazyZeego('Content') const Item = lazyZeego('Item') const ItemTitle = lazyZeego('ItemTitle') const ItemSubtitle = lazyZeego('ItemSubtitle') const ItemIcon = lazyZeego('ItemIcon') const ItemImage = lazyZeego('ItemImage') const ItemIndicator = lazyZeego('ItemIndicator') const Group = lazyZeego('Group') const Label = lazyZeego('Label') const Separator = lazyZeego('Separator') const Sub = lazyZeego('Sub') const SubTrigger = lazyZeego('SubTrigger') const SubContent = lazyZeego('SubContent') const Portal: FC<{ children: React.ReactNode }> = ({ children }) => <>{children} Portal.displayName = 'Portal' const Arrow: FC = () => null Arrow.displayName = 'Arrow' const RadioGroup: FC<{ children: React.ReactNode }> = ({ children }) => <>{children} RadioGroup.displayName = `${MenuType}RadioGroup` const RadioItem: FC<{ children: React.ReactNode }> = ({ children }) => <>{children} RadioItem.displayName = `${MenuType}RadioItem` const CheckboxItem: FC = () => null CheckboxItem.displayName = 'CheckboxItem' const Preview: FC = isContextMenu ? lazyZeego('Preview', `${MenuType}Preview`) : () => null Preview.displayName = `${MenuType}Preview` const Auxiliary: FC = isContextMenu ? lazyZeego('Auxiliary', `${MenuType}Auxiliary`) : () => null Auxiliary.displayName = `${MenuType}Auxiliary` // on Android, provide NativeMenuContext so components use Gesture.Manual() // instead of Gesture.Tap() (which sends ACTION_CANCEL to MenuView) const Menu: FC = ({ children, onOpenChange, onOpenWillChange }) => { const triggerOwnerRef = React.useRef(null) const claimTriggerBoundary = React.useCallback((debugName?: string | null) => { if (triggerOwnerRef.current) { unstable_releaseExternalPressOwnership(triggerOwnerRef.current, debugName) } triggerOwnerRef.current = unstable_claimExternalPressOwnership(debugName) }, []) const releaseTriggerBoundary = React.useCallback((debugName?: string | null) => { if (!triggerOwnerRef.current) return unstable_releaseExternalPressOwnership(triggerOwnerRef.current, debugName) triggerOwnerRef.current = null }, []) React.useEffect(() => releaseTriggerBoundary, [releaseTriggerBoundary]) const z = resolve() if (!z) return null const handleOpenChange = React.useCallback( (isOpen: boolean) => { if (!isOpen) { releaseTriggerBoundary() } onOpenChange?.(isOpen) }, [onOpenChange, releaseTriggerBoundary] ) const handleOpenWillChange = React.useCallback( (willOpen: boolean) => { if (!willOpen) { releaseTriggerBoundary() } onOpenWillChange?.(willOpen) }, [onOpenWillChange, releaseTriggerBoundary] ) const rootProps: Record = { onOpenChange: handleOpenChange } if (isContextMenu && onOpenWillChange) { rootProps.onOpenWillChange = handleOpenWillChange } const content = ( {transformChildren(z.menu, z.componentMap, children, false, { claim: claimTriggerBoundary, release: releaseTriggerBoundary, })} ) if (isAndroid) { return ( {content} ) } return content } Menu.displayName = MenuType return { Menu: withStaticProperties(Menu, { Trigger, Content, Item, ItemTitle, ItemSubtitle, ItemIcon, ItemImage, ItemIndicator, Group, Label, Separator, Sub, SubTrigger, SubContent, CheckboxItem, Portal, RadioGroup, RadioItem, Arrow, Preview, Auxiliary, }), } }