import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu"; import { BlurView } from "expo-blur"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import { BackHandler, Pressable, ScrollView, useColorScheme, View } from "react-native"; import { useKeyboardState } from "react-native-keyboard-controller"; import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget"; import { useThemeColor } from "../lib/useThemeColor"; import { cn } from "../lib/cn"; import { type AppSymbolName, SymbolView } from "./AppSymbol"; import { AppText as Text } from "./AppText"; import { OverlayPortal } from "./OverlayPortal"; const MENU_WIDTH = 250; const SCREEN_MARGIN = 12; const ANCHOR_GAP = 6; // Anchor position is snapshotted in window coordinates when the menu opens; // the overlay root measures itself the same way, and the menu is placed from // the delta. Both snapshots are taken at open time so later reflows (keyboard // show/hide, screen transitions) can't flip an opens-up menu to opens-down // mid-presentation. type AnchorSnapshot = { readonly x: number; readonly y: number; readonly width: number; readonly height: number; }; type OverlayFrame = { readonly x: number; readonly y: number; readonly width: number; readonly height: number; }; export type AndroidAnchoredMenuProps = { readonly actions: readonly MenuAction[]; readonly title?: string; readonly onPressAction?: MenuComponentProps["onPressAction"]; /** Applied to the anchor wrapper — call sites flex these to fill toolbars. */ readonly className?: string; readonly style?: StyleProp; /** * Plain children open the menu on tap (the wrapper owns the press). A * render function keeps the children interactive and hands them `open` to * call from their own gesture — e.g. a row that selects on tap and opens * this menu on long-press. */ readonly children: ReactNode | ((open: () => void) => ReactNode); }; /** * Token-styled anchored dropdown for Android, drop-in for the subset of the * MenuView contract the app uses (actions with state/subtitle/image/ * attributes, one level of subactions). The native AppCompat PopupMenu caps * out on theming — stock animation, item metrics, and submenu chrome — so * ControlPillMenu renders this instead on Android while iOS keeps the native * UIMenu. Styling follows the themed native popup (12dp radius, plain rows, * trailing check glyph); submenus drill in under a muted parent-title header. */ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [anchor, setAnchor] = useState(null); const [path, setPath] = useState([]); // Height of the modal's root view, in the modal's own coordinate space. // Menus that flip above their anchor are pinned by their BOTTOM edge // (bottom = rootHeight - anchorTop), so drill-in height changes grow // upward without any re-measurement — positioning them via `top` from the // menu's measured height made every submenu transition settle over two // frames and jitter. const [rootHeight, setRootHeight] = useState(null); // Window frame of the overlay root, measured on layout. Anchor coordinates // are converted into this frame, so the menu lands correctly no matter // where the portal host sits (status bar, keyboard resize, etc.). const [overlay, setOverlay] = useState(null); const anchorRef = useRef(null); const overlayRef = useRef(null); const isDarkMode = useColorScheme() === "dark"; const keyboardVisible = useKeyboardState((state) => state.isVisible); const keyboardHeight = useKeyboardState((state) => state.height); const rippleColor = useThemeColor("--color-subtle"); const iconColor = useThemeColor("--color-icon"); const iconSubtleColor = useThemeColor("--color-icon-subtle"); const dangerColor = useThemeColor("--color-danger-foreground"); const close = useCallback(() => { setAnchor(null); setPath([]); setOverlay(null); setRootHeight(null); }, []); const open = useCallback(() => { anchorRef.current?.measureInWindow((x, y, width, height) => { setAnchor({ x, y, width, height }); }); }, []); const measureOverlay = useCallback(() => { overlayRef.current?.measureInWindow((x, y, width, height) => { setOverlay({ x, y, width, height }); setRootHeight(height); }); }, []); // The dropdown renders in-window (no Modal takes focus), so the hardware // back gesture needs explicit handling while it is open. Back steps out of // a drilled-in submenu one level at a time (mirroring the tappable parent // header) before closing the menu. Under predictive back // (enableOnBackInvokedCallback) this stays correct: back reaches JS // through always-registered OnBackPressedDispatcher callbacks (react-native // core on Android 16+, withAndroidPredictiveBackCompat on 13-15), which // also keeps the system from playing a "leave app" preview while the menu // merely closes. const submenuDepth = path.length; useEffect(() => { if (anchor === null) { return; } const subscription = BackHandler.addEventListener("hardwareBackPress", () => { if (submenuDepth > 0) { setPath((current) => current.slice(0, -1)); } else { close(); } return true; }); return () => subscription.remove(); }, [anchor, close, submenuDepth]); const parent = path.length > 0 ? path[path.length - 1] : null; const levelActions = (parent?.subactions ?? props.actions).filter( (action) => !(action.attributes?.hidden ?? false), ); // Anchor in overlay-local coordinates (both measured in window space). const local = anchor === null || overlay === null ? null : { x: anchor.x - overlay.x, y: anchor.y - overlay.y, width: anchor.width, height: anchor.height, }; const preferredLeft = local === null || overlay === null ? 0 : local.x + local.width / 2 <= overlay.width / 2 ? local.x : local.x + local.width - MENU_WIDTH; const left = overlay === null ? 0 : Math.min( Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - MENU_WIDTH - SCREEN_MARGIN, ); // The keyboard stays up while the menu is open (in-window overlay, no // focus change), so the space it covers is not usable — without this the // composer-pill menus "open down" into the IME and can't be tapped. const usableBottom = overlay === null ? 0 : overlay.height - (keyboardVisible ? keyboardHeight : 0); const spaceBelow = local === null || overlay === null ? 0 : usableBottom - (local.y + local.height) - ANCHOR_GAP - SCREEN_MARGIN; const spaceAbove = local === null ? 0 : local.y - ANCHOR_GAP - SCREEN_MARGIN; const opensDown = spaceBelow >= 280 || spaceBelow >= spaceAbove; const maxHeight = Math.min(opensDown ? spaceBelow : spaceAbove, 480); // The menu needs the overlay frame before it can be placed; it stays // unmounted for that first frame so the fade-in plays at the final position. const placeable = local !== null && rootHeight !== null; const onPressItem = useCallback( (action: MenuAction) => { if ((action.subactions?.length ?? 0) > 0) { setPath((current) => [...current, action]); return; } close(); if (action.id !== undefined) { props.onPressAction?.({ nativeEvent: { event: action.id }, } as Parameters>[0]); } }, [close, props.onPressAction], ); return ( <> {typeof props.children === "function" ? ( {props.children(open)} ) : ( {props.children} )} {anchor === null ? null : ( {!placeable || local === null ? null : ( {/* Frosted backdrop: blur of the app content behind the menu, washed with the translucent card tone so rows keep contrast. */} {/* keyboardShouldPersistTaps: the menu often opens over an active editor; the first item tap must act, not just dismiss the keyboard. */} {parent !== null ? ( // Muted parent title as the submenu header; tapping it // steps back, but it reads as a label, not a button. setPath((current) => current.slice(0, -1))} > {parent.title} ) : props.title ? ( <> {props.title} ) : null} {levelActions.map((action, index) => { const destructive = action.attributes?.destructive ?? false; const disabled = action.attributes?.disabled ?? false; const hasSubmenu = (action.subactions?.length ?? 0) > 0; return ( onPressItem(action)} > {action.title} {action.subtitle ? ( {action.subtitle} ) : null} {hasSubmenu ? ( ) : action.state === "on" ? ( ) : action.image ? ( ) : null} ); })} )} )} ); }