| ... | @@ -1,59 +1,12 @@ | ... | @@ -1,59 +1,12 @@ |
| 1 | import { MenuView } from "@react-native-menu/menu"; | 1 | import { type ComponentProps, type ReactNode, useRef } from "react"; |
| 2 | import * as Haptics from "expo-haptics"; | 2 | import { Platform, Pressable, View } from "react-native"; |
| 3 | import { | | |
| 4 | cloneElement, | | |
| 5 | isValidElement, | | |
| 6 | type ComponentProps, | | |
| 7 | type ReactElement, | | |
| 8 | type ReactNode, | | |
| 9 | useMemo, | | |
| 10 | useRef, | | |
| 11 | } from "react"; | | |
| 12 | import { | | |
| 13 | Platform, | | |
| 14 | Pressable, | | |
| 15 | View, | | |
| 16 | type ColorValue, | | |
| 17 | type PressableProps, | | |
| 18 | type AccessibilityProps, | | |
| 19 | } from "react-native"; | | |
| 20 | import { withUniwind } from "uniwind"; | | |
| 21 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | | |
| 22 | import { cn } from "../lib/cn"; | 3 | import { cn } from "../lib/cn"; |
| 23 | import { withMenuActionIconColors } from "../lib/menu-action-colors"; | | |
| 24 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; | | |
| 25 | import { SymbolView } from "./AppSymbol"; | 4 | import { SymbolView } from "./AppSymbol"; |
| 26 | import { AppText as Text } from "./AppText"; | 5 | import { AppText as Text } from "./AppText"; |
| 27 | import { MaterialIconButton } from "./MaterialIconButton"; | 6 | import { MaterialIconButton } from "./MaterialIconButton"; |
| 28 | import { MaterialButton } from "./MaterialButton"; | 7 | import { MaterialButton } from "./MaterialButton"; |
| 29 | | 8 | |
| 30 | const ThemedMenuView = withUniwind( | 9 | export { 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 | ); | | |
| 57 | | 10 | |
| 58 | export function ControlPill(props: { | 11 | export 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. | | |
| 193 | export 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 | } | | |