1const fs = require("node:fs");
2const path = require("node:path");
3const {
4 AndroidConfig,
5 withAndroidColors,
6 withAndroidColorsNight,
7 withAndroidStyles,
8 withDangerousMod,
9} = require("expo/config-plugins");
10
11// @react-native-menu/menu renders an AppCompat PopupMenu on Android, which
12// inherits the dated default popup chrome from the app theme. These resources
13// restyle it to match the app palette (global.css --color-card / --color-foreground)
14// with rounded corners, and anchor it below the button instead of overlapping it.
15
16const POPUP_BACKGROUND_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
17<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
18 <solid android:color="@color/popup_menu_background" />
19 <corners android:radius="12dp" />
20</shape>
21`;
22
23// Checkable menu rows insert a CheckBox at the row's right edge; the theme
24// swaps its square-box button for this check glyph so the selected option
25// shows a plain right-aligned check.
26const CHECK_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
27<vector xmlns:android="http://schemas.android.com/apk/res/android"
28 android:width="18dp"
29 android:height="18dp"
30 android:viewportWidth="24"
31 android:viewportHeight="24"
32 android:tint="@color/popup_menu_item_text">
33 <path
34 android:fillColor="#FFFFFFFF"
35 android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
36</vector>
37`;
38
39// CheckBox button drawable: check glyph when selected, nothing otherwise.
40const CHECKBOX_BUTTON_SELECTOR = `<?xml version="1.0" encoding="utf-8"?>
41<selector xmlns:android="http://schemas.android.com/apk/res/android">
42 <item android:state_checked="true" android:drawable="@drawable/ic_menu_check" />
43 <item android:drawable="@android:color/transparent" />
44</selector>
45`;
46
47// Replaces the default filled-triangle submenu indicator with a stroked ">"
48// chevron.
49const SUBMENU_ARROW_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
50<vector xmlns:android="http://schemas.android.com/apk/res/android"
51 android:width="20dp"
52 android:height="20dp"
53 android:viewportWidth="24"
54 android:viewportHeight="24"
55 android:alpha="0.6"
56 android:tint="@color/popup_menu_item_text">
57 <path
58 android:pathData="M9,6l6,6l-6,6"
59 android:strokeColor="#FFFFFFFF"
60 android:strokeWidth="2"
61 android:strokeLineCap="round"
62 android:strokeLineJoin="round"
63 android:fillColor="#00000000" />
64</vector>
65`;
66
67const COLORS = {
68 light: { background: "#F7F7F7", itemText: "#262626" },
69 night: { background: "#161616", itemText: "#F5F5F5" },
70};
71
72function assignStyleItem(style, name, value) {
73 style.item = style.item ?? [];
74 const existing = style.item.find((item) => item.$?.name === name);
75 if (existing) {
76 existing._ = value;
77 } else {
78 style.item.push({ _: value, $: { name } });
79 }
80}
81
82function withPopupMenuStyles(config) {
83 return withAndroidStyles(config, (config) => {
84 const resources = config.modResults.resources;
85 resources.style = resources.style ?? [];
86
87 const appTheme = resources.style.find((style) => style.$?.name === "AppTheme");
88 if (appTheme) {
89 assignStyleItem(appTheme, "popupMenuStyle", "@style/AppPopupMenu");
90 assignStyleItem(appTheme, "android:popupMenuStyle", "@style/AppPopupMenu");
91 assignStyleItem(
92 appTheme,
93 "textAppearanceLargePopupMenu",
94 "@style/AppPopupMenu.TextAppearance",
95 );
96 assignStyleItem(
97 appTheme,
98 "textAppearanceSmallPopupMenu",
99 "@style/AppPopupMenu.TextAppearance",
100 );
101 // Submenu popups show their parent item as a header row that reads a
102 // separate theme attribute, so it needs the same themed text color.
103 assignStyleItem(
104 appTheme,
105 "textAppearancePopupMenuHeader",
106 "@style/AppPopupMenu.HeaderTextAppearance",
107 );
108 // Menu item views resolve their submenu arrow from this style
109 // (android:listMenuViewStyle / android:subMenuArrow are public attrs).
110 assignStyleItem(appTheme, "android:listMenuViewStyle", "@style/AppPopupMenuListMenuView");
111 // Checkable rows inflate a plain CheckBox at the row end; restyle its
112 // button so the selected option shows a right-aligned check glyph
113 // instead of a square box. Both framework and AppCompat attrs are set
114 // since the popup may inflate either CheckBox flavor. App-wide for
115 // native checkboxes, which the app otherwise doesn't use.
116 assignStyleItem(appTheme, "android:checkboxStyle", "@style/AppPopupMenuCheckBox");
117 assignStyleItem(appTheme, "checkboxStyle", "@style/AppPopupMenuCheckBoxCompat");
118 }
119
120 resources.style = resources.style.filter(
121 (style) =>
122 ![
123 "AppPopupMenu",
124 "AppPopupMenu.TextAppearance",
125 "AppPopupMenu.HeaderTextAppearance",
126 "AppPopupMenuListMenuView",
127 "AppPopupMenuCheckBox",
128 "AppPopupMenuCheckBoxCompat",
129 ].includes(style.$?.name),
130 );
131 resources.style.push(
132 {
133 $: { name: "AppPopupMenu", parent: "Widget.AppCompat.PopupMenu" },
134 item: [
135 { _: "@drawable/popup_menu_background", $: { name: "android:popupBackground" } },
136 { _: "false", $: { name: "android:overlapAnchor" } },
137 { _: "4dp", $: { name: "android:dropDownVerticalOffset" } },
138 ],
139 },
140 {
141 $: { name: "AppPopupMenu.TextAppearance", parent: "TextAppearance.AppCompat.Menu" },
142 item: [
143 { _: "15sp", $: { name: "android:textSize" } },
144 { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } },
145 // DM Sans (--font-sans); embedded by the expo-font plugin config in
146 // app.config.ts.
147 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
148 ],
149 },
150 {
151 $: {
152 name: "AppPopupMenu.HeaderTextAppearance",
153 parent: "TextAppearance.AppCompat.Widget.PopupMenu.Header",
154 },
155 item: [
156 { _: "15sp", $: { name: "android:textSize" } },
157 { _: "@color/popup_menu_item_text", $: { name: "android:textColor" } },
158 { _: "@font/xml_dm_sans_regular", $: { name: "android:fontFamily" } },
159 ],
160 },
161 // The framework default (Widget.Material.ListMenuView) only carries
162 // subMenuArrow, so replacing the style wholesale is safe.
163 {
164 $: { name: "AppPopupMenuListMenuView" },
165 item: [{ _: "@drawable/popup_menu_submenu_arrow", $: { name: "android:subMenuArrow" } }],
166 },
167 {
168 $: {
169 name: "AppPopupMenuCheckBox",
170 parent: "android:Widget.Material.CompoundButton.CheckBox",
171 },
172 item: [{ _: "@drawable/popup_menu_check_button", $: { name: "android:button" } }],
173 },
174 {
175 $: {
176 name: "AppPopupMenuCheckBoxCompat",
177 parent: "Widget.AppCompat.CompoundButton.CheckBox",
178 },
179 item: [
180 { _: "@drawable/popup_menu_check_button", $: { name: "android:button" } },
181 // buttonCompat wins over android:button in AppCompatCheckBox.
182 { _: "@drawable/popup_menu_check_button", $: { name: "buttonCompat" } },
183 ],
184 },
185 );
186
187 return config;
188 });
189}
190
191function withPopupMenuColors(config) {
192 config = withAndroidColors(config, (config) => {
193 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
194 name: "popup_menu_background",
195 value: COLORS.light.background,
196 });
197 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
198 name: "popup_menu_item_text",
199 value: COLORS.light.itemText,
200 });
201 return config;
202 });
203 config = withAndroidColorsNight(config, (config) => {
204 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
205 name: "popup_menu_background",
206 value: COLORS.night.background,
207 });
208 config.modResults = AndroidConfig.Colors.assignColorValue(config.modResults, {
209 name: "popup_menu_item_text",
210 value: COLORS.night.itemText,
211 });
212 return config;
213 });
214 return config;
215}
216
217function withPopupMenuBackgroundDrawable(config) {
218 return withDangerousMod(config, [
219 "android",
220 async (config) => {
221 const drawableDir = path.join(
222 config.modRequest.platformProjectRoot,
223 "app",
224 "src",
225 "main",
226 "res",
227 "drawable",
228 );
229 fs.mkdirSync(drawableDir, { recursive: true });
230 fs.writeFileSync(
231 path.join(drawableDir, "popup_menu_background.xml"),
232 POPUP_BACKGROUND_DRAWABLE,
233 );
234 fs.writeFileSync(path.join(drawableDir, "ic_menu_check.xml"), CHECK_DRAWABLE);
235 fs.writeFileSync(
236 path.join(drawableDir, "popup_menu_check_button.xml"),
237 CHECKBOX_BUTTON_SELECTOR,
238 );
239 fs.writeFileSync(
240 path.join(drawableDir, "popup_menu_submenu_arrow.xml"),
241 SUBMENU_ARROW_DRAWABLE,
242 );
243 return config;
244 },
245 ]);
246}
247
248module.exports = function withAndroidModernPopupMenu(config) {
249 return withPopupMenuBackgroundDrawable(withPopupMenuColors(withPopupMenuStyles(config)));
250};