From 429d66c13666c4e243065bbd02bee48e1eea2307 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 12:55:12 -0700 Subject: [PATCH] refactor(mobile): split native menus by platform (#12382) --- apps/mobile/src/components/ControlPill.tsx | 172 +----------------- .../components/ControlPillMenu.android.tsx | 43 +++++ .../mobile/src/components/ControlPillMenu.tsx | 115 ++++++++++++ .../src/components/ControlPillMenu.types.ts | 12 ++ 4 files changed, 173 insertions(+), 169 deletions(-) create mode 100644 apps/mobile/src/components/ControlPillMenu.android.tsx create mode 100644 apps/mobile/src/components/ControlPillMenu.tsx create mode 100644 apps/mobile/src/components/ControlPillMenu.types.ts diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index ba75beeeb554b2ad1b35d98c8a3daba412d61aa2..1a1a84c31bc8e3dd0b0ed2f1b4df9b880b9a3d58 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -1,59 +1,12 @@ -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 { type ComponentProps, type ReactNode, useRef } from "react"; +import { Platform, Pressable, View } from "react-native"; 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"; import { MaterialIconButton } from "./MaterialIconButton"; import { MaterialButton } from "./MaterialButton"; -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 { ControlPillMenu } from "./ControlPillMenu"; export function ControlPill(props: { readonly icon?: ComponentProps["name"]; @@ -187,122 +140,3 @@ export function ControlPill(props: { ); } - -// iOS renders UIMenu; AndroidAnchoredMenu adapts the same actions to the -// selected Android appearance and keeps editor menus above the keyboard. -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} - - ); -} diff --git a/apps/mobile/src/components/ControlPillMenu.android.tsx b/apps/mobile/src/components/ControlPillMenu.android.tsx new file mode 100644 index 0000000000000000000000000000000000000000..984086eb8b131028dcc101121339231e7e2111f8 --- /dev/null +++ b/apps/mobile/src/components/ControlPillMenu.android.tsx @@ -0,0 +1,43 @@ +import * as Haptics from "expo-haptics"; +import { cloneElement, isValidElement, type ReactElement } from "react"; +import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; +import type { ControlPillMenuProps } from "./ControlPillMenu.types"; + +/** Uses the same actions as iOS menus, with Android appearance and editor anchoring. */ +export function ControlPillMenu(props: ControlPillMenuProps) { + // 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} + + ); +} diff --git a/apps/mobile/src/components/ControlPillMenu.tsx b/apps/mobile/src/components/ControlPillMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b2ddb54e454481cd26e5c037e1a041815489d590 --- /dev/null +++ b/apps/mobile/src/components/ControlPillMenu.tsx @@ -0,0 +1,115 @@ +import { MenuView } from "@react-native-menu/menu"; +import { + cloneElement, + isValidElement, + useMemo, + useRef, + type ComponentProps, + type ReactElement, +} from "react"; +import type { ColorValue, PressableProps } from "react-native"; +import { withUniwind } from "uniwind"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; +import { withMenuActionIconColors } from "../lib/menu-action-colors"; +import type { ControlPillMenuProps } from "./ControlPillMenu.types"; + +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 ControlPillMenu(props: ControlPillMenuProps) { + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; + const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false }); + const pendingPress = useRef<(() => void) | null>(null); + + 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} + + ); +} diff --git a/apps/mobile/src/components/ControlPillMenu.types.ts b/apps/mobile/src/components/ControlPillMenu.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb8d58b6669c6d57c32d9d401db98bb27ee39642 --- /dev/null +++ b/apps/mobile/src/components/ControlPillMenu.types.ts @@ -0,0 +1,12 @@ +import type { MenuView } from "@react-native-menu/menu"; +import type { ComponentProps, ReactNode } from "react"; +import type { AccessibilityProps } from "react-native"; + +export type ControlPillMenuProps = Omit< + ComponentProps, + "children" | "themeVariant" +> & + Pick & { + readonly children: ReactNode; + readonly className?: string; + }; -- 2.54.0