import { MenuView } from "@react-native-menu/menu"; import * as Haptics from "expo-haptics"; import { cloneElement, isValidElement, type ComponentProps, type ReactElement, type ReactNode, useMemo, useRef, } from "react"; import { Platform, Pressable, View, type ColorValue, type PressableProps, type AccessibilityProps, } from "react-native"; import { withUniwind } from "uniwind"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { cn } from "../lib/cn"; import { withMenuActionIconColors } from "../lib/menu-action-colors"; import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; import { SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; const ThemedMenuView = withUniwind( function NativeMenuView({ iconColor, destructiveIconColor, ...props }: ComponentProps & { readonly iconColor?: ColorValue; readonly destructiveIconColor?: ColorValue; }) { const actions = useMemo( () => withMenuActionIconColors(props.actions, { icon: iconColor, destructiveIcon: destructiveIconColor, }), [props.actions, iconColor, destructiveIconColor], ); return ; }, { iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" }, destructiveIconColor: { fromClassName: "destructiveIconColorClassName", styleProperty: "accentColor", }, }, ); export function ControlPill(props: { readonly icon?: ComponentProps["name"]; readonly iconNode?: ReactNode; readonly label?: string; readonly accessibilityLabel?: string; readonly onPress?: () => void; readonly activateOnPressIn?: boolean; readonly variant?: "circle" | "pill" | "primary" | "danger"; readonly disabled?: boolean; readonly className?: string; }) { const variant = props.variant ?? "circle"; const activatedOnPressInRef = useRef(false); const handlePressIn = () => { activatedOnPressInRef.current = true; props.onPress?.(); }; const handlePressOut = () => { // Pressability invokes onPressOut immediately before onPress on release. // Defer the reset so onPress can identify the same physical gesture. setTimeout(() => { activatedOnPressInRef.current = false; }, 0); }; const handlePress = () => { if (activatedOnPressInRef.current) { return; } props.onPress?.(); }; const iconTintClassName = variant === "primary" ? props.disabled ? "accent-icon-subtle" : "accent-primary-foreground" : variant === "danger" ? "accent-danger-foreground" : "accent-icon"; const isCircle = variant === "circle" || variant === "danger" || (variant === "primary" && !props.label); const containerClassName = cn( isCircle ? "h-11 w-11 items-center justify-center rounded-full" : variant === "primary" ? "h-11 flex-row items-center justify-center gap-2 rounded-full px-5" : "h-11 flex-row items-center justify-center gap-2 rounded-full px-3.5", variant === "primary" ? props.disabled ? "bg-subtle-strong" : "bg-primary" : variant === "danger" ? "bg-danger" : "bg-subtle", props.className, ); const labelClassName = cn( "text-center text-xs font-t3-bold", variant === "primary" ? props.disabled ? "text-foreground-muted" : "text-primary-foreground" : "", ); return ( {props.iconNode ? ( {props.iconNode} ) : props.icon ? ( ) : null} {props.label ? {props.label} : null} ); } // iOS renders the native UIMenu (standard checkmark for `state: "on"`); // Android renders the token-styled AndroidAnchoredMenu, since the native // AppCompat popup can't be themed past its stock animation, metrics, and // submenu chrome. export function ControlPillMenu( props: Omit, "children" | "themeVariant"> & Pick & { readonly children: ReactNode; readonly className?: string; }, ) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false }); const pendingPress = useRef<(() => void) | null>(null); if (Platform.OS === "android") { // Long-press menus keep their child interactive: the child element gets // an injected onLongPress (mirroring the iOS context-menu interaction) // so its own tap handling still works. if (props.shouldOpenOnLongPress && isValidElement(props.children)) { const child = props.children as ReactElement<{ onLongPress?: () => void }>; return ( {(open) => cloneElement(child, { onLongPress: () => { void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); open(); }, }) } ); } return ( {props.children} ); } const { className: _className, ...menuProps } = props; let children = menuProps.children; if (props.shouldOpenOnLongPress && isValidElement(children)) { const child = children as ReactElement>; children = cloneElement(child, { onTouchStart: (event) => { // Reset for a new touch, not onPressIn, which also fires when a // finger moves out of the row and back during the same gesture. menuPress.current.isPreparing = false; menuPress.current.suppressPress = menuPress.current.isOpen; pendingPress.current = null; child.props.onTouchStart?.(event); }, onPress: (event) => { // Accessibility clicks have no touch identifier and must not inherit // cancellation from a previous physical gesture. const isTouch = typeof event.nativeEvent.identifier === "number"; if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) { return; } if (isTouch && menuPress.current.isPreparing) { // A release can arrive between native menu preparation and display. // Let UIKit's display/cancel callback decide this press's outcome. event.persist(); pendingPress.current = () => child.props.onPress?.(event); return; } child.props.onPress?.(event); }, }); menuProps.onMenuInteractionStart = () => { menuPress.current.isPreparing = true; props.onMenuInteractionStart?.(); }; menuProps.onOpenMenu = () => { menuPress.current.isPreparing = false; menuPress.current.isOpen = true; menuPress.current.suppressPress = true; pendingPress.current = null; props.onOpenMenu?.(); }; menuProps.onCloseMenu = () => { menuPress.current.isPreparing = false; menuPress.current.isOpen = false; // Keep this gesture cancelled even if dismissal precedes finger-up. // A separate JS long-press timer would also swallow holds that never // open the native menu. const press = pendingPress.current; pendingPress.current = null; props.onCloseMenu?.(); if (!menuPress.current.suppressPress) { press?.(); } }; } return ( {children} ); }