Skip to content
Source
Sign inSign up

MenuPopup.android.tsx

186 lines6.5 KB

1import { NativeThemeHost } from "./NativeThemeHost.android";
2import {
3 Box,
4 Column,
5 DropdownMenu,
6 DropdownMenuItem,
7 RNHostView,
8 Text,
9} from "@expo/ui/jetpack-compose";
10import { defaultMinSize, padding, size, width } from "@expo/ui/jetpack-compose/modifiers";
11import type { MenuAction } from "@react-native-menu/menu";
12import { Pressable } from "react-native";
13import { resolveScaledTextRole } from "../../lib/appearancePreferences";
14​
15import { useAndroidControlSizing } from "./useAndroidControlSizing";
16import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider";
17import type { MenuPopupProps } from "./MenuPopup";
18import { isAppSymbolName, SymbolView, type AppSymbolName } from "./AppSymbol.android";
19​
20// The Compose glyph host consumes touches; run the action here and keep accessibility on the row.
21function MenuIcon(props: {
22 readonly name: AppSymbolName;
23 readonly color?: MenuAction["imageColor"];
24 readonly onPress: () => void;
25 readonly destructive?: boolean;
26 readonly disabled?: boolean;
27}) {
28 const { iconSize } = useAndroidControlSizing();
29 return (
30 <RNHostView matchContents modifiers={[size(iconSize, iconSize)]}>
31 <Pressable
32 accessible={false}
33 focusable={false}
34 disabled={props.disabled}
35 onPress={props.onPress}
36 style={{ width: iconSize, height: iconSize }}
37 importantForAccessibility="no-hide-descendants"
38 >
39 <SymbolView
40 name={props.name}
41 size={iconSize}
42 type="monochrome"
43 {...(typeof props.color === "string" && !props.disabled
44 ? { tintColor: props.color }
45 : {
46 tintColorClassName: props.disabled
47 ? "accent-icon-subtle"
48 : props.destructive
49 ? "accent-danger-foreground"
50 : "accent-foreground",
51 })}
52 />
53 </Pressable>
54 </RNHostView>
55 );
57​
58export function MenuPopupItems(
59 props: Pick<MenuPopupProps, "menuWidth" | "actions" | "title" | "parent" | "onPress" | "onBack">,
60) {
61 const { appearance, themeVariables: colors } = useAppearancePreferences();
62 const { scale, menuItemHeight } = useAndroidControlSizing();
63 const body = resolveScaledTextRole("body", appearance.baseFontSize);
64 const caption = resolveScaledTextRole("caption", appearance.baseFontSize);
65 const foreground = colors["--color-foreground"];
66 const muted = colors["--color-foreground-muted"];
67 // A fixed native item height clips wrapped labels; a minimum lets each row grow.
68 const itemModifiers = [width(props.menuWidth), defaultMinSize({ minHeight: menuItemHeight })];
69 return (
70 <>
71 {props.parent ? (
72 <DropdownMenuItem onClick={props.onBack} modifiers={itemModifiers}>
73 <DropdownMenuItem.LeadingIcon>
74 <MenuIcon name="arrow.left" onPress={props.onBack} />
75 </DropdownMenuItem.LeadingIcon>
76 <DropdownMenuItem.Text>
77 <Text color={foreground} style={{ typography: "bodyLarge", ...body }}>
78 {props.parent.title}
79 </Text>
80 </DropdownMenuItem.Text>
81 </DropdownMenuItem>
82 ) : props.title ? (
83 <Text
84 color={muted}
85 style={{ typography: "bodySmall", ...caption }}
86 modifiers={[padding(16 * scale, 8 * scale, 16 * scale, 8 * scale)]}
87 >
88 {props.title}
89 </Text>
90 ) : null}
91 {props.actions.map((action, index) => (
92 <DropdownMenuItem
93 key={action.id ?? `${index}-${action.title}`}
94 enabled={!action.attributes?.disabled}
95 modifiers={itemModifiers}
96 onClick={() => props.onPress(action)}
97 >
98 {action.image && isAppSymbolName(action.image) ? (
99 <DropdownMenuItem.LeadingIcon>
100 <MenuIcon
101 name={action.image}
102 color={action.imageColor}
103 onPress={() => props.onPress(action)}
104 destructive={action.attributes?.destructive}
105 disabled={action.attributes?.disabled}
106 />
107 </DropdownMenuItem.LeadingIcon>
108 ) : null}
109 <DropdownMenuItem.Text>
110 <Column>
111 <Text
112 style={{ typography: "bodyLarge", ...body }}
113 color={
114 action.attributes?.disabled
115 ? muted
116 : action.attributes?.destructive
117 ? colors["--color-danger-foreground"]
118 : foreground
119 }
120 >
121 {action.title}
122 </Text>
123 {action.subtitle ? (
124 <Text color={muted} style={{ typography: "bodySmall", ...caption }}>
125 {action.subtitle}
126 </Text>
127 ) : null}
128 </Column>
129 </DropdownMenuItem.Text>
130 {(action.subactions?.length ?? 0) > 0 ? (
131 <DropdownMenuItem.TrailingIcon>
132 <MenuIcon
133 name="chevron.right"
134 onPress={() => props.onPress(action)}
135 disabled={action.attributes?.disabled}
136 />
137 </DropdownMenuItem.TrailingIcon>
138 ) : action.state === "on" ? (
139 <DropdownMenuItem.TrailingIcon>
140 <MenuIcon
141 name="checkmark"
142 onPress={() => props.onPress(action)}
143 disabled={action.attributes?.disabled}
144 />
145 </DropdownMenuItem.TrailingIcon>
146 ) : null}
147 </DropdownMenuItem>
148 ))}
149 </>
150 );
152​
153export function MenuPopup(props: MenuPopupProps) {
154 const { themeVariables: colors } = useAppearancePreferences();
155 const items = <MenuPopupItems {...props} />;
156 if (props.inline) {
157 return (
158 <NativeThemeHost
159 ignoreSafeAreaKeyboardInsets
160 matchContents
161 style={{ width: props.menuWidth }}
162 >
163 <Column>{items}</Column>
164 </NativeThemeHost>
165 );
166 }
167 return (
168 <NativeThemeHost
169 ignoreSafeAreaKeyboardInsets
170 style={{
171 position: "absolute",
172 left: props.anchor.x,
173 top: props.anchor.y,
174 width: props.anchor.width,
175 height: props.anchor.height,
176 }}
177 >
178 <DropdownMenu expanded onDismissRequest={props.onClose} color={colors["--color-card-alt"]}>
179 <DropdownMenu.Trigger>
180 <Box modifiers={[size(props.anchor.width, props.anchor.height)]} />
181 </DropdownMenu.Trigger>
182 <DropdownMenu.Items>{items}</DropdownMenu.Items>
183 </DropdownMenu>
184 </NativeThemeHost>
185 );