Skip to content
Source
Sign inSign up

Menu.tsx

112 lines4.0 KB

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 { MenuProps } from "./Menu.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 Menu(props: MenuProps) {
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 /* oxlint-disable react/refs -- Cloning the trigger installs event callbacks; the refs are accessed when those callbacks run. */
55 children = cloneElement(child, {
56 onTouchStart: (event) => {
57 // onPressIn can repeat within one gesture when a finger leaves and re-enters the row.
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 lack touch identifiers and must ignore previous touch cancellation.
65 const isTouch = typeof event.nativeEvent.identifier === "number";
66 if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) {
67 return;
68 }
69 if (isTouch && menuPress.current.isPreparing) {
70 // A release can precede native display; let UIKit's display/cancel callback decide its outcome.
71 event.persist();
72 pendingPress.current = () => child.props.onPress?.(event);
73 return;
74 }
75 child.props.onPress?.(event);
76 },
77 });
78 /* oxlint-enable react/refs */
79 menuProps.onMenuInteractionStart = () => {
80 menuPress.current.isPreparing = true;
81 props.onMenuInteractionStart?.();
82 };
83 menuProps.onOpenMenu = () => {
84 menuPress.current.isPreparing = false;
85 menuPress.current.isOpen = true;
86 menuPress.current.suppressPress = true;
87 pendingPress.current = null;
88 props.onOpenMenu?.();
89 };
90 menuProps.onCloseMenu = () => {
91 menuPress.current.isPreparing = false;
92 menuPress.current.isOpen = false;
93 // Keep cancellation through finger-up even if the menu dismisses first.
94 const press = pendingPress.current;
95 pendingPress.current = null;
96 props.onCloseMenu?.();
97 if (!menuPress.current.suppressPress) {
98 press?.();
99 }
100 };
101 }
102 return (
103 <ThemedMenuView
104 {...menuProps}
105 iconColorClassName="accent-icon"
106 destructiveIconColorClassName="accent-danger-foreground"
107 themeVariant={isDarkMode ? "dark" : "light"}
108 >
109 {children}
110 </ThemedMenuView>
111 );