Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 3 additions & 169 deletions apps/mobile/src/components/ControlPill.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MenuView> & {
readonly iconColor?: ColorValue;
readonly destructiveIconColor?: ColorValue;
}) {
const actions = useMemo(
() =>
withMenuActionIconColors(props.actions, {
icon: iconColor,
destructiveIcon: destructiveIconColor,
}),
[props.actions, iconColor, destructiveIconColor],
);
return <MenuView {...props} actions={actions} />;
},
{
iconColor: { fromClassName: "iconColorClassName", styleProperty: "accentColor" },
destructiveIconColor: {
fromClassName: "destructiveIconColorClassName",
styleProperty: "accentColor",
},
},
);
export { ControlPillMenu } from "./ControlPillMenu";

export function ControlPill(props: {
readonly icon?: ComponentProps<typeof SymbolView>["name"];
Expand Down Expand Up @@ -187,122 +140,3 @@ export function ControlPill(props: {
</Pressable>
);
}

// 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<ComponentProps<typeof MenuView>, "children" | "themeVariant"> &
Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
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 (
<AndroidAnchoredMenu
actions={props.actions}
className={props.className}
title={props.title}
style={props.style}
onPressAction={props.onPressAction}
>
{(open) =>
cloneElement(child, {
onLongPress: () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
open();
},
})
}
</AndroidAnchoredMenu>
);
}
return (
<AndroidAnchoredMenu
actions={props.actions}
className={props.className}
title={props.title}
style={props.style}
onPressAction={props.onPressAction}
>
{props.children}
</AndroidAnchoredMenu>
);
}

const { className: _className, ...menuProps } = props;
let children = menuProps.children;
if (props.shouldOpenOnLongPress && isValidElement(children)) {
const child = children as ReactElement<Pick<PressableProps, "onTouchStart" | "onPress">>;
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 (
<ThemedMenuView
{...menuProps}
iconColorClassName="accent-icon"
destructiveIconColorClassName="accent-danger-foreground"
themeVariant={isDarkMode ? "dark" : "light"}
>
{children}
</ThemedMenuView>
);
}
43 changes: 43 additions & 0 deletions apps/mobile/src/components/ControlPillMenu.android.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AndroidAnchoredMenu
actions={props.actions}
className={props.className}
title={props.title}
style={props.style}
onPressAction={props.onPressAction}
>
{(open) =>
cloneElement(child, {
onLongPress: () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
open();
},
})
}
</AndroidAnchoredMenu>
);
}
return (
<AndroidAnchoredMenu
actions={props.actions}
className={props.className}
title={props.title}
style={props.style}
onPressAction={props.onPressAction}
>
{props.children}
</AndroidAnchoredMenu>
);
}
115 changes: 115 additions & 0 deletions apps/mobile/src/components/ControlPillMenu.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MenuView> & {
readonly iconColor?: ColorValue;
readonly destructiveIconColor?: ColorValue;
}) {
const actions = useMemo(
() =>
withMenuActionIconColors(props.actions, {
icon: iconColor,
destructiveIcon: destructiveIconColor,
}),
[props.actions, iconColor, destructiveIconColor],
);
return <MenuView {...props} actions={actions} />;
},
{
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<Pick<PressableProps, "onTouchStart" | "onPress">>;
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 (
<ThemedMenuView
{...menuProps}
iconColorClassName="accent-icon"
destructiveIconColorClassName="accent-danger-foreground"
themeVariant={isDarkMode ? "dark" : "light"}
>
{children}
</ThemedMenuView>
);
}
12 changes: 12 additions & 0 deletions apps/mobile/src/components/ControlPillMenu.types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MenuView>,
"children" | "themeVariant"
> &
Pick<AccessibilityProps, "accessible" | "accessibilityLabel" | "accessibilityRole"> & {
readonly children: ReactNode;
readonly className?: string;
};
Loading