@expo/ui 56.0.6 → 56.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/android/build.gradle +2 -2
  3. package/android/src/main/java/expo/modules/ui/ExpoUIModule.kt +6 -2
  4. package/android/src/main/java/expo/modules/ui/HostView.kt +2 -0
  5. package/android/src/main/java/expo/modules/ui/ModifierRegistry.kt +19 -1
  6. package/android/src/main/java/expo/modules/ui/RNHostView.kt +8 -3
  7. package/android/src/main/java/expo/modules/ui/ShadowNodeSyncFlush.kt +28 -0
  8. package/build/community/menu/MenuView.android.d.ts +16 -0
  9. package/build/community/menu/MenuView.android.d.ts.map +1 -0
  10. package/build/community/menu/MenuView.d.ts +19 -0
  11. package/build/community/menu/MenuView.d.ts.map +1 -0
  12. package/build/community/menu/MenuView.ios.d.ts +10 -0
  13. package/build/community/menu/MenuView.ios.d.ts.map +1 -0
  14. package/build/community/menu/index.d.ts +5 -0
  15. package/build/community/menu/index.d.ts.map +1 -0
  16. package/build/community/menu/types.d.ts +166 -0
  17. package/build/community/menu/types.d.ts.map +1 -0
  18. package/build/jetpack-compose/modifiers/index.d.ts +15 -0
  19. package/build/jetpack-compose/modifiers/index.d.ts.map +1 -1
  20. package/build/swift-ui/Alert/index.d.ts +42 -0
  21. package/build/swift-ui/Alert/index.d.ts.map +1 -0
  22. package/build/swift-ui/SlotView.d.ts +5 -2
  23. package/build/swift-ui/SlotView.d.ts.map +1 -1
  24. package/build/swift-ui/SwipeActions/index.d.ts +38 -0
  25. package/build/swift-ui/SwipeActions/index.d.ts.map +1 -0
  26. package/build/swift-ui/TabView/index.d.ts +1 -1
  27. package/build/swift-ui/TabView/index.d.ts.map +1 -1
  28. package/build/swift-ui/index.d.ts +2 -0
  29. package/build/swift-ui/index.d.ts.map +1 -1
  30. package/build/swift-ui/modifiers/index.d.ts +3 -1
  31. package/build/swift-ui/modifiers/index.d.ts.map +1 -1
  32. package/build/swift-ui/modifiers/symbolEffect.d.ts +103 -0
  33. package/build/swift-ui/modifiers/symbolEffect.d.ts.map +1 -0
  34. package/build/universal/Column/index.android.d.ts.map +1 -1
  35. package/build/universal/Host/index.d.ts +17 -6
  36. package/build/universal/Host/index.d.ts.map +1 -1
  37. package/build/universal/Row/index.android.d.ts.map +1 -1
  38. package/build/universal/TextInput/types.d.ts +1 -1
  39. package/build/universal/TextInput/types.d.ts.map +1 -1
  40. package/expo-module.config.json +1 -1
  41. package/ios/Alert/Alert.swift +56 -0
  42. package/ios/Alert/AlertProps.swift +8 -0
  43. package/ios/ExpoUIModule.swift +2 -0
  44. package/ios/ExpoUITouchHandlerHelper.h +4 -1
  45. package/ios/ExpoUITouchHandlerHelper.mm +1 -0
  46. package/ios/Modifiers/SwipeActionsModifier.swift +97 -0
  47. package/ios/Modifiers/SymbolEffectModifier.swift +452 -0
  48. package/ios/Modifiers/ViewModifierRegistry.swift +4 -0
  49. package/ios/SlotView.swift +5 -0
  50. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{56.0.6/expo.modules.ui-56.0.6-sources.jar → 56.0.8/expo.modules.ui-56.0.8-sources.jar} +0 -0
  51. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8-sources.jar.md5 +1 -0
  52. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8-sources.jar.sha1 +1 -0
  53. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8-sources.jar.sha256 +1 -0
  54. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8-sources.jar.sha512 +1 -0
  55. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.aar +0 -0
  56. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.aar.md5 +1 -0
  57. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.aar.sha1 +1 -0
  58. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.aar.sha256 +1 -0
  59. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.aar.sha512 +1 -0
  60. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{56.0.6/expo.modules.ui-56.0.6.module → 56.0.8/expo.modules.ui-56.0.8.module} +22 -22
  61. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.module.md5 +1 -0
  62. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.module.sha1 +1 -0
  63. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.module.sha256 +1 -0
  64. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.module.sha512 +1 -0
  65. package/local-maven-repo/expo/modules/ui/expo.modules.ui/{56.0.6/expo.modules.ui-56.0.6.pom → 56.0.8/expo.modules.ui-56.0.8.pom} +1 -1
  66. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.pom.md5 +1 -0
  67. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.pom.sha1 +1 -0
  68. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.pom.sha256 +1 -0
  69. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.8/expo.modules.ui-56.0.8.pom.sha512 +1 -0
  70. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml +4 -4
  71. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.md5 +1 -1
  72. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha1 +1 -1
  73. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha256 +1 -1
  74. package/local-maven-repo/expo/modules/ui/expo.modules.ui/maven-metadata.xml.sha512 +1 -1
  75. package/package.json +7 -3
  76. package/src/community/menu/MenuView.android.tsx +224 -0
  77. package/src/community/menu/MenuView.ios.tsx +149 -0
  78. package/src/community/menu/MenuView.tsx +36 -0
  79. package/src/community/menu/index.tsx +14 -0
  80. package/src/community/menu/types.tsx +171 -0
  81. package/src/jetpack-compose/modifiers/index.ts +25 -0
  82. package/src/swift-ui/Alert/index.tsx +87 -0
  83. package/src/swift-ui/SlotView.tsx +17 -4
  84. package/src/swift-ui/SwipeActions/index.tsx +73 -0
  85. package/src/swift-ui/TabView/index.tsx +1 -1
  86. package/src/swift-ui/index.tsx +2 -0
  87. package/src/swift-ui/modifiers/index.ts +3 -0
  88. package/src/swift-ui/modifiers/symbolEffect.ts +181 -0
  89. package/src/ts-declarations/react-native-web.d.ts +20 -0
  90. package/src/universal/Column/index.android.tsx +1 -1
  91. package/src/universal/Host/index.tsx +70 -5
  92. package/src/universal/Row/index.android.tsx +1 -1
  93. package/src/universal/TextInput/types.ts +1 -1
  94. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6-sources.jar.md5 +0 -1
  95. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6-sources.jar.sha1 +0 -1
  96. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6-sources.jar.sha256 +0 -1
  97. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6-sources.jar.sha512 +0 -1
  98. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.aar +0 -0
  99. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.aar.md5 +0 -1
  100. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.aar.sha1 +0 -1
  101. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.aar.sha256 +0 -1
  102. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.aar.sha512 +0 -1
  103. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.module.md5 +0 -1
  104. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.module.sha1 +0 -1
  105. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.module.sha256 +0 -1
  106. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.module.sha512 +0 -1
  107. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.pom.md5 +0 -1
  108. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.pom.sha1 +0 -1
  109. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.pom.sha256 +0 -1
  110. package/local-maven-repo/expo/modules/ui/expo.modules.ui/56.0.6/expo.modules.ui-56.0.6.pom.sha512 +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/ui",
3
- "version": "56.0.6",
3
+ "version": "56.0.8",
4
4
  "description": "A collection of UI components",
5
5
  "sideEffects": [
6
6
  "*.fx.js"
@@ -51,6 +51,10 @@
51
51
  "types": "./build/community/masked-view/index.d.ts",
52
52
  "default": "./src/community/masked-view/index.tsx"
53
53
  },
54
+ "./community/menu": {
55
+ "types": "./build/community/menu/index.d.ts",
56
+ "default": "./src/community/menu/index.tsx"
57
+ },
54
58
  "./babel-plugin": {
55
59
  "types": "./plugin/babel-plugin.d.ts",
56
60
  "default": "./plugin/babel-plugin.js"
@@ -87,7 +91,7 @@
87
91
  "@types/react": "~19.2.0",
88
92
  "react-native-reanimated": "4.3.1",
89
93
  "react-native-worklets": "0.8.3",
90
- "expo": "56.0.0-preview.10",
94
+ "expo": "56.0.0-preview.12",
91
95
  "expo-module-scripts": "56.0.2"
92
96
  },
93
97
  "jest": {
@@ -116,7 +120,7 @@
116
120
  "optional": true
117
121
  }
118
122
  },
119
- "gitHead": "40f0a6f6711d93762e0506b37e6e077e4bd9a541",
123
+ "gitHead": "f26be3dd9396bf7c399a1d607865d0fabdbc0d64",
120
124
  "scripts": {
121
125
  "build": "expo-module build",
122
126
  "clean": "expo-module clean",
@@ -0,0 +1,224 @@
1
+ import * as React from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+
4
+ import type { MenuAction, MenuComponentProps, MenuComponentRef, NativeActionEvent } from './types';
5
+ import { HorizontalDivider } from '../../jetpack-compose/Divider';
6
+ import { DropdownMenu } from '../../jetpack-compose/DropdownMenu';
7
+ import {
8
+ DropdownMenuItem,
9
+ type DropdownMenuItemElementColors,
10
+ } from '../../jetpack-compose/DropdownMenu/DropdownMenuItem';
11
+ import { Host } from '../../jetpack-compose/Host';
12
+ import { Icon } from '../../jetpack-compose/Icon';
13
+ import { RNHostView } from '../../jetpack-compose/RNHostView';
14
+ import { Text as ComposeText } from '../../jetpack-compose/Text';
15
+ import { useMaterialColors } from '../../jetpack-compose/colors';
16
+
17
+ function actionId(action: MenuAction): string {
18
+ return action.id ?? action.title;
19
+ }
20
+
21
+ function makeEvent(action: MenuAction): NativeActionEvent {
22
+ return { nativeEvent: { event: actionId(action) } };
23
+ }
24
+
25
+ function buildElementColors(
26
+ action: MenuAction,
27
+ destructiveColor: string
28
+ ): DropdownMenuItemElementColors | undefined {
29
+ const isDestructive = action.attributes?.destructive === true;
30
+ const textColor = action.titleColor ?? (isDestructive ? destructiveColor : undefined);
31
+ // The leading icon picks up `leadingIconColor` via `LocalContentColor`, but only
32
+ // when the `Icon` itself doesn't set `tint` — so an explicit `imageColor` from
33
+ // the caller still wins.
34
+ const leadingIconColor = isDestructive ? destructiveColor : undefined;
35
+ if (textColor == null && leadingIconColor == null) {
36
+ return undefined;
37
+ }
38
+ return {
39
+ textColor,
40
+ disabledTextColor: textColor,
41
+ leadingIconColor,
42
+ disabledLeadingIconColor: leadingIconColor,
43
+ };
44
+ }
45
+
46
+ type ItemProps = {
47
+ action: MenuAction;
48
+ onPressAction: MenuComponentProps['onPressAction'];
49
+ dismissAll: () => void;
50
+ destructiveColor: string;
51
+ };
52
+
53
+ function MenuActionItem({ action, onPressAction, dismissAll, destructiveColor }: ItemProps) {
54
+ const [submenuExpanded, setSubmenuExpanded] = React.useState(false);
55
+
56
+ if (action.attributes?.hidden) {
57
+ return null;
58
+ }
59
+
60
+ const { subactions, displayInline, state, attributes, title, image, imageColor } = action;
61
+ // `image` is non-string only when caller passes an ImageSourcePropType
62
+ // (number from `require()` or `{ uri }`). The string form is iOS-only (SF Symbol).
63
+ const leadingIconSource = typeof image === 'string' || image == null ? null : image;
64
+ const elementColors = buildElementColors(action, destructiveColor);
65
+
66
+ if (subactions && subactions.length > 0) {
67
+ if (displayInline) {
68
+ return (
69
+ <>
70
+ <HorizontalDivider />
71
+ {subactions.map((sub) => (
72
+ <MenuActionItem
73
+ key={actionId(sub)}
74
+ action={sub}
75
+ onPressAction={onPressAction}
76
+ dismissAll={dismissAll}
77
+ destructiveColor={destructiveColor}
78
+ />
79
+ ))}
80
+ <HorizontalDivider />
81
+ </>
82
+ );
83
+ }
84
+ return (
85
+ <DropdownMenu expanded={submenuExpanded} onDismissRequest={() => setSubmenuExpanded(false)}>
86
+ <DropdownMenu.Trigger>
87
+ <DropdownMenuItem
88
+ enabled={!attributes?.disabled}
89
+ elementColors={elementColors}
90
+ onClick={() => setSubmenuExpanded(true)}>
91
+ <DropdownMenuItem.Text>
92
+ <ComposeText>{title}</ComposeText>
93
+ </DropdownMenuItem.Text>
94
+ {leadingIconSource && (
95
+ <DropdownMenuItem.LeadingIcon>
96
+ <Icon source={leadingIconSource} size={24} tint={imageColor} />
97
+ </DropdownMenuItem.LeadingIcon>
98
+ )}
99
+ </DropdownMenuItem>
100
+ </DropdownMenu.Trigger>
101
+ <DropdownMenu.Items>
102
+ {subactions.map((sub) => (
103
+ <MenuActionItem
104
+ key={actionId(sub)}
105
+ action={sub}
106
+ onPressAction={onPressAction}
107
+ dismissAll={() => {
108
+ setSubmenuExpanded(false);
109
+ dismissAll();
110
+ }}
111
+ destructiveColor={destructiveColor}
112
+ />
113
+ ))}
114
+ </DropdownMenu.Items>
115
+ </DropdownMenu>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <DropdownMenuItem
121
+ enabled={!attributes?.disabled}
122
+ elementColors={elementColors}
123
+ onClick={() => {
124
+ onPressAction?.(makeEvent(action));
125
+ dismissAll();
126
+ }}>
127
+ <DropdownMenuItem.Text>
128
+ <ComposeText>{title}</ComposeText>
129
+ </DropdownMenuItem.Text>
130
+ {leadingIconSource && (
131
+ <DropdownMenuItem.LeadingIcon>
132
+ <Icon source={leadingIconSource} size={24} tint={imageColor} />
133
+ </DropdownMenuItem.LeadingIcon>
134
+ )}
135
+ {state === 'on' && (
136
+ <DropdownMenuItem.TrailingIcon>
137
+ <ComposeText>✓</ComposeText>
138
+ </DropdownMenuItem.TrailingIcon>
139
+ )}
140
+ </DropdownMenuItem>
141
+ );
142
+ }
143
+
144
+ /**
145
+ * A drop-in replacement for `@react-native-menu/menu` on Android.
146
+ * Wraps the trigger in a `Pressable` (whose `onPress`/`onLongPress` opens the menu) and
147
+ * renders the actions tree as a controlled Material `DropdownMenu`.
148
+ *
149
+ * Note: when `action.image` is a string, it is treated as an iOS SF Symbol and ignored
150
+ * on Android — pass an `ImageSourcePropType` (e.g. `require('./icon.xml')`) to render
151
+ * a leading icon. `MenuView.title` is also unused on Android since Material
152
+ * `DropdownMenu` has no title slot.
153
+ */
154
+ export function MenuView(props: MenuComponentProps & { ref?: React.Ref<MenuComponentRef> }) {
155
+ const {
156
+ ref,
157
+ actions,
158
+ onPressAction,
159
+ onOpenMenu,
160
+ onCloseMenu,
161
+ shouldOpenOnLongPress,
162
+ style,
163
+ children,
164
+ testID,
165
+ } = props;
166
+ const [expanded, setExpanded] = React.useState(false);
167
+ // Mirror `expanded` into a ref and flip it eagerly inside the callbacks so a
168
+ // second call within the same React tick is a no-op (the state update is
169
+ // async and wouldn't otherwise reflect back to the ref in time).
170
+ const expandedRef = React.useRef(false);
171
+
172
+ const open = React.useCallback(() => {
173
+ if (expandedRef.current) return;
174
+ expandedRef.current = true;
175
+ setExpanded(true);
176
+ onOpenMenu?.();
177
+ }, [onOpenMenu]);
178
+
179
+ const dismissAll = React.useCallback(() => {
180
+ if (!expandedRef.current) return;
181
+ expandedRef.current = false;
182
+ setExpanded(false);
183
+ onCloseMenu?.();
184
+ }, [onCloseMenu]);
185
+
186
+ React.useImperativeHandle(ref, () => ({ show: open }), [open]);
187
+
188
+ const destructiveColor = useMaterialColors().error;
189
+
190
+ return (
191
+ <View style={style} testID={testID}>
192
+ <Host matchContents>
193
+ <DropdownMenu expanded={expanded} onDismissRequest={dismissAll}>
194
+ <DropdownMenu.Trigger>
195
+ <RNHostView matchContents>
196
+ <Pressable
197
+ onPress={shouldOpenOnLongPress ? undefined : open}
198
+ onLongPress={shouldOpenOnLongPress ? open : undefined}
199
+ // Mirror upstream `@react-native-menu/menu`, which intercepts touches
200
+ // natively on a bare `ReactViewGroup`: no click sound, no focus
201
+ // highlight, no extra a11y node — children declare their own role.
202
+ android_disableSound
203
+ focusable={false}
204
+ accessible={false}>
205
+ {children}
206
+ </Pressable>
207
+ </RNHostView>
208
+ </DropdownMenu.Trigger>
209
+ <DropdownMenu.Items>
210
+ {actions.map((action) => (
211
+ <MenuActionItem
212
+ key={actionId(action)}
213
+ action={action}
214
+ onPressAction={onPressAction}
215
+ dismissAll={dismissAll}
216
+ destructiveColor={destructiveColor}
217
+ />
218
+ ))}
219
+ </DropdownMenu.Items>
220
+ </DropdownMenu>
221
+ </Host>
222
+ </View>
223
+ );
224
+ }
@@ -0,0 +1,149 @@
1
+ import * as React from 'react';
2
+
3
+ import type { MenuAction, MenuComponentProps, MenuComponentRef, NativeActionEvent } from './types';
4
+ import { Button } from '../../swift-ui/Button';
5
+ import { ContextMenu } from '../../swift-ui/ContextMenu';
6
+ import { Host } from '../../swift-ui/Host';
7
+ import { Menu } from '../../swift-ui/Menu';
8
+ import { RNHostView } from '../../swift-ui/RNHostView';
9
+ import { Section } from '../../swift-ui/Section';
10
+ import { Toggle } from '../../swift-ui/Toggle';
11
+ import {
12
+ disabled as disabledModifier,
13
+ foregroundColor as foregroundColorModifier,
14
+ tint as tintModifier,
15
+ } from '../../swift-ui/modifiers';
16
+ import type { ModifierConfig } from '../../types';
17
+
18
+ function actionId(action: MenuAction): string {
19
+ return action.id ?? action.title;
20
+ }
21
+
22
+ function makeEvent(action: MenuAction): NativeActionEvent {
23
+ return { nativeEvent: { event: actionId(action) } };
24
+ }
25
+
26
+ function renderAction(
27
+ action: MenuAction,
28
+ onPressAction: MenuComponentProps['onPressAction']
29
+ ): React.ReactNode {
30
+ if (action.attributes?.hidden) {
31
+ return null;
32
+ }
33
+
34
+ const { subactions, displayInline, state, attributes, image, imageColor, title } = action;
35
+ const key = actionId(action);
36
+ const systemImage = typeof image === 'string' ? image : undefined;
37
+ // `tint` is what SwiftUI's `Menu`/`Button` honor for the leading SF Symbol —
38
+ // `foregroundColor` would also affect the label text.
39
+ const tintMod = imageColor ? tintModifier(imageColor) : null;
40
+
41
+ if (subactions && subactions.length > 0) {
42
+ const children = subactions.map((sub) => renderAction(sub, onPressAction));
43
+ if (displayInline) {
44
+ return (
45
+ <Section key={key} title={title}>
46
+ {children}
47
+ </Section>
48
+ );
49
+ }
50
+ // SwiftUI's system menu UI ignores per-item color modifiers on submenu
51
+ // headers, so we don't forward `imageColor` here. Leaf `Button`s below
52
+ // tint via `foregroundColor`, which does take effect.
53
+ return (
54
+ <Menu key={key} label={title} systemImage={systemImage}>
55
+ {children}
56
+ </Menu>
57
+ );
58
+ }
59
+
60
+ const fire = () => onPressAction?.(makeEvent(action));
61
+
62
+ const modifiers: ModifierConfig[] = [];
63
+ if (attributes?.disabled) modifiers.push(disabledModifier(true));
64
+
65
+ if (state === 'on' || state === 'off') {
66
+ if (tintMod) modifiers.push(tintMod);
67
+ return (
68
+ <Toggle
69
+ key={key}
70
+ label={title}
71
+ systemImage={systemImage}
72
+ isOn={state === 'on'}
73
+ onIsOnChange={fire}
74
+ modifiers={modifiers.length > 0 ? modifiers : undefined}
75
+ />
76
+ );
77
+ }
78
+
79
+ // For a leaf `Button`, `foregroundColor` also tints the system image —
80
+ // upstream uses this to color both the label and the icon. Skip when the
81
+ // role is destructive so SwiftUI's red tint remains in effect.
82
+ if (imageColor && !attributes?.destructive) {
83
+ modifiers.push(foregroundColorModifier(imageColor));
84
+ }
85
+
86
+ return (
87
+ <Button
88
+ key={key}
89
+ label={title}
90
+ systemImage={systemImage}
91
+ role={attributes?.destructive ? 'destructive' : undefined}
92
+ modifiers={modifiers.length > 0 ? modifiers : undefined}
93
+ onPress={fire}
94
+ />
95
+ );
96
+ }
97
+
98
+ let warnedShowNoop = false;
99
+
100
+ /**
101
+ * A drop-in replacement for `@react-native-menu/menu` on iOS.
102
+ * Uses SwiftUI `Menu` for tap triggers and `ContextMenu` for long-press triggers.
103
+ */
104
+ export function MenuView(props: MenuComponentProps & { ref?: React.Ref<MenuComponentRef> }) {
105
+ const { ref, actions, onPressAction, shouldOpenOnLongPress, title, style, children, testID } =
106
+ props;
107
+
108
+ // SwiftUI `Menu`/`ContextMenu` expose no programmatic open API, so `show()`
109
+ // can't do anything on iOS. Surface that as a one-time dev warning instead of
110
+ // silently no-opping.
111
+ React.useImperativeHandle(
112
+ ref,
113
+ () => ({
114
+ show: () => {
115
+ if (__DEV__ && !warnedShowNoop) {
116
+ warnedShowNoop = true;
117
+ console.warn(
118
+ '[@expo/ui] MenuView.show() is a no-op on iOS. SwiftUI Menu/ContextMenu have no programmatic open API.'
119
+ );
120
+ }
121
+ },
122
+ }),
123
+ []
124
+ );
125
+
126
+ const items = actions.map((action) => renderAction(action, onPressAction));
127
+ const body = title ? <Section title={title}>{items}</Section> : items;
128
+
129
+ // RNHostView requires a single ReactElement; wrap in a fragment so callers
130
+ // can pass any `ReactNode` (string, array, etc.).
131
+ const trigger = (
132
+ <RNHostView matchContents>
133
+ <>{children}</>
134
+ </RNHostView>
135
+ );
136
+
137
+ return (
138
+ <Host matchContents style={style} testID={testID}>
139
+ {shouldOpenOnLongPress ? (
140
+ <ContextMenu>
141
+ <ContextMenu.Trigger>{trigger}</ContextMenu.Trigger>
142
+ <ContextMenu.Items>{body}</ContextMenu.Items>
143
+ </ContextMenu>
144
+ ) : (
145
+ <Menu label={trigger}>{body}</Menu>
146
+ )}
147
+ </Host>
148
+ );
149
+ }
@@ -0,0 +1,36 @@
1
+ import { type Ref, useEffect, useImperativeHandle } from 'react';
2
+ import { View } from 'react-native';
3
+
4
+ import type { MenuComponentProps, MenuComponentRef } from './types';
5
+
6
+ let warned = false;
7
+
8
+ /**
9
+ * A drop-in replacement for `@react-native-menu/menu`'s `MenuView`. Wrap any trigger
10
+ * view; long-pressing or tapping (per `shouldOpenOnLongPress`) shows a popup menu
11
+ * built from the `actions` tree.
12
+ *
13
+ * - On Android, renders via Compose's `DropdownMenu` anchored to a `Pressable`.
14
+ * - On iOS, renders via SwiftUI's `Menu` (tap) or `ContextMenu` (long-press).
15
+ * - On web, the trigger renders the trigger but actions do not fire;
16
+ * a one-time `console.warn` is emitted.
17
+ *
18
+ * @platform android
19
+ * @platform ios
20
+ */
21
+ export function MenuView(props: MenuComponentProps & { ref?: Ref<MenuComponentRef> }) {
22
+ useEffect(() => {
23
+ if (!warned) {
24
+ warned = true;
25
+ console.warn(
26
+ "[@expo/ui] MenuView is currently Android and iOS-only; the trigger will render but actions won't fire on this platform."
27
+ );
28
+ }
29
+ }, []);
30
+ useImperativeHandle(props.ref, () => ({ show: () => {} }), []);
31
+ return (
32
+ <View style={props.style} testID={props.testID}>
33
+ {props.children}
34
+ </View>
35
+ );
36
+ }
@@ -0,0 +1,14 @@
1
+ import { MenuView } from './MenuView';
2
+
3
+ export type {
4
+ MenuAction,
5
+ MenuAttributes,
6
+ MenuComponentProps,
7
+ MenuState,
8
+ MenuComponentRef,
9
+ NativeActionEvent,
10
+ } from './types';
11
+
12
+ export default MenuView;
13
+ // named export needed for docs generator
14
+ export { MenuView };
@@ -0,0 +1,171 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ColorValue, ImageSourcePropType, StyleProp, ViewStyle } from 'react-native';
3
+ import type { SFSymbol } from 'sf-symbols-typescript';
4
+
5
+ /**
6
+ * Visual and behavioral attributes of a menu action.
7
+ * Compatible with `@react-native-menu/menu`.
8
+ */
9
+ export type MenuAttributes = {
10
+ /**
11
+ * Renders the action with a destructive style (red text/icon).
12
+ */
13
+ destructive?: boolean;
14
+ /**
15
+ * Disables the action so it can't be activated.
16
+ */
17
+ disabled?: boolean;
18
+ /**
19
+ * Hides the action from the menu.
20
+ */
21
+ hidden?: boolean;
22
+ };
23
+
24
+ /**
25
+ * Selection state for a menu action.
26
+ * `'on'` renders a checkmark; `'off'` doesn't.
27
+ */
28
+ export type MenuState = 'on' | 'off';
29
+
30
+ /**
31
+ * A single action inside a `MenuView`.
32
+ * Compatible with `@react-native-menu/menu`.
33
+ */
34
+ export type MenuAction = {
35
+ /**
36
+ * Identifier passed back via `onPressAction.nativeEvent.event` when this action is selected.
37
+ * Defaults to `title` if omitted.
38
+ */
39
+ id?: string;
40
+ /**
41
+ * Action label shown in the menu.
42
+ */
43
+ title: string;
44
+ /**
45
+ * Text color of the action label.
46
+ * @platform android
47
+ */
48
+ titleColor?: ColorValue;
49
+ /**
50
+ * Icon to render beside the action label.
51
+ *
52
+ * - When an `SFSymbol` name (e.g. `'trash'`), rendered on iOS only.
53
+ * Not rendered on Android — pass an `ImageSourcePropType` instead to show an
54
+ * icon there.
55
+ * - When an `ImageSourcePropType` (e.g. `require('./trash.xml')` or
56
+ * `{ uri: '...' }`), rendered on Android via Compose `Icon`. Ignored on iOS;
57
+ * SwiftUI menus only accept SF Symbol names for built-in `Menu`/`Button`
58
+ * labels.
59
+ */
60
+ image?: SFSymbol | ImageSourcePropType;
61
+ /**
62
+ * Tint color applied to the action's icon.
63
+ *
64
+ * Visually applied on Android via the leading `Icon`'s tint. On iOS, the
65
+ * value is accepted but **may not render**: SwiftUI's `Menu`/`ContextMenu`
66
+ * draw their items via the system menu UI, which ignores per-item color
67
+ * modifiers.
68
+ */
69
+ imageColor?: ColorValue;
70
+ /**
71
+ * Selection state. When `'on'`, the action renders a checkmark.
72
+ */
73
+ state?: MenuState;
74
+ /**
75
+ * Visual/behavioral flags.
76
+ */
77
+ attributes?: MenuAttributes;
78
+ /**
79
+ * Nested actions. Without `displayInline`, renders as a submenu;
80
+ * with `displayInline: true`, renders as an inline section.
81
+ */
82
+ subactions?: MenuAction[];
83
+ /**
84
+ * When `true` and `subactions` is present, renders the children as an inline section
85
+ * inside the parent menu (with this action's `title` as the section header on iOS).
86
+ */
87
+ displayInline?: boolean;
88
+ };
89
+
90
+ /**
91
+ * Imperative handle exposed by `MenuView` via `ref`.
92
+ * Compatible with `@react-native-menu/menu`'s `ref.show()` API.
93
+ */
94
+ export type MenuComponentRef = {
95
+ /**
96
+ * Programmatically open the menu.
97
+ *
98
+ * On Android, opens the anchored `DropdownMenu` (equivalent to the user tapping
99
+ * the trigger). On iOS this is a no-op — SwiftUI `Menu`/`ContextMenu` have no
100
+ * programmatic open API; a one-time `console.warn` is emitted in development.
101
+ * @platform android
102
+ */
103
+ show: () => void;
104
+ };
105
+
106
+ /**
107
+ * Event payload delivered to `onPressAction` when an action is selected.
108
+ * Compatible with `@react-native-menu/menu`.
109
+ */
110
+ export type NativeActionEvent = {
111
+ nativeEvent: {
112
+ /** Identifier of the pressed action: `action.id ?? action.title`. */
113
+ event: string;
114
+ };
115
+ };
116
+
117
+ /**
118
+ * Props for the `MenuView` component.
119
+ * Drop-in compatible with `@react-native-menu/menu`.
120
+ */
121
+ export type MenuComponentProps = {
122
+ /**
123
+ * Menu title shown at the top of the menu.
124
+ * @platform ios
125
+ */
126
+ title?: string;
127
+ /**
128
+ * Callback invoked when a menu action is selected.
129
+ */
130
+ onPressAction?: (event: NativeActionEvent) => void;
131
+ /**
132
+ * Callback invoked when the menu opens.
133
+ *
134
+ * On Android, fires when the trigger's tap/long-press flips `expanded` to `true`.
135
+ * On iOS, SwiftUI `Menu`/`ContextMenu` do not expose an open hook, so this is not
136
+ * fired there.
137
+ * @platform android
138
+ */
139
+ onOpenMenu?: () => void;
140
+ /**
141
+ * Callback invoked when the menu closes (either via dismissal or after an action
142
+ * fires).
143
+ *
144
+ * On Android, fires from the controlled `DropdownMenu`'s dismiss path.
145
+ * On iOS, SwiftUI `Menu`/`ContextMenu` do not expose a close hook in a way we can
146
+ * forward, so this is not fired there.
147
+ * @platform android
148
+ */
149
+ onCloseMenu?: () => void;
150
+ /**
151
+ * The actions to display in the menu.
152
+ */
153
+ actions: MenuAction[];
154
+ /**
155
+ * When `true`, the menu opens on long-press of the trigger instead of a single tap.
156
+ * @default false
157
+ */
158
+ shouldOpenOnLongPress?: boolean;
159
+ /**
160
+ * Style applied to the trigger wrapper.
161
+ */
162
+ style?: StyleProp<ViewStyle>;
163
+ /**
164
+ * Test identifier passed through to the trigger view.
165
+ */
166
+ testID?: string;
167
+ /**
168
+ * Trigger view. Long-pressing or tapping (per `shouldOpenOnLongPress`) opens the menu.
169
+ */
170
+ children?: ReactNode;
171
+ };
@@ -291,6 +291,31 @@ export const clickable = (handler: () => void, options?: { indication?: boolean
291
291
  indication: options?.indication ?? true,
292
292
  });
293
293
 
294
+ /**
295
+ * Makes the view respond to both click and long-click gestures.
296
+ * Wraps Compose's `Modifier.combinedClickable`. Useful for triggering a `DropdownMenu`
297
+ * on long-press while keeping a separate short-press action.
298
+ * @param handlers.onClick - Function to call on a short tap.
299
+ * @param handlers.onLongClick - Function to call on a long press.
300
+ * @param options - Optional configuration.
301
+ * @param options.indication - Whether to show a ripple indication. Defaults to `true`.
302
+ */
303
+ export const combinedClickable = (
304
+ handlers: { onClick?: () => void; onLongClick?: () => void },
305
+ options?: { indication?: boolean }
306
+ ) =>
307
+ createModifierWithEventListener(
308
+ 'combinedClickable',
309
+ (params: { event: 'click' | 'longClick' }) => {
310
+ if (params.event === 'click') {
311
+ handlers.onClick?.();
312
+ } else if (params.event === 'longClick') {
313
+ handlers.onLongClick?.();
314
+ }
315
+ },
316
+ { indication: options?.indication ?? true }
317
+ );
318
+
294
319
  /**
295
320
  * Makes the view selectable, like a radio button row.
296
321
  * @param selected - Whether the item is currently selected.