1import { MenuView } from "@react-native-menu/menu";
2import {
3 cloneElement,
4 isValidElement,
5 useMemo,
6 useRef,
7 type ComponentProps,
8 type ReactElement,
9} from "react";
10import type { ColorValue, PressableProps } from "react-native";
11import { withUniwind } from "uniwind";
12import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
13import { withMenuActionIconColors } from "../lib/menu-action-colors";
14import type { ControlPillMenuProps } from "./ControlPillMenu.types";
15
16const ThemedMenuView = withUniwind(
17 function NativeMenuView({
18 iconColor,
19 destructiveIconColor,
20 ...props
21 }: ComponentProps<typeof MenuView> & {
22 readonly iconColor?: ColorValue;
23 readonly destructiveIconColor?: ColorValue;
24 }) {
25 const actions = useMemo(
26 () =>
27 withMenuActionIconColors(props.actions, {
28 icon: iconColor,
29 destructiveIcon: destructiveIconColor,
30 }),
31 [props.actions, iconColor, destructiveIconColor],
32 );
33 return <MenuView {...props} actions={actions} />;
34 },
35 {
36 iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" },
37 destructiveIconColor: {
38 fromClassName: "destructiveIconColorClassName",
39 styleProperty: "accentColor",
40 },
41 },
42);
43
44export function ControlPillMenu(props: ControlPillMenuProps) {
45 const { themeAppearance } = useAppearancePreferences();
46 const isDarkMode = themeAppearance === "dark";
47 const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false });
48 const pendingPress = useRef<(() => void) | null>(null);
49
50 const { className: _className, ...menuProps } = props;
51 let children = menuProps.children;
52 if (props.shouldOpenOnLongPress && isValidElement(children)) {
53 const child = children as ReactElement<Pick<PressableProps, "onTouchStart" | "onPress">>;
54 children = cloneElement(child, {
55 onTouchStart: (event) => {
56 // Reset for a new touch, not onPressIn, which also fires when a
57 // finger moves out of the row and back during the same gesture.
58 menuPress.current.isPreparing = false;
59 menuPress.current.suppressPress = menuPress.current.isOpen;
60 pendingPress.current = null;
61 child.props.onTouchStart?.(event);
62 },
63 onPress: (event) => {
64 // Accessibility clicks have no touch identifier and must not inherit
65 // cancellation from a previous physical gesture.
66 const isTouch = typeof event.nativeEvent.identifier === "number";
67 if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) {
68 return;
69 }
70 if (isTouch && menuPress.current.isPreparing) {
71 // A release can arrive between native menu preparation and display.
72 // Let UIKit's display/cancel callback decide this press's outcome.
73 event.persist();
74 pendingPress.current = () => child.props.onPress?.(event);
75 return;
76 }
77 child.props.onPress?.(event);
78 },
79 });
80 menuProps.onMenuInteractionStart = () => {
81 menuPress.current.isPreparing = true;
82 props.onMenuInteractionStart?.();
83 };
84 menuProps.onOpenMenu = () => {
85 menuPress.current.isPreparing = false;
86 menuPress.current.isOpen = true;
87 menuPress.current.suppressPress = true;
88 pendingPress.current = null;
89 props.onOpenMenu?.();
90 };
91 menuProps.onCloseMenu = () => {
92 menuPress.current.isPreparing = false;
93 menuPress.current.isOpen = false;
94 // Keep this gesture cancelled even if dismissal precedes finger-up.
95 // A separate JS long-press timer would also swallow holds that never
96 // open the native menu.
97 const press = pendingPress.current;
98 pendingPress.current = null;
99 props.onCloseMenu?.();
100 if (!menuPress.current.suppressPress) {
101 press?.();
102 }
103 };
104 }
105 return (
106 <ThemedMenuView
107 {...menuProps}
108 iconColorClassName="accent-icon"
109 destructiveIconColorClassName="accent-danger-foreground"
110 themeVariant={isDarkMode ? "dark" : "light"}
111 >
112 {children}
113 </ThemedMenuView>
114 );
115}