| author | |
| committer | |
| log | 429d66c13666c4e243065bbd02bee48e1eea2307 |
| tree | cc4342b8ac822c0de5fba5ed797c06299baaa600 |
| parent | 7e7cd32463b528838cb10ef6b89ef6ceac1f7a4c |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 173 insertions(+), 169 deletions(-)
apps/mobile/src/components/ControlPill.tsx+3-169| ... | ... | @@ -1,59 +1,12 @@ |
| 1 | import { MenuView } from "@react-native-menu/menu"; | |
| 2 | import * as Haptics from "expo-haptics"; | |
| 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"; | |
| 1 | import { type ComponentProps, type ReactNode, useRef } from "react"; | |
| 2 | import { Platform, Pressable, View } from "react-native"; | |
| 22 | 3 | import { cn } from "../lib/cn"; |
| 23 | import { withMenuActionIconColors } from "../lib/menu-action-colors"; | |
| 24 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; | |
| 25 | 4 | import { SymbolView } from "./AppSymbol"; |
| 26 | 5 | import { AppText as Text } from "./AppText"; |
| 27 | 6 | import { MaterialIconButton } from "./MaterialIconButton"; |
| 28 | 7 | import { MaterialButton } from "./MaterialButton"; |
| 29 | 8 | |
| 30 | const ThemedMenuView = withUniwind( | |
| 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 | ); | |
| 9 | export { ControlPillMenu } from "./ControlPillMenu"; | |
| 57 | 10 | |
| 58 | 11 | export function ControlPill(props: { |
| 59 | 12 | readonly icon?: ComponentProps<typeof SymbolView>["name"]; |
| ... | ... | @@ -187,122 +140,3 @@ export function ControlPill(props: { |
| 187 | 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 | } |
apps/mobile/src/components/ControlPillMenu.android.tsx created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import * as Haptics from "expo-haptics"; | |
| 2 | import { cloneElement, isValidElement, type ReactElement } from "react"; | |
| 3 | import { AndroidAnchoredMenu } from "./AndroidAnchoredMenu"; | |
| 4 | import type { ControlPillMenuProps } from "./ControlPillMenu.types"; | |
| 5 | ||
| 6 | /** Uses the same actions as iOS menus, with Android appearance and editor anchoring. */ | |
| 7 | export 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 @@ |
| 1 | import { MenuView } from "@react-native-menu/menu"; | |
| 2 | import { | |
| 3 | cloneElement, | |
| 4 | isValidElement, | |
| 5 | useMemo, | |
| 6 | useRef, | |
| 7 | type ComponentProps, | |
| 8 | type ReactElement, | |
| 9 | } from "react"; | |
| 10 | import type { ColorValue, PressableProps } from "react-native"; | |
| 11 | import { withUniwind } from "uniwind"; | |
| 12 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | |
| 13 | import { withMenuActionIconColors } from "../lib/menu-action-colors"; | |
| 14 | import type { ControlPillMenuProps } from "./ControlPillMenu.types"; | |
| 15 | ||
| 16 | const 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 | ||
| 44 | export 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 @@ |
| 1 | import type { MenuView } from "@react-native-menu/menu"; | |
| 2 | import type { ComponentProps, ReactNode } from "react"; | |
| 3 | import type { AccessibilityProps } from "react-native"; | |
| 4 | ||
| 5 | export 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 | }; |