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