334 lines · 13.5 KB · TypeScript
RawDownload
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, View } from "react-native";import { useKeyboardState } from "react-native-keyboard-controller";import Animated, { FadeIn } from "react-native-reanimated"; import { appBlurTargetRef } from "../lib/appBlurTarget";import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";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<ViewStyle>;  /**   * 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<AnchorSnapshot | null>(null);  const [path, setPath] = useState<readonly MenuAction[]>([]);  // 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<number | null>(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<OverlayFrame | null>(null);  const anchorRef = useRef<View>(null);  const overlayRef = useRef<View>(null);   const { themeAppearance } = useAppearancePreferences();  const isDarkMode = themeAppearance === "dark";  const keyboardVisible = useKeyboardState((state) => state.isVisible);  const keyboardHeight = useKeyboardState((state) => state.height);  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<NonNullable<MenuComponentProps["onPressAction"]>>[0]);      }    },    [close, props.onPressAction],  );   return (    <>      {typeof props.children === "function" ? (        <View ref={anchorRef} collapsable={false} className={props.className} style={props.style}>          {props.children(open)}        </View>      ) : (        <Pressable          ref={anchorRef}          accessibilityRole="button"          className={props.className}          collapsable={false}          style={props.style}          onPress={open}        >          <View pointerEvents="none">{props.children}</View>        </Pressable>      )}      {anchor === null ? null : (        <OverlayPortal>          <View            ref={overlayRef}            collapsable={false}            className="absolute inset-0"            onLayout={measureOverlay}          >            <Pressable accessible={false} className="absolute inset-0" onPress={close} />            {!placeable || local === null ? null : (              <Animated.View                entering={FadeIn.duration(120)}                className="absolute w-[250px] overflow-hidden rounded-[12px] border border-border shadow-2xl"                style={{                  left,                  maxHeight,                  ...(opensDown                    ? { top: local.y + local.height + ANCHOR_GAP }                    : { bottom: (rootHeight ?? 0) - local.y + ANCHOR_GAP }),                }}              >                {/* Frosted backdrop: blur of the app content behind the menu,                  washed with the translucent card tone so rows keep contrast. */}                <BlurView                  blurMethod="dimezisBlurView"                  blurTarget={appBlurTargetRef}                  intensity={40}                  tint={isDarkMode ? "dark" : "light"}                  className="absolute inset-0"                />                <View className="absolute inset-0 bg-card-translucent" />                {/* keyboardShouldPersistTaps: the menu often opens over an                  active editor; the first item tap must act, not just                  dismiss the keyboard. */}                <ScrollView                  bounces={false}                  keyboardShouldPersistTaps="always"                  showsVerticalScrollIndicator={false}                >                  {parent !== null ? (                    // Muted parent title as the submenu header; tapping it                    // steps back, but it reads as a label, not a button.                    <Pressable                      className="px-3.5 pb-1 pt-2.5"                      onPress={() => setPath((current) => current.slice(0, -1))}                    >                      <Text className="text-xs font-t3-bold text-foreground-muted">                        {parent.title}                      </Text>                    </Pressable>                  ) : props.title ? (                    <>                      <View className="px-3.5 py-2">                        <Text className="text-center text-xs text-foreground-muted">                          {props.title}                        </Text>                      </View>                      <View className="h-px bg-border" />                    </>                  ) : 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 (                      <Pressable                        key={action.id ?? `${index}-${action.title}`}                        disabled={disabled}                        className={cn(                          "min-h-11 flex-row items-center gap-2.5 px-3.5 py-2.5 active:bg-subtle",                          disabled && "opacity-45",                        )}                        onPress={() => onPressItem(action)}                      >                        <View className="flex-1 gap-0.5">                          <Text                            className={cn(                              // Same face as the pill labels that open these menus.                              "text-sm font-t3-bold",                              destructive && "text-danger-foreground",                            )}                          >                            {action.title}                          </Text>                          {action.subtitle ? (                            <Text className="text-xs leading-snug text-foreground-muted">                              {action.subtitle}                            </Text>                          ) : null}                        </View>                        {hasSubmenu ? (                          <SymbolView                            name="chevron.right"                            size={13}                            tintColorClassName={"accent-icon-subtle"}                            type="monochrome"                          />                        ) : action.state === "on" ? (                          <SymbolView                            name="checkmark"                            size={15}                            tintColorClassName={"accent-icon"}                            type="monochrome"                          />                        ) : action.image ? (                          <SymbolView                            name={action.image as AppSymbolName}                            size={15}                            tintColorClassName={                              destructive ? "accent-danger-foreground" : "accent-icon"                            }                            type="monochrome"                          />                        ) : null}                      </Pressable>                    );                  })}                </ScrollView>              </Animated.View>            )}          </View>        </OverlayPortal>      )}    </>  );}