authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-18 12:55:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 12:55:12-07:00
log429d66c13666c4e243065bbd02bee48e1eea2307
treecc4342b8ac822c0de5fba5ed797c06299baaa600
parent7e7cd32463b528838cb10ef6b89ef6ceac1f7a4c
signature Signed by PGP key B5690EEEBB952194

refactor(mobile): split native menus by platform (#12382)


4 files changed, 173 insertions(+), 169 deletions(-)

apps/mobile/src/components/ControlPill.tsx+3-169
...@@ -1,59 +1,12 @@...@@ -1,59 +1,12 @@
1import { MenuView } from "@react-native-menu/menu";1import { type ComponentProps, type ReactNode, useRef } from "react";
2import * as Haptics from "expo-haptics";2import { Platform, Pressable, View } from "react-native";
3import {
4 cloneElement,
5 isValidElement,
6 type ComponentProps,
7 type ReactElement,
8 type ReactNode,
9 useMemo,
10 useRef,
11} from "react";
12import {
13 Platform,
14 Pressable,
15 View,
16 type ColorValue,
17 type PressableProps,
18 type AccessibilityProps,
19} from "react-native";
20import { withUniwind } from "uniwind";
21import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
22import { cn } from "../lib/cn";3import { cn } from "../lib/cn";
23import { withMenuActionIconColors } from "../lib/menu-action-colors";
24import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
25import { SymbolView } from "./AppSymbol";4import { SymbolView } from "./AppSymbol";
26import { AppText as Text } from "./AppText";5import { AppText as Text } from "./AppText";
27import { MaterialIconButton } from "./MaterialIconButton";6import { MaterialIconButton } from "./MaterialIconButton";
28import { MaterialButton } from "./MaterialButton";7import { MaterialButton } from "./MaterialButton";
298
30const ThemedMenuView = withUniwind(9export { ControlPillMenu } from "./ControlPillMenu";
31 function NativeMenuView({
32 iconColor,
33 destructiveIconColor,
34 ...props
35 }: ComponentProps<typeof MenuView> & {
36 readonly iconColor?: ColorValue;
37 readonly destructiveIconColor?: ColorValue;
38 }) {
39 const actions = useMemo(
40 () =>
41 withMenuActionIconColors(props.actions, {
42 icon: iconColor,
43 destructiveIcon: destructiveIconColor,
44 }),
45 [props.actions, iconColor, destructiveIconColor],
46 );
47 return <MenuView {...props} actions={actions} />;
48 },
49 {
50 iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" },
51 destructiveIconColor: {
52 fromClassName: "destructiveIconColorClassName",
53 styleProperty: "accentColor",
54 },
55 },
56);
5710
58export function ControlPill(props: {11export function ControlPill(props: {
59 readonly icon?: ComponentProps<typeof SymbolView>["name"];12 readonly icon?: ComponentProps<typeof SymbolView>["name"];
...@@ -187,122 +140,3 @@ export function ControlPill(props: {...@@ -187,122 +140,3 @@ export function ControlPill(props: {
187 </Pressable>140 </Pressable>
188 );141 );
189}142}
190
191// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the
192// selected Android appearance and keeps editor menus above the keyboard.
193export function ControlPillMenu(
194 props: Omit<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &
195 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
196 readonly children: ReactNode;
197 readonly className?: string;
198 },
199) {
200 const { themeAppearance } = useAppearancePreferences();
201 const isDarkMode = themeAppearance === "dark";
202 const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false });
203 const pendingPress = useRef<(() => void) | null>(null);
204
205 if (Platform.OS === "android") {
206 // Long-press menus keep their child interactive: the child element gets
207 // an injected onLongPress (mirroring the iOS context-menu interaction)
208 // so its own tap handling still works.
209 if (props.shouldOpenOnLongPress && isValidElement(props.children)) {
210 const child = props.children as ReactElement<{ onLongPress?: () => void }>;
211 return (
212 <AndroidAnchoredMenu
213 actions={props.actions}
214 className={props.className}
215 title={props.title}
216 style={props.style}
217 onPressAction={props.onPressAction}
218 >
219 {(open) =>
220 cloneElement(child, {
221 onLongPress: () => {
222 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
223 open();
224 },
225 })
226 }
227 </AndroidAnchoredMenu>
228 );
229 }
230 return (
231 <AndroidAnchoredMenu
232 actions={props.actions}
233 className={props.className}
234 title={props.title}
235 style={props.style}
236 onPressAction={props.onPressAction}
237 >
238 {props.children}
239 </AndroidAnchoredMenu>
240 );
241 }
242
243 const { className: _className, ...menuProps } = props;
244 let children = menuProps.children;
245 if (props.shouldOpenOnLongPress && isValidElement(children)) {
246 const child = children as ReactElement<Pick<PressableProps, "onTouchStart" | "onPress">>;
247 children = cloneElement(child, {
248 onTouchStart: (event) => {
249 // Reset for a new touch, not onPressIn, which also fires when a
250 // finger moves out of the row and back during the same gesture.
251 menuPress.current.isPreparing = false;
252 menuPress.current.suppressPress = menuPress.current.isOpen;
253 pendingPress.current = null;
254 child.props.onTouchStart?.(event);
255 },
256 onPress: (event) => {
257 // Accessibility clicks have no touch identifier and must not inherit
258 // cancellation from a previous physical gesture.
259 const isTouch = typeof event.nativeEvent.identifier === "number";
260 if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) {
261 return;
262 }
263 if (isTouch && menuPress.current.isPreparing) {
264 // A release can arrive between native menu preparation and display.
265 // Let UIKit's display/cancel callback decide this press's outcome.
266 event.persist();
267 pendingPress.current = () => child.props.onPress?.(event);
268 return;
269 }
270 child.props.onPress?.(event);
271 },
272 });
273 menuProps.onMenuInteractionStart = () => {
274 menuPress.current.isPreparing = true;
275 props.onMenuInteractionStart?.();
276 };
277 menuProps.onOpenMenu = () => {
278 menuPress.current.isPreparing = false;
279 menuPress.current.isOpen = true;
280 menuPress.current.suppressPress = true;
281 pendingPress.current = null;
282 props.onOpenMenu?.();
283 };
284 menuProps.onCloseMenu = () => {
285 menuPress.current.isPreparing = false;
286 menuPress.current.isOpen = false;
287 // Keep this gesture cancelled even if dismissal precedes finger-up.
288 // A separate JS long-press timer would also swallow holds that never
289 // open the native menu.
290 const press = pendingPress.current;
291 pendingPress.current = null;
292 props.onCloseMenu?.();
293 if (!menuPress.current.suppressPress) {
294 press?.();
295 }
296 };
297 }
298 return (
299 <ThemedMenuView
300 {...menuProps}
301 iconColorClassName="accent-icon"
302 destructiveIconColorClassName="accent-danger-foreground"
303 themeVariant={isDarkMode ? "dark" : "light"}
304 >
305 {children}
306 </ThemedMenuView>
307 );
308}
apps/mobile/src/components/ControlPillMenu.android.tsx created+43
...@@ -0,0 +1,43 @@
1import * as Haptics from "expo-haptics";
2import { cloneElement, isValidElement, type ReactElement } from "react";
3import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu";
4import type { ControlPillMenuProps } from "./ControlPillMenu.types";
5
6/** Uses the same actions as iOS menus, with Android appearance and editor anchoring. */
7export function ControlPillMenu(props: ControlPillMenuProps) {
8 // Long-press menus keep their child interactive: the child element gets
9 // an injected onLongPress (mirroring the iOS context-menu interaction)
10 // so its own tap handling still works.
11 if (props.shouldOpenOnLongPress && isValidElement(props.children)) {
12 const child = props.children as ReactElement<{ onLongPress?: () => void }>;
13 return (
14 <AndroidAnchoredMenu
15 actions={props.actions}
16 className={props.className}
17 title={props.title}
18 style={props.style}
19 onPressAction={props.onPressAction}
20 >
21 {(open) =>
22 cloneElement(child, {
23 onLongPress: () => {
24 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
25 open();
26 },
27 })
28 }
29 </AndroidAnchoredMenu>
30 );
31 }
32 return (
33 <AndroidAnchoredMenu
34 actions={props.actions}
35 className={props.className}
36 title={props.title}
37 style={props.style}
38 onPressAction={props.onPressAction}
39 >
40 {props.children}
41 </AndroidAnchoredMenu>
42 );
43}
apps/mobile/src/components/ControlPillMenu.tsx created+115
...@@ -0,0 +1,115 @@
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}
apps/mobile/src/components/ControlPillMenu.types.ts created+12
...@@ -0,0 +1,12 @@
1import type { MenuView } from "@react-native-menu/menu";
2import type { ComponentProps, ReactNode } from "react";
3import type { AccessibilityProps } from "react-native";
4
5export type ControlPillMenuProps = Omit<
6 ComponentProps<typeof MenuView>,
7 "children" | "themeVariant"
8> &
9 Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
10 readonly children: ReactNode;
11 readonly className?: string;
12 };